From b1c1c883b46078bd6381c979f1472ef5c191c276 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 14:19:14 +0000 Subject: [PATCH 1/8] fix(server): selected-file commits keep cherry-pick and revert state Committing selected files resets the index first, and git reset deletes CHERRY_PICK_HEAD and REVERT_HEAD. A partial commit during a cherry-pick then lost the original author and ended the pick. Reject selected-file commits during a cherry-pick or revert, as for merges. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 55 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 46 ++++++++-------- 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d9a48457f8a38..723e986b44c64 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5052,6 +5052,61 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.each([ + { operation: "cherry-pick", stateRef: "CHERRY_PICK_HEAD", author: "Original Author" }, + { operation: "revert", stateRef: "REVERT_HEAD", author: "Test" }, + ] as const)( + "rejects selected-file commits during a $operation without losing its state", + ({ operation, stateRef, author }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature"]); + yield* writeTextFile(cwd, "README.md", "feature version\n"); + yield* writeTextFile(cwd, "picked.txt", "picked\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Feature", "--author", "Original Author "]); + const featureHead = yield* git(cwd, ["rev-parse", "HEAD"]); + if (operation === "cherry-pick") yield* git(cwd, ["checkout", initialBranch]); + yield* writeTextFile(cwd, "README.md", "other version\n"); + yield* git(cwd, ["commit", "-am", "Other"]); + const head = yield* git(cwd, ["rev-parse", "HEAD"]); + const started = yield* driver.execute({ + operation: "test.sequencer", + cwd, + args: [operation, "--no-edit", featureHead], + allowNonZeroExit: true, + }); + assert.equal(started.exitCode, 1); + yield* writeTextFile(cwd, "README.md", "resolved version\n"); + const gitDir = yield* git(cwd, ["rev-parse", "--absolute-git-dir"]); + const names = ["index", stateRef, "MERGE_MSG"]; + const before = names.map((name) => NodeFS.readFileSync(`${gitDir}/${name}`)); + + const result = yield* driver + .commit(cwd, "Partial", "", { stage: { filePaths: ["picked.txt"] } }) + .pipe(Effect.result); + if (Result.isSuccess(result)) { + assert.fail(`Selected ${operation} commit succeeded`); + } else { + assert.include( + result.failure.detail, + `Cannot commit selected files during a ${operation}`, + ); + } + assert.deepEqual( + names.map((name) => NodeFS.readFileSync(`${gitDir}/${name}`)), + before, + ); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), head); + + yield* driver.commit(cwd, "Finish", "", { stage: {} }); + assert.equal(yield* git(cwd, ["show", "-s", "--format=%an", "HEAD"]), author); + assert.isFalse(NodeFS.existsSync(`${gitDir}/${stateRef}`)); + }), + ); + it.effect("treats selected file paths literally", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 11580f4c26e18..6a2eeb931da74 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2415,32 +2415,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { env }, ); } else { - const mergeHeadPath = yield* runGitStdout("GitVcsDriver.commit.mergeHead", cwd, [ - "rev-parse", - "--git-path", - "MERGE_HEAD", - ]); - const isMerging = yield* fileSystem.exists(path.resolve(cwd, mergeHeadPath.trim())).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - operation: "GitVcsDriver.commit.mergeHead", - command: "git commit", - cwd, - detail: "Could not check whether a merge is in progress.", - cause, - }), - ), - ); - // Like git commit --only, reject partial merges before reset can discard their ancestry. - if (isMerging) { - return yield* new GitCommandError({ - operation: "GitVcsDriver.commit", - command: "git commit", + // Reset discards these, so reject partial commits that would lose their ancestry or author. + for (const [stateRef, action] of [ + ["MERGE_HEAD", "merge"], + ["CHERRY_PICK_HEAD", "cherry-pick"], + ["REVERT_HEAD", "revert"], + ] as const) { + // Reftable stores CHERRY_PICK_HEAD and REVERT_HEAD as refs, not files. The full name + // skips branches and tags that reuse the name. + const state = yield* runGitStdout( + "GitVcsDriver.commit.operationState", cwd, - detail: - "Cannot commit selected files during a merge. Commit all files to finish the merge.", - }); + ["rev-parse", "--verify", "--quiet", "--symbolic-full-name", stateRef], + true, + ); + if (state.trim() === stateRef) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.commit", + command: "git commit", + cwd, + detail: `Cannot commit selected files during a ${action}. Commit all files to finish the ${action}.`, + }); + } } yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset"]).pipe( Effect.catchTags({ GitCommandError: () => Effect.void }), From 344792db8d21557c43460a6d6a8bc0d2490b4313 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 14:27:16 +0000 Subject: [PATCH 2/8] fix(server): detect operation refs that share a branch or tag name With a same-named branch or tag, rev-parse warned about the ambiguity and printed nothing, so the guard missed a merge or pick in progress. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 22 ++++++++------------ apps/server/src/vcs/GitVcsDriverCore.ts | 16 ++++++++++---- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 723e986b44c64..50293a5cf10be 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4993,6 +4993,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "Main"]); const mainHead = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["branch", "MERGE_HEAD"]); if (location === "worktree") { const worktree = path.join(yield* makeTmpDir(), "linked"); yield* git(cwd, ["worktree", "add", "-b", "linked", worktree]); @@ -5062,6 +5063,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); const driver = yield* GitVcsDriver.GitVcsDriver; + const path = yield* Path.Path; yield* git(cwd, ["checkout", "-b", "feature"]); yield* writeTextFile(cwd, "README.md", "feature version\n"); yield* writeTextFile(cwd, "picked.txt", "picked\n"); @@ -5072,6 +5074,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, "README.md", "other version\n"); yield* git(cwd, ["commit", "-am", "Other"]); const head = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["branch", stateRef]); const started = yield* driver.execute({ operation: "test.sequencer", cwd, @@ -5082,28 +5085,21 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, "README.md", "resolved version\n"); const gitDir = yield* git(cwd, ["rev-parse", "--absolute-git-dir"]); const names = ["index", stateRef, "MERGE_MSG"]; - const before = names.map((name) => NodeFS.readFileSync(`${gitDir}/${name}`)); + const before = names.map((name) => NodeFS.readFileSync(path.join(gitDir, name))); - const result = yield* driver + const error = yield* driver .commit(cwd, "Partial", "", { stage: { filePaths: ["picked.txt"] } }) - .pipe(Effect.result); - if (Result.isSuccess(result)) { - assert.fail(`Selected ${operation} commit succeeded`); - } else { - assert.include( - result.failure.detail, - `Cannot commit selected files during a ${operation}`, - ); - } + .pipe(Effect.flip); + assert.include(error.detail, `Cannot commit selected files during a ${operation}`); assert.deepEqual( - names.map((name) => NodeFS.readFileSync(`${gitDir}/${name}`)), + names.map((name) => NodeFS.readFileSync(path.join(gitDir, name))), before, ); assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), head); yield* driver.commit(cwd, "Finish", "", { stage: {} }); assert.equal(yield* git(cwd, ["show", "-s", "--format=%an", "HEAD"]), author); - assert.isFalse(NodeFS.existsSync(`${gitDir}/${stateRef}`)); + assert.isFalse(NodeFS.existsSync(path.join(gitDir, stateRef))); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 6a2eeb931da74..ca6c6351bf1c1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2415,18 +2415,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { env }, ); } else { - // Reset discards these, so reject partial commits that would lose their ancestry or author. + // Reset deletes these, so reject partial commits rather than lose the merge, pick or revert. for (const [stateRef, action] of [ ["MERGE_HEAD", "merge"], ["CHERRY_PICK_HEAD", "cherry-pick"], ["REVERT_HEAD", "revert"], ] as const) { - // Reftable stores CHERRY_PICK_HEAD and REVERT_HEAD as refs, not files. The full name - // skips branches and tags that reuse the name. + // Reftable stores CHERRY_PICK_HEAD and REVERT_HEAD as refs, not files. A same-named + // branch or tag resolves to its full name, and an ambiguity warning would blank stdout. const state = yield* runGitStdout( "GitVcsDriver.commit.operationState", cwd, - ["rev-parse", "--verify", "--quiet", "--symbolic-full-name", stateRef], + [ + "-c", + "core.warnAmbiguousRefs=false", + "rev-parse", + "--verify", + "--quiet", + "--symbolic-full-name", + stateRef, + ], true, ); if (state.trim() === stateRef) { From 5ac8a796729cd98ba7835df32e9d41608cff29b9 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 18:13:39 +0000 Subject: [PATCH 3/8] fix(server): selected-file commits keep a multi-commit no-commit cherry-pick A conflicted multi-commit `cherry-pick --no-commit` stops with the sequencer but no CHERRY_PICK_HEAD. A selected-file commit then reset the index, so `cherry-pick --continue` went on with the remaining picks while unresolved files kept their conflict markers as ordinary edits. Reject selected-file commits while the sequencer is present. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 47 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 24 ++++++++++ 2 files changed, 71 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 50293a5cf10be..5436657b75c67 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5103,6 +5103,53 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + 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; + 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", "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 head = yield* git(cwd, ["rev-parse", "HEAD"]); + const started = yield* driver.execute({ + operation: "test.sequencer", + cwd, + args: ["cherry-pick", "--no-commit", "feature~1", "feature"], + allowNonZeroExit: true, + }); + assert.equal(started.exitCode, 1); + yield* writeTextFile(cwd, "a.txt", "resolved a\n"); + const gitDir = yield* git(cwd, ["rev-parse", "--absolute-git-dir"]); + 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 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); + }), + ); + it.effect("treats selected file paths literally", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ca6c6351bf1c1..a119e23da1960 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2446,6 +2446,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } } + // A conflicted multi-commit `cherry-pick --no-commit` leaves only the sequencer, and reset + // would clear its unmerged entries so `--continue` proceeds past them. + const sequencerError = new GitCommandError({ + operation: "GitVcsDriver.commit", + command: "git commit", + cwd, + detail: + "Cannot commit selected files during a cherry-pick or revert. Commit all files, then continue the cherry-pick or revert.", + }); + const gitDir = yield* runGitStdout("GitVcsDriver.commit.sequencer", cwd, [ + "rev-parse", + "--absolute-git-dir", + ]); + const isSequencing = yield* fileSystem.exists(path.join(gitDir.trim(), "sequencer")).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...sequencerError, + detail: "Could not check whether a cherry-pick or revert is in progress.", + cause, + }), + ), + ); + if (isSequencing) return yield* sequencerError; yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset"]).pipe( Effect.catchTags({ GitCommandError: () => Effect.void }), ); From 7cecb45519300a68bf1072db0c29dc37ab340f12 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 11:18:37 +0000 Subject: [PATCH 4/8] fix(server): selected-file commits are refused during a conflicted multi-commit cherry-pick --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 47 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 3 ++ 2 files changed, 50 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9388648ddd02e..c825d86d3f379 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6574,6 +6574,53 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + 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; + 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", "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 head = yield* git(cwd, ["rev-parse", "HEAD"]); + const started = yield* driver.execute({ + operation: "test.sequencer", + cwd, + args: ["cherry-pick", "--no-commit", "feature~1", "feature"], + allowNonZeroExit: true, + }); + assert.equal(started.exitCode, 1); + yield* writeTextFile(cwd, "a.txt", "resolved a\n"); + const gitDir = yield* git(cwd, ["rev-parse", "--absolute-git-dir"]); + 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 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); + }), + ); + it.effect("treats selected file paths literally", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a8e4fb9265f3d..f8120f1f0dab5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2500,6 +2500,9 @@ 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", From 9fd15e6de4df696d142f65852e02b4a7692bd6d3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 11:21:02 +0000 Subject: [PATCH 5/8] fix(server): selected-file commits find a cherry-pick or revert in a reftable repository --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 25 +++++++++++++----- apps/server/src/vcs/GitVcsDriverCore.ts | 27 ++++++++++++++++++-- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c825d86d3f379..0295bbe38b251 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6462,7 +6462,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "Main"]); const mainHead = yield* git(cwd, ["rev-parse", "HEAD"]); - yield* git(cwd, ["branch", "MERGE_HEAD"]); if (location === "worktree") { const worktree = path.join(yield* makeTmpDir(), "linked"); yield* git(cwd, ["worktree", "add", "-b", "linked", worktree]); @@ -6536,12 +6535,16 @@ 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 }) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); + if (refs === "reftable") yield* git(cwd, ["refs", "migrate", "--ref-format=reftable"]); const driver = yield* GitVcsDriver.GitVcsDriver; yield* git(cwd, ["checkout", "-b", "other"]); yield* writeTextFile(cwd, "README.md", "other version\n"); @@ -6552,6 +6555,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { 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, @@ -6561,7 +6565,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(result.exitCode, 1); yield* writeTextFile(cwd, "README.md", "resolved version\n"); yield* git(cwd, ["add", "README.md"]); - const gitDir = yield* git(cwd, ["rev-parse", "--absolute-git-dir"]); const selected = operation === "cherry-pick" ? "picked.txt" : "README.md"; const commit = yield* driver @@ -6570,7 +6573,17 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { 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}`)); + assert.equal( + yield* git(cwd, [ + "-c", + "core.warnAmbiguousRefs=false", + "rev-parse", + "--verify", + "--symbolic-full-name", + head, + ]), + head, + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f8120f1f0dab5..567119264bd4a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2509,8 +2509,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...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(["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) => From 80876e503786ba1ecfeabc1d4a624e557dba3c6e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 12:28:41 +0000 Subject: [PATCH 6/8] test(server): skip the reftable selected-file cases on Git before 2.46 The two reftable rows set up their repository with `git refs migrate`, which Git added in 2.46. On an older Git that call failed before the selected-file guard ran, so the rows failed without testing it. They now report as skipped there and still run on Git 2.46 and later. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 95 +++++++++++--------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 0295bbe38b251..1291b9f840248 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -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"; @@ -342,6 +343,12 @@ const git = ( return result.stdout.trim(); }); +const [, gitMajor, gitMinor] = + /(\d+)\.(\d+)/u.exec(NodeChildProcess.execFileSync("git", ["--version"], { encoding: "utf8" })) ?? + []; +const gitHasRefsMigrate = + Number(gitMajor) > 2 || (Number(gitMajor) === 2 && Number(gitMinor) >= 46); + // Git blocks file submodule transports (CVE-2022-39253) despite repo config; local fixtures need env permission. const withGitFileProtocol = (effect: Effect.Effect) => Effect.flatMap(HostProcess.Environment, (env) => @@ -6540,51 +6547,55 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { // 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 }) => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - if (refs === "reftable") yield* git(cwd, ["refs", "migrate", "--ref-format=reftable"]); - 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"; + ])( + "rejects selected-file commits during a $operation ($refs)", + ({ operation, head, refs }, ctx) => + Effect.gen(function* () { + if (refs === "reftable" && !gitHasRefsMigrate) + return ctx.skip("Needs Git 2.46 or later for `git refs migrate`."); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + if (refs === "reftable") yield* git(cwd, ["refs", "migrate", "--ref-format=reftable"]); + 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); + 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", + 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, - ]), - head, - ); - }), + ); + }), ); it.effect("rejects selected-file commits during a multi-commit no-commit cherry-pick", () => From 6c2482be7d1857c380031aa043a785d95e869f70 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 12:13:15 +0000 Subject: [PATCH 7/8] test(server): build the reftable selected-file repos with git init git refs migrate refuses repositories with reflogs before Git 2.48, so the reftable cases failed on Git 2.46 and 2.47. Create the repository as reftable instead, which needs only Git 2.45. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 44f555e612954..90cc06cddd6e7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -346,8 +346,7 @@ const git = ( const [, gitMajor, gitMinor] = /(\d+)\.(\d+)/u.exec(NodeChildProcess.execFileSync("git", ["--version"], { encoding: "utf8" })) ?? []; -const gitHasRefsMigrate = - Number(gitMajor) > 2 || (Number(gitMajor) === 2 && Number(gitMinor) >= 46); +const gitHasReftable = Number(gitMajor) > 2 || (Number(gitMajor) === 2 && Number(gitMinor) >= 45); const gitWithStdin = ( cwd: string, @@ -7194,11 +7193,13 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "rejects selected-file commits during a $operation ($refs)", ({ operation, head, refs }, ctx) => Effect.gen(function* () { - if (refs === "reftable" && !gitHasRefsMigrate) - return ctx.skip("Needs Git 2.46 or later for `git refs migrate`."); + if (refs === "reftable" && !gitHasReftable) + return ctx.skip("Needs Git 2.45 or later for reftable."); const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - if (refs === "reftable") yield* git(cwd, ["refs", "migrate", "--ref-format=reftable"]); + if (refs === "reftable") yield* git(cwd, ["init", "--ref-format=reftable"]); + 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"); From 7dfa19cd2e34d5e74fd1652f359e76f481558e1f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 12:21:03 +0000 Subject: [PATCH 8/8] test(server): init the reftable selected-file repos once A second plain git init turns a reftable repository back into the files format on Git 2.55, so the reftable cases failed in CI. Pass the ref format to the one init that builds the repository. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 90cc06cddd6e7..ad279e43e2a10 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -394,11 +394,11 @@ const initRepoWithCommit = ( return { initialBranch: head.trim().replace(/^ref: refs\/heads\//, "") }; }); -const commitInitialRepo = (cwd: string) => +const commitInitialRepo = (cwd: string, initArgs: ReadonlyArray = []) => 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", @@ -7196,8 +7196,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { if (refs === "reftable" && !gitHasReftable) return ctx.skip("Needs Git 2.45 or later for reftable."); const cwd = yield* makeTmpDir(); - if (refs === "reftable") yield* git(cwd, ["init", "--ref-format=reftable"]); - yield* initRepoWithCommit(cwd); + 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;