Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 99 additions & 26 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";
Expand Down Expand Up @@ -342,6 +343,11 @@ const git = (
return result.stdout.trim();
});

const [, gitMajor, gitMinor] =
/(\d+)\.(\d+)/u.exec(NodeChildProcess.execFileSync("git", ["--version"], { encoding: "utf8" })) ??
[];
const gitHasReftable = Number(gitMajor) > 2 || (Number(gitMajor) === 2 && Number(gitMinor) >= 45);

const gitWithStdin = (
cwd: string,
args: ReadonlyArray<string>,
Expand Down Expand Up @@ -388,11 +394,11 @@ const initRepoWithCommit = (
return { initialBranch: head.trim().replace(/^ref: refs\/heads\//, "") };
});

const commitInitialRepo = (cwd: string) =>
const commitInitialRepo = (cwd: string, initArgs: ReadonlyArray<string> = []) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
yield* git(cwd, ["init"]);
yield* git(cwd, ["init", ...initArgs]);
yield* fileSystem.writeFileString(
pathService.join(cwd, ".git", "config"),
"[user]\n\temail = test@test.com\n\tname = Test\n",
Expand Down Expand Up @@ -7178,41 +7184,108 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
);

it.effect.each([
{ operation: "cherry-pick", head: "CHERRY_PICK_HEAD" },
{ operation: "revert", head: "REVERT_HEAD" },
])("rejects selected-file commits during a $operation", ({ operation, head }) =>
{ operation: "cherry-pick", head: "CHERRY_PICK_HEAD", refs: "files" },
{ operation: "revert", head: "REVERT_HEAD", refs: "files" },
// Reftable keeps CHERRY_PICK_HEAD and REVERT_HEAD as refs with no file under .git.
{ operation: "cherry-pick", head: "CHERRY_PICK_HEAD", refs: "reftable" },
{ operation: "revert", head: "REVERT_HEAD", refs: "reftable" },
])(
"rejects selected-file commits during a $operation ($refs)",
({ operation, head, refs }, ctx) =>
Effect.gen(function* () {
if (refs === "reftable" && !gitHasReftable)
return ctx.skip("Needs Git 2.45 or later for reftable.");
const cwd = yield* makeTmpDir();
if (refs === "reftable") yield* commitInitialRepo(cwd, ["--ref-format=reftable"]);
else yield* initRepoWithCommit(cwd);
// Reftable leaves .git/HEAD as a stub, so ask Git for the branch.
const initialBranch = yield* git(cwd, ["symbolic-ref", "--short", "HEAD"]);
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* git(cwd, ["checkout", "-b", "other"]);
yield* writeTextFile(cwd, "README.md", "other version\n");
yield* writeTextFile(cwd, "picked.txt", "picked\n");
yield* git(cwd, ["add", "."]);
yield* git(cwd, ["commit", "-m", "Other"]);
if (operation === "cherry-pick") yield* git(cwd, ["checkout", initialBranch]);
yield* writeTextFile(cwd, "README.md", "main version\n");
yield* git(cwd, ["commit", "-am", "Main"]);
const originalHead = yield* git(cwd, ["rev-parse", "HEAD"]);
yield* git(cwd, ["branch", head]);
const result = yield* driver.execute({
operation: `test.${operation}`,
cwd,
args: [operation, operation === "cherry-pick" ? "other" : "HEAD~1"],
allowNonZeroExit: true,
});
assert.equal(result.exitCode, 1);
yield* writeTextFile(cwd, "README.md", "resolved version\n");
yield* git(cwd, ["add", "README.md"]);
const selected = operation === "cherry-pick" ? "picked.txt" : "README.md";

const commit = yield* driver
.commit(cwd, "Partial", "", { stage: { filePaths: [selected] } })
.pipe(Effect.result);

assert.isTrue(Result.isFailure(commit), `Selected-file ${operation} commit succeeded`);
assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), originalHead);
assert.equal(
yield* git(cwd, [
"-c",
"core.warnAmbiguousRefs=false",
"rev-parse",
"--verify",
"--symbolic-full-name",
head,
]),
head,
);
}),
);

it.effect("rejects selected-file commits during a multi-commit no-commit cherry-pick", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* git(cwd, ["checkout", "-b", "other"]);
yield* writeTextFile(cwd, "README.md", "other version\n");
yield* writeTextFile(cwd, "picked.txt", "picked\n");
const path = yield* Path.Path;
yield* writeTextFile(cwd, "a.txt", "base a\n");
yield* writeTextFile(cwd, "b.txt", "base b\n");
yield* git(cwd, ["add", "."]);
yield* git(cwd, ["commit", "-m", "Other"]);
if (operation === "cherry-pick") yield* git(cwd, ["checkout", initialBranch]);
yield* writeTextFile(cwd, "README.md", "main version\n");
yield* git(cwd, ["commit", "-m", "Base"]);
yield* git(cwd, ["checkout", "-b", "feature"]);
yield* writeTextFile(cwd, "a.txt", "feature a\n");
yield* writeTextFile(cwd, "b.txt", "feature b\n");
yield* git(cwd, ["commit", "-am", "Feature one"]);
yield* writeTextFile(cwd, "later.txt", "later\n");
yield* git(cwd, ["add", "."]);
yield* git(cwd, ["commit", "-m", "Feature two"]);
yield* git(cwd, ["checkout", initialBranch]);
yield* writeTextFile(cwd, "a.txt", "main a\n");
yield* writeTextFile(cwd, "b.txt", "main b\n");
yield* git(cwd, ["commit", "-am", "Main"]);
const originalHead = yield* git(cwd, ["rev-parse", "HEAD"]);
const result = yield* driver.execute({
operation: `test.${operation}`,
const head = yield* git(cwd, ["rev-parse", "HEAD"]);
const started = yield* driver.execute({
operation: "test.sequencer",
cwd,
args: [operation, operation === "cherry-pick" ? "other" : "HEAD~1"],
args: ["cherry-pick", "--no-commit", "feature~1", "feature"],
allowNonZeroExit: true,
});
assert.equal(result.exitCode, 1);
yield* writeTextFile(cwd, "README.md", "resolved version\n");
yield* git(cwd, ["add", "README.md"]);
assert.equal(started.exitCode, 1);
yield* writeTextFile(cwd, "a.txt", "resolved a\n");
const gitDir = yield* git(cwd, ["rev-parse", "--absolute-git-dir"]);
const selected = operation === "cherry-pick" ? "picked.txt" : "README.md";
assert.isFalse(NodeFS.existsSync(path.join(gitDir, "CHERRY_PICK_HEAD")));
const names = ["index", "sequencer/todo", "sequencer/head"];
const before = names.map((name) => NodeFS.readFileSync(path.join(gitDir, name)));

const commit = yield* driver
.commit(cwd, "Partial", "", { stage: { filePaths: [selected] } })
.pipe(Effect.result);

assert.isTrue(Result.isFailure(commit), `Selected-file ${operation} commit succeeded`);
assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), originalHead);
assert.isTrue(NodeFS.existsSync(`${gitDir}/${head}`));
const error = yield* driver
.commit(cwd, "Partial", "", { stage: { filePaths: ["a.txt"] } })
.pipe(Effect.flip);
assert.include(error.detail, "Cannot commit selected files during a cherry-pick or revert");
assert.deepEqual(
names.map((name) => NodeFS.readFileSync(path.join(gitDir, name))),
before,
);
assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), head);
}),
);

Expand Down
30 changes: 28 additions & 2 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2534,14 +2534,40 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
{ head: "MERGE_HEAD", operation: "merge" },
{ head: "CHERRY_PICK_HEAD", operation: "cherry-pick" },
{ head: "REVERT_HEAD", operation: "revert" },
// A conflicted multi-commit `cherry-pick --no-commit` writes only the sequencer; reset
// would clear its unmerged entries, so `--continue` would walk past the conflict.
{ head: "sequencer", operation: "cherry-pick or revert" },
] as const;
const headPaths = yield* runGitStdout("GitVcsDriver.commit.mergeHead", cwd, [
"rev-parse",
...inProgressHeads.flatMap(({ head }) => ["--git-path", head]),
]);
const resolvedHeadPaths = headPaths.trim().split("\n");
const inProgress = yield* Effect.findFirst(inProgressHeads, (_, index) =>
fileSystem.exists(path.resolve(cwd, resolvedHeadPaths[index] ?? "")),
// Reftable keeps CHERRY_PICK_HEAD and REVERT_HEAD as refs with no file under .git. A
// same-named branch resolves to its full name; an ambiguity warning would blank stdout.
const operationRefs = new Set<string>(["CHERRY_PICK_HEAD", "REVERT_HEAD"]);
const isOperationRef = (head: string) =>
runGitStdout(
"GitVcsDriver.commit.operationRef",
cwd,
[
"-c",
"core.warnAmbiguousRefs=false",
"rev-parse",
"--verify",
"--quiet",
"--symbolic-full-name",
head,
],
true,
).pipe(Effect.map((ref) => ref.trim() === head));
const inProgress = yield* Effect.findFirst(inProgressHeads, ({ head }, index) =>
fileSystem.exists(path.resolve(cwd, resolvedHeadPaths[index] ?? "")).pipe(
Effect.filterOrElse(
(exists) => exists || !operationRefs.has(head),
() => isOperationRef(head),
),
),
).pipe(
Effect.mapError(
(cause) =>
Expand Down
Loading