From 20fe1fd30dd9f8ec8fc15f3fa611397213a618e4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 21:30:56 +0000 Subject: [PATCH 1/8] fix(server): commits no longer include files that still have conflict markers Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 77 ++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 156 +++++++++++++++++-- 2 files changed, 220 insertions(+), 13 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index baf1368054d4e..648f9eadf22d3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5919,7 +5919,18 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), mainHead); assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); - if (conflict) yield* writeTextFile(cwd, "README.md", "resolved version\n"); + if (conflict) { + const markers = yield* driver + .commit(commitCwd, "Finish merge", "", { stage: {} }) + .pipe(Effect.flip); + assert.equal( + markers.detail, + "Cannot commit files that still contain conflict markers: README.md. Resolve the conflicts, then commit again.", + ); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), mainHead); + assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); + yield* writeTextFile(cwd, "README.md", "resolved version\n"); + } yield* driver.commit(commitCwd, "Finish merge", "", { stage: {} }); assert.equal( yield* git(cwd, ["show", "-s", "--format=%P", "HEAD"]), @@ -5960,6 +5971,70 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.each([ + { markerSize: "3", attributes: "untracked" }, + { markerSize: "3", attributes: "staged" }, + { markerSize: "9", attributes: "incoming" }, + { markerSize: "3", attributes: "changed" }, + { markerSize: "5.0", attributes: "committed" }, + ])( + "rejects files that still contain conflict markers outside a merge (size $markerSize, $attributes)", + ({ markerSize, attributes }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + // Git still writes markers into -diff files (often lockfiles), at the configured size. Each row + // leaves that size in one place only: the worktree, the index, or HEAD (the stash or the user + // changes it afterwards). + const setSize = (size: string) => + writeTextFile(cwd, ".gitattributes", `/README.md -diff conflict-marker-size=${size}\n`); + if (attributes === "untracked") yield* setSize(markerSize); + if (attributes === "incoming" || attributes === "changed" || attributes === "committed") { + yield* setSize(markerSize); + yield* git(cwd, ["add", ".gitattributes"]); + yield* git(cwd, ["commit", "-m", "Attributes"]); + } + if (attributes === "incoming") yield* setSize("3"); + yield* writeTextFile(cwd, "README.md", "stashed version\n"); + yield* git(cwd, ["stash"]); + yield* writeTextFile(cwd, "README.md", "committed version\n"); + yield* git(cwd, ["commit", "-am", "Change README"]); + if (attributes === "staged") { + yield* setSize(markerSize); + yield* git(cwd, ["add", ".gitattributes"]); + } + const pop = yield* driver.execute({ + operation: "test.stashPop", + cwd, + args: ["stash", "pop"], + allowNonZeroExit: true, + }); + assert.equal(pop.exitCode, 1); + if (attributes === "staged" || attributes === "changed") yield* setSize("7"); + if (attributes === "changed") yield* git(cwd, ["add", ".gitattributes"]); + yield* writeTextFile(cwd, "other.txt", "other\n"); + const head = yield* git(cwd, ["rev-parse", "HEAD"]); + const unmerged = yield* git(cwd, ["ls-files", "--unmerged"]); + const nested = (yield* Path.Path).join(cwd, "nested"); + yield* (yield* FileSystem.FileSystem).makeDirectory(nested); + const detail = + "Cannot commit files that still contain conflict markers: README.md. Resolve the conflicts, then commit again."; + + const selected = yield* driver + .commit(cwd, "Pick", "", { stage: { filePaths: ["README.md", "other.txt"] } }) + .pipe(Effect.flip); + const all = yield* driver.prepareCommitContext(nested).pipe(Effect.flip); + + assert.equal(selected.detail, detail); + assert.equal(all.detail, detail); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), head); + assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); + yield* driver.commit(cwd, "Add other", "", { stage: { filePaths: ["other.txt"] } }); + assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "other.txt"); + }), + ); + const commitWithHooks = (hooks: Record) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e41aada677570..9d3d106025393 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -73,6 +73,14 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const GIT_DEFAULT_CONFLICT_MARKER_SIZE = 7; + +// Git falls back to the default for any value that is not a positive integer. +function parseConflictMarkerSize(value = "") { + const size = /^\+?\d+$/.test(value) ? Number.parseInt(value, 10) : 0; + return size > 0 ? size : GIT_DEFAULT_CONFLICT_MARKER_SIZE; +} + // Temporary-index git add scales poorly (50k files took ~30 s); report incomplete totals above this cap. const REVIEW_UNTRACKED_MAX_FILES = 5_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; @@ -2432,9 +2440,139 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* filePaths?: readonly string[], env?: NodeJS.ProcessEnv, ) { - const config = env - ? ["-c", "core.splitIndex=false", "-c", "splitIndex.sharedIndexExpire=never"] - : []; + const isolatedIndexConfig = [ + "-c", + "core.splitIndex=false", + "-c", + "splitIndex.sharedIndexExpire=never", + ]; + const config = env ? isolatedIndexConfig : []; + const envOptions = env ? { env } : {}; + // Status paths are repository-relative, while cwd can be a subdirectory. + const toPathspec = (file: string) => `:(top,literal)${file}`; + const pathspecs = (filePaths ?? []).map(toPathspec); + const unmerged = yield* executeGit( + "GitVcsDriver.commit.unmerged", + cwd, + [ + "ls-files", + "--unmerged", + "-z", + "--full-name", + "--", + ...(pathspecs.length > 0 ? pathspecs : [":(top)"]), + ], + { ...envOptions, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ); + // Each record is " \t", once per stage. + const unmergedPaths = new Set( + splitNullSeparatedGitStdoutPaths(unmerged).map((record) => + record.slice(record.indexOf("\t") + 1), + ), + ); + if (unmergedPaths.size > 0) { + // add -A marks unmerged paths resolved, so check the files it would stage for leftover markers. + const files = Array.from(unmergedPaths); + const operation = "GitVcsDriver.commit.conflictMarkers"; + const checkFailed = "Could not check conflicted files for conflict markers."; + const root = yield* readWorktreeRoot(cwd); + if (root === null) { + return yield* new GitCommandError({ operation, command: "git", cwd, detail: checkFailed }); + } + // check-attr takes plain repository paths, not pathspecs, and prints one + // " " triple per path in input order. + const readMarkerSizes = (source: readonly string[], options: { env?: NodeJS.ProcessEnv }) => + executeGit( + "GitVcsDriver.commit.conflictMarkerSize", + root, + ["check-attr", ...source, "-z", "conflict-marker-size", "--", ...files], + { ...options, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ).pipe(Effect.map((result) => result.stdout.split("\0"))); + // HEAD's attributes come from a throwaway index, since check-attr --source needs Git 2.40. + const readHeadMarkerSizes = Effect.gen(function* () { + const head = yield* executeGit( + "GitVcsDriver.commit.head", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ); + if (head.exitCode !== 0) return []; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-attributes-index-", + }); + const headIndex = { GIT_INDEX_FILE: path.join(directory, "index") }; + yield* runGit( + "GitVcsDriver.commit.headIndex", + cwd, + [...isolatedIndexConfig, "read-tree", "HEAD"], + { env: headIndex }, + ); + return yield* readMarkerSizes(["--cached"], { env: headIndex }); + }).pipe( + Effect.scoped, + Effect.catchTags({ + PlatformError: (cause) => + new GitCommandError({ operation, command: "git", cwd, detail: checkFailed, cause }), + }), + ); + // Markers use the size from before the conflict; afterwards it may survive only in HEAD, the + // index or the worktree. An unborn HEAD leaves the default. + const lookups = yield* Effect.all( + [ + readMarkerSizes([], envOptions), + readMarkerSizes(["--cached"], envOptions), + readHeadMarkerSizes, + ], + { concurrency: 3 }, + ); + const filesBySize = new Map>(); + files.forEach((file, index) => { + for (const values of lookups) { + const size = parseConflictMarkerSize(values[index * 3 + 2]); + filesBySize.set(size, (filesBySize.get(size) ?? new Set()).add(file)); + } + }); + const markedPaths = new Set(); + for (const [size, group] of filesBySize) { + // Git writes exactly `size` marker characters, then whitespace and a label. + const args = [ + "grep", + "-l", + "-z", + "--full-name", + "-E", + "-e", + `^(<{${size}}|>{${size}})([[:space:]]|$)`, + "--", + ...Array.from(group, toPathspec), + ]; + const markers = yield* executeGit(operation, cwd, args, { + ...envOptions, + allowNonZeroExit: true, + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, + }); + if (markers.exitCode !== 0 && markers.exitCode !== 1) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation, cwd, args }), + detail: checkFailed, + ...(markers.exitCode === null ? {} : { exitCode: markers.exitCode }), + stdoutLength: markers.stdout.length, + stderrLength: markers.stderr.length, + }); + } + for (const file of splitNullSeparatedGitStdoutPaths(markers)) markedPaths.add(file); + } + if (markedPaths.size > 0) { + const named = Array.from(markedPaths).slice(0, 5).join(", "); + const more = markedPaths.size > 5 ? ` and ${markedPaths.size - 5} more` : ""; + return yield* new GitCommandError({ + operation: "GitVcsDriver.commit", + command: "git commit", + cwd, + detail: `Cannot commit files that still contain conflict markers: ${named}${more}. Resolve the conflicts, then commit again.`, + }); + } + } if (filePaths && filePaths.length > 0) { if (env) { const head = yield* executeGit( @@ -2483,17 +2621,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.commit.addSelected", cwd, - // Status paths are repository-relative, while cwd can be a subdirectory. - [...config, "add", "-A", "--", ...filePaths.map((file) => `:(top,literal)${file}`)], - env ? { env } : {}, + [...config, "add", "-A", "--", ...pathspecs], + envOptions, ); } else { - yield* runGit( - "GitVcsDriver.commit.addAll", - cwd, - [...config, "add", "-A"], - env ? { env } : {}, - ); + yield* runGit("GitVcsDriver.commit.addAll", cwd, [...config, "add", "-A"], envOptions); } }); From 202972dd917e8a9ab142cea32dba78e561a92f54 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 23:24:36 +0000 Subject: [PATCH 2/8] fix(server): check working-tree-encoding files for conflict markers in their staged form Git stores these files as UTF-8 but writes their conflict markers in the working-tree encoding, so a plain grep of the worktree missed them. Stage those files into a throwaway index and grep the converted content. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 22 +++++- apps/server/src/vcs/GitVcsDriverCore.ts | 76 ++++++++++++++++---- 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 648f9eadf22d3..87497b685bcac 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5977,6 +5977,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { { markerSize: "9", attributes: "incoming" }, { markerSize: "3", attributes: "changed" }, { markerSize: "5.0", attributes: "committed" }, + { markerSize: "7", attributes: "encoded" }, ])( "rejects files that still contain conflict markers outside a merge (size $markerSize, $attributes)", ({ markerSize, attributes }) => @@ -5989,6 +5990,23 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { // changes it afterwards). const setSize = (size: string) => writeTextFile(cwd, ".gitattributes", `/README.md -diff conflict-marker-size=${size}\n`); + // Git stores working-tree-encoding files as UTF-8 but writes their markers in that encoding. + const writeReadme = (contents: string) => + attributes === "encoded" + ? Effect.flatMap(FileSystem.FileSystem, (fileSystem) => + fileSystem.writeFile(`${cwd}/README.md`, Buffer.from(contents, "utf16le")), + ) + : writeTextFile(cwd, "README.md", contents); + if (attributes === "encoded") { + yield* writeTextFile( + cwd, + ".gitattributes", + "/README.md working-tree-encoding=UTF-16LE\n", + ); + yield* writeReadme("# test\n"); + yield* git(cwd, ["add", ".gitattributes", "README.md"]); + yield* git(cwd, ["commit", "-m", "Encoding"]); + } if (attributes === "untracked") yield* setSize(markerSize); if (attributes === "incoming" || attributes === "changed" || attributes === "committed") { yield* setSize(markerSize); @@ -5996,9 +6014,9 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["commit", "-m", "Attributes"]); } if (attributes === "incoming") yield* setSize("3"); - yield* writeTextFile(cwd, "README.md", "stashed version\n"); + yield* writeReadme("stashed version\n"); yield* git(cwd, ["stash"]); - yield* writeTextFile(cwd, "README.md", "committed version\n"); + yield* writeReadme("committed version\n"); yield* git(cwd, ["commit", "-am", "Change README"]); if (attributes === "staged") { yield* setSize(markerSize); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 9d3d106025393..90a525f35c2a7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2480,16 +2480,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* new GitCommandError({ operation, command: "git", cwd, detail: checkFailed }); } // check-attr takes plain repository paths, not pathspecs, and prints one - // " " triple per path in input order. - const readMarkerSizes = (source: readonly string[], options: { env?: NodeJS.ProcessEnv }) => + // " " triple per path and attribute in input order. + const readAttributes = (source: readonly string[], options: { env?: NodeJS.ProcessEnv }) => executeGit( "GitVcsDriver.commit.conflictMarkerSize", root, - ["check-attr", ...source, "-z", "conflict-marker-size", "--", ...files], + [ + "check-attr", + ...source, + "-z", + "conflict-marker-size", + "working-tree-encoding", + "--", + ...files, + ], { ...options, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe(Effect.map((result) => result.stdout.split("\0"))); // HEAD's attributes come from a throwaway index, since check-attr --source needs Git 2.40. - const readHeadMarkerSizes = Effect.gen(function* () { + const readHeadAttributes = Effect.gen(function* () { const head = yield* executeGit( "GitVcsDriver.commit.head", cwd, @@ -2507,7 +2515,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* [...isolatedIndexConfig, "read-tree", "HEAD"], { env: headIndex }, ); - return yield* readMarkerSizes(["--cached"], { env: headIndex }); + return yield* readAttributes(["--cached"], { env: headIndex }); }).pipe( Effect.scoped, Effect.catchTags({ @@ -2519,24 +2527,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // index or the worktree. An unborn HEAD leaves the default. const lookups = yield* Effect.all( [ - readMarkerSizes([], envOptions), - readMarkerSizes(["--cached"], envOptions), - readHeadMarkerSizes, + readAttributes([], envOptions), + readAttributes(["--cached"], envOptions), + readHeadAttributes, ], { concurrency: 3 }, ); const filesBySize = new Map>(); + const encodedFiles = new Set(); files.forEach((file, index) => { for (const values of lookups) { - const size = parseConflictMarkerSize(values[index * 3 + 2]); + const size = parseConflictMarkerSize(values[index * 6 + 2]); filesBySize.set(size, (filesBySize.get(size) ?? new Set()).add(file)); + const encoding = values[index * 6 + 5]; + if (encoding !== "unspecified" && encoding !== "unset") encodedFiles.add(file); } }); - const markedPaths = new Set(); - for (const [size, group] of filesBySize) { + const grepMarkers = Effect.fn("GitVcsDriver.commit.grepMarkers")(function* ( + size: number, + group: readonly string[], + source: readonly string[], + options: { env?: NodeJS.ProcessEnv }, + ) { // Git writes exactly `size` marker characters, then whitespace and a label. const args = [ "grep", + ...source, "-l", "-z", "--full-name", @@ -2544,10 +2560,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "-e", `^(<{${size}}|>{${size}})([[:space:]]|$)`, "--", - ...Array.from(group, toPathspec), + ...group.map(toPathspec), ]; const markers = yield* executeGit(operation, cwd, args, { - ...envOptions, + ...options, allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, }); @@ -2560,7 +2576,39 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* stderrLength: markers.stderr.length, }); } - for (const file of splitNullSeparatedGitStdoutPaths(markers)) markedPaths.add(file); + return splitNullSeparatedGitStdoutPaths(markers); + }); + // Git stages working-tree-encoding files as UTF-8, so grep that content from a throwaway index. + const grepEncodedMarkers = Effect.fn("GitVcsDriver.commit.grepEncodedMarkers")( + function* (size: number, group: readonly string[]) { + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-encoded-index-", + }); + const options = { env: { ...env, GIT_INDEX_FILE: path.join(directory, "index") } }; + yield* runGit( + "GitVcsDriver.commit.encodedIndex", + root, + [...isolatedIndexConfig, "update-index", "--add", "--remove", "--", ...group], + options, + ); + return yield* grepMarkers(size, group, ["--cached"], options); + }, + Effect.scoped, + Effect.catchTags({ + PlatformError: (cause) => + new GitCommandError({ operation, command: "git", cwd, detail: checkFailed, cause }), + }), + ); + const markedPaths = new Set(); + for (const [size, group] of filesBySize) { + const plain: string[] = []; + const encoded: string[] = []; + for (const file of group) (encodedFiles.has(file) ? encoded : plain).push(file); + const found = [ + ...(plain.length > 0 ? yield* grepMarkers(size, plain, [], envOptions) : []), + ...(encoded.length > 0 ? yield* grepEncodedMarkers(size, encoded) : []), + ]; + for (const file of found) markedPaths.add(file); } if (markedPaths.size > 0) { const named = Array.from(markedPaths).slice(0, 5).join(", "); From d5ed9a727684fc72a49b31f041fac210772bfa07 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 23:47:46 +0000 Subject: [PATCH 3/8] fix(server): decode conflict markers with the encoding Git wrote them in The working-tree-encoding that wrote the markers can survive only in HEAD or the index once the worktree attributes change. Check each size and encoding found in the worktree, index and HEAD, decoding through a throwaway repository whose info/attributes sets that encoding. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 11 ++- apps/server/src/vcs/GitVcsDriverCore.ts | 75 +++++++++++++------- 2 files changed, 57 insertions(+), 29 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 87497b685bcac..a07e5234bdf62 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5978,6 +5978,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { { markerSize: "3", attributes: "changed" }, { markerSize: "5.0", attributes: "committed" }, { markerSize: "7", attributes: "encoded" }, + { markerSize: "7", attributes: "encoding removed" }, ])( "rejects files that still contain conflict markers outside a merge (size $markerSize, $attributes)", ({ markerSize, attributes }) => @@ -5991,13 +5992,14 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const setSize = (size: string) => writeTextFile(cwd, ".gitattributes", `/README.md -diff conflict-marker-size=${size}\n`); // Git stores working-tree-encoding files as UTF-8 but writes their markers in that encoding. + const encoded = attributes === "encoded" || attributes === "encoding removed"; const writeReadme = (contents: string) => - attributes === "encoded" + encoded ? Effect.flatMap(FileSystem.FileSystem, (fileSystem) => fileSystem.writeFile(`${cwd}/README.md`, Buffer.from(contents, "utf16le")), ) : writeTextFile(cwd, "README.md", contents); - if (attributes === "encoded") { + if (encoded) { yield* writeTextFile( cwd, ".gitattributes", @@ -6031,6 +6033,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(pop.exitCode, 1); if (attributes === "staged" || attributes === "changed") yield* setSize("7"); if (attributes === "changed") yield* git(cwd, ["add", ".gitattributes"]); + if (attributes === "encoding removed") yield* writeTextFile(cwd, ".gitattributes", ""); yield* writeTextFile(cwd, "other.txt", "other\n"); const head = yield* git(cwd, ["rev-parse", "HEAD"]); const unmerged = yield* git(cwd, ["ls-files", "--unmerged"]); @@ -6050,6 +6053,10 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); yield* driver.commit(cwd, "Add other", "", { stage: { filePaths: ["other.txt"] } }); assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "other.txt"); + if (encoded) { + yield* writeReadme("resolved version\n"); + yield* driver.commit(cwd, "Resolve", "", { stage: {} }); + } }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 90a525f35c2a7..6d71cce294ab6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2523,8 +2523,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* new GitCommandError({ operation, command: "git", cwd, detail: checkFailed, cause }), }), ); - // Markers use the size from before the conflict; afterwards it may survive only in HEAD, the - // index or the worktree. An unborn HEAD leaves the default. + // Markers use the size and encoding from before the conflict; afterwards they may survive only in + // HEAD, the index or the worktree. An unborn HEAD leaves the defaults. const lookups = yield* Effect.all( [ readAttributes([], envOptions), @@ -2533,26 +2533,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: 3 }, ); - const filesBySize = new Map>(); - const encodedFiles = new Set(); + const checks = new Map< + string, + { size: number; encoding: string | null; files: Set } + >(); files.forEach((file, index) => { for (const values of lookups) { const size = parseConflictMarkerSize(values[index * 6 + 2]); - filesBySize.set(size, (filesBySize.get(size) ?? new Set()).add(file)); - const encoding = values[index * 6 + 5]; - if (encoding !== "unspecified" && encoding !== "unset") encodedFiles.add(file); + const value = values[index * 6 + 5] ?? "unspecified"; + const encoding = ["unspecified", "unset", "set"].includes(value) ? null : value; + const key = `${size} ${encoding ?? ""}`; + const check = checks.get(key) ?? { size, encoding, files: new Set() }; + check.files.add(file); + checks.set(key, check); } }); const grepMarkers = Effect.fn("GitVcsDriver.commit.grepMarkers")(function* ( size: number, group: readonly string[], - source: readonly string[], + command: readonly string[], options: { env?: NodeJS.ProcessEnv }, ) { // Git writes exactly `size` marker characters, then whitespace and a label. const args = [ - "grep", - ...source, + ...command, "-l", "-z", "--full-name", @@ -2578,20 +2582,40 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } return splitNullSeparatedGitStdoutPaths(markers); }); - // Git stages working-tree-encoding files as UTF-8, so grep that content from a throwaway index. + // Git writes markers in the working-tree encoding. A throwaway repository's info/attributes + // outranks .gitattributes, so its index holds the files decoded from that encoding. const grepEncodedMarkers = Effect.fn("GitVcsDriver.commit.grepEncodedMarkers")( - function* (size: number, group: readonly string[]) { + function* (size: number, encoding: string, group: readonly string[]) { const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-encoded-index-", + prefix: "t3code-encoded-", }); + yield* runGit("GitVcsDriver.commit.encodedRepository", root, [ + "init", + "--bare", + "--quiet", + "--template=", + directory, + ]); + yield* fileSystem.makeDirectory(path.join(directory, "info"), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(directory, "info", "attributes"), + `* -filter working-tree-encoding=${encoding}\n`, + ); + const repository = [`--git-dir=${directory}`, `--work-tree=${root}`]; const options = { env: { ...env, GIT_INDEX_FILE: path.join(directory, "index") } }; - yield* runGit( - "GitVcsDriver.commit.encodedIndex", - root, - [...isolatedIndexConfig, "update-index", "--add", "--remove", "--", ...group], - options, + // One file per call: a file that does not decode in this encoding is skipped, not fatal. + yield* Effect.forEach( + group, + (file) => + executeGit( + "GitVcsDriver.commit.encodedIndex", + root, + [...repository, ...isolatedIndexConfig, "update-index", "--add", "--", file], + { ...options, allowNonZeroExit: true }, + ), + { discard: true }, ); - return yield* grepMarkers(size, group, ["--cached"], options); + return yield* grepMarkers(size, group, [...repository, "grep", "--cached"], options); }, Effect.scoped, Effect.catchTags({ @@ -2600,14 +2624,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); const markedPaths = new Set(); - for (const [size, group] of filesBySize) { - const plain: string[] = []; - const encoded: string[] = []; - for (const file of group) (encodedFiles.has(file) ? encoded : plain).push(file); - const found = [ - ...(plain.length > 0 ? yield* grepMarkers(size, plain, [], envOptions) : []), - ...(encoded.length > 0 ? yield* grepEncodedMarkers(size, encoded) : []), - ]; + for (const { size, encoding, files: group } of checks.values()) { + const found = + encoding === null + ? yield* grepMarkers(size, [...group], ["grep"], envOptions) + : yield* grepEncodedMarkers(size, encoding, [...group]); for (const file of found) markedPaths.add(file); } if (markedPaths.size > 0) { From 6d424b2c9db92daa0c05a9ecca2f3ad6ba1f01f8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 11:33:55 +0000 Subject: [PATCH 4/8] fix(server): preserve conflicts when committing unrelated files --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 5 ++++ apps/server/src/vcs/GitVcsDriverCore.ts | 28 +++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index a07e5234bdf62..341848d505aab 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6053,6 +6053,11 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); yield* driver.commit(cwd, "Add other", "", { stage: { filePaths: ["other.txt"] } }); assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "other.txt"); + assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); + assert.equal( + (yield* driver.prepareCommitContext(nested).pipe(Effect.flip)).detail, + detail, + ); if (encoded) { yield* writeReadme("resolved version\n"); yield* driver.commit(cwd, "Resolve", "", { stage: {} }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 6d71cce294ab6..1db5677851afa 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2448,6 +2448,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]; const config = env ? isolatedIndexConfig : []; const envOptions = env ? { env } : {}; + let preserveUnselectedConflicts = false; // Status paths are repository-relative, while cwd can be a subdirectory. const toPathspec = (file: string) => `:(top,literal)${file}`; const pathspecs = (filePaths ?? []).map(toPathspec); @@ -2685,7 +2686,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "Cannot commit selected files during a merge. Commit all files to finish the merge.", }); } - yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]); + const remainingConflicts = yield* runGitStdout( + "GitVcsDriver.commit.unselectedConflicts", + cwd, + [ + "ls-files", + "--unmerged", + "-z", + "--", + ":(top)", + ...filePaths.map((file) => `:(top,exclude,literal)${file}`), + ], + ); + preserveUnselectedConflicts = remainingConflicts.length > 0; + // Reset discards conflict stages and attributes needed by the next commit's marker check. + if (!preserveUnselectedConflicts) { + yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]); + } } yield* runGit( "GitVcsDriver.commit.addSelected", @@ -2696,6 +2713,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } else { yield* runGit("GitVcsDriver.commit.addAll", cwd, [...config, "add", "-A"], envOptions); } + return preserveUnselectedConflicts ? pathspecs : []; }); const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = @@ -2802,14 +2820,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { - if (options?.stage !== undefined) { - yield* stageCommitChanges(cwd, options.stage.filePaths); - } + const partialCommitPaths = + options?.stage !== undefined ? yield* stageCommitChanges(cwd, options.stage.filePaths) : []; const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } + if (partialCommitPaths.length > 0) { + args.push("--only", "--", ...partialCommitPaths); + } const progress = options?.progress?.onOutputLine === undefined ? options?.progress From bed35506576c804ad81237febaa578ef222ad106 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 16:03:00 +0000 Subject: [PATCH 5/8] fix(server): a selected-file commit keeps other files' merge conflicts Main's conflict-marker check (#35132) now covers Commit all, so this branch drops its own git grep check and keeps only the selected-file part: while files outside the selection are still conflicted, the commit skips the index reset that dropped their conflict stages and commits only its paths with git commit --only, the paths on stdin as in #35128. The keep-conflicts logic is maria-rcks's from 6d424b2c9d. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 164 ++++++------ apps/server/src/vcs/GitVcsDriverCore.ts | 247 ++----------------- 2 files changed, 96 insertions(+), 315 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index be660be12cf91..3cfa3c853510e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6600,98 +6600,84 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect.each([ - { markerSize: "3", attributes: "untracked" }, - { markerSize: "3", attributes: "staged" }, - { markerSize: "9", attributes: "incoming" }, - { markerSize: "3", attributes: "changed" }, - { markerSize: "5.0", attributes: "committed" }, - { markerSize: "7", attributes: "encoded" }, - { markerSize: "7", attributes: "encoding removed" }, - ])( - "rejects files that still contain conflict markers outside a merge (size $markerSize, $attributes)", - ({ markerSize, attributes }) => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - // Git still writes markers into -diff files (often lockfiles), at the configured size. Each row - // leaves that size in one place only: the worktree, the index, or HEAD (the stash or the user - // changes it afterwards). - const setSize = (size: string) => - writeTextFile(cwd, ".gitattributes", `/README.md -diff conflict-marker-size=${size}\n`); - // Git stores working-tree-encoding files as UTF-8 but writes their markers in that encoding. - const encoded = attributes === "encoded" || attributes === "encoding removed"; - const writeReadme = (contents: string) => - encoded - ? Effect.flatMap(FileSystem.FileSystem, (fileSystem) => - fileSystem.writeFile(`${cwd}/README.md`, Buffer.from(contents, "utf16le")), - ) - : writeTextFile(cwd, "README.md", contents); - if (encoded) { - yield* writeTextFile( - cwd, - ".gitattributes", - "/README.md working-tree-encoding=UTF-16LE\n", - ); - yield* writeReadme("# test\n"); - yield* git(cwd, ["add", ".gitattributes", "README.md"]); - yield* git(cwd, ["commit", "-m", "Encoding"]); - } - if (attributes === "untracked") yield* setSize(markerSize); - if (attributes === "incoming" || attributes === "changed" || attributes === "committed") { - yield* setSize(markerSize); - yield* git(cwd, ["add", ".gitattributes"]); - yield* git(cwd, ["commit", "-m", "Attributes"]); - } - if (attributes === "incoming") yield* setSize("3"); - yield* writeReadme("stashed version\n"); - yield* git(cwd, ["stash"]); - yield* writeReadme("committed version\n"); - yield* git(cwd, ["commit", "-am", "Change README"]); - if (attributes === "staged") { - yield* setSize(markerSize); - yield* git(cwd, ["add", ".gitattributes"]); - } - const pop = yield* driver.execute({ - operation: "test.stashPop", - cwd, - args: ["stash", "pop"], - allowNonZeroExit: true, + it.effect("keeps other files conflicted through a selected-file commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + // git stash pop leaves README.md conflicted with no merge in progress. + yield* writeTextFile(cwd, "README.md", "stashed version\n"); + yield* git(cwd, ["stash"]); + yield* writeTextFile(cwd, "README.md", "committed version\n"); + yield* git(cwd, ["commit", "-am", "Change README"]); + const pop = yield* driver.execute({ + operation: "test.stashPop", + cwd, + args: ["stash", "pop"], + allowNonZeroExit: true, + }); + assert.equal(pop.exitCode, 1); + yield* writeTextFile(cwd, "other.txt", "other\n"); + const head = yield* git(cwd, ["rev-parse", "HEAD"]); + const unmerged = yield* git(cwd, ["ls-files", "--unmerged"]); + const nested = (yield* Path.Path).join(cwd, "nested"); + yield* (yield* FileSystem.FileSystem).makeDirectory(nested); + + yield* driver.commit(cwd, "Add other", "", { stage: { filePaths: ["other.txt"] } }); + + assert.equal(yield* git(cwd, ["rev-parse", "HEAD~1"]), head); + assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "other.txt"); + assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); + const all = yield* driver.prepareCommitContext(nested).pipe(Effect.flip); + assert.equal( + all.detail, + "Cannot commit with unresolved merge conflicts in README.md. Resolve them, then commit.", + ); + }), + ); + + it.effect("commits a selected file beside more than 1 MB of conflicted path names", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const dir = ["a", "b", "c"].map((letter) => letter.repeat(200)).join("/"); + yield* fs.makeDirectory(path.join(cwd, dir), { recursive: true }); + const files = Array.from( + { length: 1_250 }, + (_, index) => `${dir}/${String(index).padStart(4, "0")}-${"x".repeat(240)}`, + ); + assert.isAbove(files.join("\0").length, 1_000_000); + const writeAll = (content: string) => + Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), content), { + concurrency: 16, + discard: true, }); - assert.equal(pop.exitCode, 1); - if (attributes === "staged" || attributes === "changed") yield* setSize("7"); - if (attributes === "changed") yield* git(cwd, ["add", ".gitattributes"]); - if (attributes === "encoding removed") yield* writeTextFile(cwd, ".gitattributes", ""); - yield* writeTextFile(cwd, "other.txt", "other\n"); - const head = yield* git(cwd, ["rev-parse", "HEAD"]); - const unmerged = yield* git(cwd, ["ls-files", "--unmerged"]); - const nested = (yield* Path.Path).join(cwd, "nested"); - yield* (yield* FileSystem.FileSystem).makeDirectory(nested); - const detail = - "Cannot commit files that still contain conflict markers: README.md. Resolve the conflicts, then commit again."; + yield* writeAll("base\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-qm", "Add files"]); + yield* writeAll("stashed\n"); + yield* git(cwd, ["stash", "-q"]); + yield* writeAll("committed\n"); + yield* git(cwd, ["commit", "-qam", "Change files"]); + const head = yield* git(cwd, ["rev-parse", "HEAD"]); + const pop = yield* driver.execute({ + operation: "test.stashPop", + cwd, + args: ["stash", "pop"], + allowNonZeroExit: true, + maxOutputBytes: 16 * 1024 * 1024, + }); + assert.equal(pop.exitCode, 1); + yield* writeTextFile(cwd, "other.txt", "other\n"); - const selected = yield* driver - .commit(cwd, "Pick", "", { stage: { filePaths: ["README.md", "other.txt"] } }) - .pipe(Effect.flip); - const all = yield* driver.prepareCommitContext(nested).pipe(Effect.flip); + yield* driver.commit(cwd, "Add other", "", { stage: { filePaths: ["other.txt"] } }); - assert.equal(selected.detail, detail); - assert.equal(all.detail, detail); - assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), head); - assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); - yield* driver.commit(cwd, "Add other", "", { stage: { filePaths: ["other.txt"] } }); - assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "other.txt"); - assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); - assert.equal( - (yield* driver.prepareCommitContext(nested).pipe(Effect.flip)).detail, - detail, - ); - if (encoded) { - yield* writeReadme("resolved version\n"); - yield* driver.commit(cwd, "Resolve", "", { stage: {} }); - } - }), + assert.equal(yield* git(cwd, ["rev-parse", "HEAD~1"]), head); + assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "other.txt"); + }), ); const commitWithHooks = (hooks: Record) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 854e725455928..0cd4a10be39c4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -79,14 +79,6 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; -const GIT_DEFAULT_CONFLICT_MARKER_SIZE = 7; - -// Git falls back to the default for any value that is not a positive integer. -function parseConflictMarkerSize(value = "") { - const size = /^\+?\d+$/.test(value) ? Number.parseInt(value, 10) : 0; - return size > 0 ? size : GIT_DEFAULT_CONFLICT_MARKER_SIZE; -} - // Temporary-index git add scales poorly (50k files took ~30 s); report incomplete totals above this cap. const REVIEW_UNTRACKED_MAX_FILES = 5_000; // git diff --numstat reads every untracked byte (~5 ms/MB), so status totals skip larger untracked input. @@ -2485,209 +2477,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* filePaths?: readonly string[], env?: NodeJS.ProcessEnv, ) { - const isolatedIndexConfig = [ - "-c", - "core.splitIndex=false", - "-c", - "splitIndex.sharedIndexExpire=never", - ]; - const config = env ? isolatedIndexConfig : []; - const envOptions = env ? { env } : {}; + const config = env + ? ["-c", "core.splitIndex=false", "-c", "splitIndex.sharedIndexExpire=never"] + : []; let preserveUnselectedConflicts = false; - // Status paths are repository-relative, while cwd can be a subdirectory. - const toPathspec = (file: string) => `:(top,literal)${file}`; - const pathspecs = (filePaths ?? []).map(toPathspec); - const unmerged = yield* executeGit( - "GitVcsDriver.commit.unmerged", - cwd, - [ - "ls-files", - "--unmerged", - "-z", - "--full-name", - "--", - ...(pathspecs.length > 0 ? pathspecs : [":(top)"]), - ], - { ...envOptions, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, - ); - // Each record is " \t", once per stage. - const unmergedPaths = new Set( - splitNullSeparatedGitStdoutPaths(unmerged).map((record) => - record.slice(record.indexOf("\t") + 1), - ), - ); - if (unmergedPaths.size > 0) { - // add -A marks unmerged paths resolved, so check the files it would stage for leftover markers. - const files = Array.from(unmergedPaths); - const operation = "GitVcsDriver.commit.conflictMarkers"; - const checkFailed = "Could not check conflicted files for conflict markers."; - const root = yield* readWorktreeRoot(cwd); - if (root === null) { - return yield* new GitCommandError({ operation, command: "git", cwd, detail: checkFailed }); - } - // check-attr takes plain repository paths, not pathspecs, and prints one - // " " triple per path and attribute in input order. - const readAttributes = (source: readonly string[], options: { env?: NodeJS.ProcessEnv }) => - executeGit( - "GitVcsDriver.commit.conflictMarkerSize", - root, - [ - "check-attr", - ...source, - "-z", - "conflict-marker-size", - "working-tree-encoding", - "--", - ...files, - ], - { ...options, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, - ).pipe(Effect.map((result) => result.stdout.split("\0"))); - // HEAD's attributes come from a throwaway index, since check-attr --source needs Git 2.40. - const readHeadAttributes = Effect.gen(function* () { - const head = yield* executeGit( - "GitVcsDriver.commit.head", - cwd, - ["rev-parse", "--verify", "--quiet", "HEAD"], - { allowNonZeroExit: true }, - ); - if (head.exitCode !== 0) return []; - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-attributes-index-", - }); - const headIndex = { GIT_INDEX_FILE: path.join(directory, "index") }; - yield* runGit( - "GitVcsDriver.commit.headIndex", - cwd, - [...isolatedIndexConfig, "read-tree", "HEAD"], - { env: headIndex }, - ); - return yield* readAttributes(["--cached"], { env: headIndex }); - }).pipe( - Effect.scoped, - Effect.catchTags({ - PlatformError: (cause) => - new GitCommandError({ operation, command: "git", cwd, detail: checkFailed, cause }), - }), - ); - // Markers use the size and encoding from before the conflict; afterwards they may survive only in - // HEAD, the index or the worktree. An unborn HEAD leaves the defaults. - const lookups = yield* Effect.all( - [ - readAttributes([], envOptions), - readAttributes(["--cached"], envOptions), - readHeadAttributes, - ], - { concurrency: 3 }, - ); - const checks = new Map< - string, - { size: number; encoding: string | null; files: Set } - >(); - files.forEach((file, index) => { - for (const values of lookups) { - const size = parseConflictMarkerSize(values[index * 6 + 2]); - const value = values[index * 6 + 5] ?? "unspecified"; - const encoding = ["unspecified", "unset", "set"].includes(value) ? null : value; - const key = `${size} ${encoding ?? ""}`; - const check = checks.get(key) ?? { size, encoding, files: new Set() }; - check.files.add(file); - checks.set(key, check); - } - }); - const grepMarkers = Effect.fn("GitVcsDriver.commit.grepMarkers")(function* ( - size: number, - group: readonly string[], - command: readonly string[], - options: { env?: NodeJS.ProcessEnv }, - ) { - // Git writes exactly `size` marker characters, then whitespace and a label. - const args = [ - ...command, - "-l", - "-z", - "--full-name", - "-E", - "-e", - `^(<{${size}}|>{${size}})([[:space:]]|$)`, - "--", - ...group.map(toPathspec), - ]; - const markers = yield* executeGit(operation, cwd, args, { - ...options, - allowNonZeroExit: true, - maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, - }); - if (markers.exitCode !== 0 && markers.exitCode !== 1) { - return yield* new GitCommandError({ - ...gitCommandContext({ operation, cwd, args }), - detail: checkFailed, - ...(markers.exitCode === null ? {} : { exitCode: markers.exitCode }), - stdoutLength: markers.stdout.length, - stderrLength: markers.stderr.length, - }); - } - return splitNullSeparatedGitStdoutPaths(markers); - }); - // Git writes markers in the working-tree encoding. A throwaway repository's info/attributes - // outranks .gitattributes, so its index holds the files decoded from that encoding. - const grepEncodedMarkers = Effect.fn("GitVcsDriver.commit.grepEncodedMarkers")( - function* (size: number, encoding: string, group: readonly string[]) { - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-encoded-", - }); - yield* runGit("GitVcsDriver.commit.encodedRepository", root, [ - "init", - "--bare", - "--quiet", - "--template=", - directory, - ]); - yield* fileSystem.makeDirectory(path.join(directory, "info"), { recursive: true }); - yield* fileSystem.writeFileString( - path.join(directory, "info", "attributes"), - `* -filter working-tree-encoding=${encoding}\n`, - ); - const repository = [`--git-dir=${directory}`, `--work-tree=${root}`]; - const options = { env: { ...env, GIT_INDEX_FILE: path.join(directory, "index") } }; - // One file per call: a file that does not decode in this encoding is skipped, not fatal. - yield* Effect.forEach( - group, - (file) => - executeGit( - "GitVcsDriver.commit.encodedIndex", - root, - [...repository, ...isolatedIndexConfig, "update-index", "--add", "--", file], - { ...options, allowNonZeroExit: true }, - ), - { discard: true }, - ); - return yield* grepMarkers(size, group, [...repository, "grep", "--cached"], options); - }, - Effect.scoped, - Effect.catchTags({ - PlatformError: (cause) => - new GitCommandError({ operation, command: "git", cwd, detail: checkFailed, cause }), - }), - ); - const markedPaths = new Set(); - for (const { size, encoding, files: group } of checks.values()) { - const found = - encoding === null - ? yield* grepMarkers(size, [...group], ["grep"], envOptions) - : yield* grepEncodedMarkers(size, encoding, [...group]); - for (const file of found) markedPaths.add(file); - } - if (markedPaths.size > 0) { - const named = Array.from(markedPaths).slice(0, 5).join(", "); - const more = markedPaths.size > 5 ? ` and ${markedPaths.size - 5} more` : ""; - return yield* new GitCommandError({ - operation: "GitVcsDriver.commit", - command: "git commit", - cwd, - detail: `Cannot commit files that still contain conflict markers: ${named}${more}. Resolve the conflicts, then commit again.`, - }); - } - } if (filePaths && filePaths.length > 0) { if (env) { const head = yield* executeGit( @@ -2738,20 +2531,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* detail: `Cannot commit selected files during a ${operation}. Commit all files to finish the ${operation}.`, }); } - const remainingConflicts = yield* runGitStdout( - "GitVcsDriver.commit.unselectedConflicts", + const selectedFiles = new Set(filePaths); + const unmergedPaths = yield* runGitStdoutWithOptions( + "GitVcsDriver.commit.unmergedPaths", cwd, - [ - "ls-files", - "--unmerged", - "-z", - "--", - ":(top)", - ...filePaths.map((file) => `:(top,exclude,literal)${file}`), - ], + ["-c", "diff.relative=false", "diff", "--name-only", "--diff-filter=U", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ); - preserveUnselectedConflicts = remainingConflicts.length > 0; - // Reset discards conflict stages and attributes needed by the next commit's marker check. + // Reset drops every conflict stage, so keep the index while unselected files are conflicted. + preserveUnselectedConflicts = unmergedPaths + .split("\0") + .some((file) => file.length > 0 && !selectedFiles.has(file)); if (!preserveUnselectedConflicts) { yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]); } @@ -2846,7 +2636,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* env ? { env } : {}, ); } - return preserveUnselectedConflicts ? pathspecs : []; + return preserveUnselectedConflicts ? filePaths : undefined; }); const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = @@ -2982,14 +2772,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* options?: GitVcsDriver.GitCommitOptions, ) { const partialCommitPaths = - options?.stage !== undefined ? yield* stageCommitChanges(cwd, options.stage.filePaths) : []; + options?.stage !== undefined + ? yield* stageCommitChanges(cwd, options.stage.filePaths) + : undefined; const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } - if (partialCommitPaths.length > 0) { - args.push("--only", "--", ...partialCommitPaths); + if (partialCommitPaths) { + args.push("--only", "--pathspec-from-file=-", "--pathspec-file-nul"); } const progress = options?.progress?.onOutputLine === undefined @@ -3003,6 +2795,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, + ...(partialCommitPaths + ? { stdin: partialCommitPaths.map((file) => `:(top,literal)${file}\0`).join("") } + : {}), ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), }).pipe(Effect.asVoid); From 637e1a64123a46a19843115b37e38fdb5c8821a4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 16:26:50 +0000 Subject: [PATCH 6/8] fix(server): the selected-file conflict check reads only the index The unmerged-path list ran git diff against the working tree, which runs clean filters, so a failing required filter on an unrelated modified file blocked a selected-file commit that main makes. The list now uses git diff --cached, which returns the same unmerged paths from the index alone. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 21 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 3cfa3c853510e..28b9ee4ad6cfb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6636,6 +6636,27 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("commits a selected file while an unselected file's clean filter fails", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "filter.broken.clean", "cat"]); + yield* git(cwd, ["config", "filter.broken.required", "true"]); + yield* writeTextFile(cwd, ".gitattributes", "other.txt filter=broken\n"); + yield* writeTextFile(cwd, "other.txt", "base\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Add other"]); + yield* writeTextFile(cwd, "README.md", "selected\n"); + yield* writeTextFile(cwd, "other.txt", "changed\n"); + yield* git(cwd, ["config", "filter.broken.clean", "false"]); + + yield* driver.commit(cwd, "Selected", "", { stage: { filePaths: ["README.md"] } }); + + assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "README.md"); + }), + ); + it.effect("commits a selected file beside more than 1 MB of conflicted path names", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0cd4a10be39c4..a2520e66d0515 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2535,7 +2535,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const unmergedPaths = yield* runGitStdoutWithOptions( "GitVcsDriver.commit.unmergedPaths", cwd, - ["-c", "diff.relative=false", "diff", "--name-only", "--diff-filter=U", "-z"], + ["-c", "diff.relative=false", "diff", "--cached", "--name-only", "--diff-filter=U", "-z"], { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ); // Reset drops every conflict stage, so keep the index while unselected files are conflicted. From be7bc0a5f040a810d701a2edef9ba5b53a3acc9c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 05:37:01 +0000 Subject: [PATCH 7/8] fix(server): partial commits honor the old-Git pathspec fallback Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 65 -------------------- apps/server/src/vcs/GitVcsDriverCore.ts | 16 +++-- 2 files changed, 12 insertions(+), 69 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d069ba0383589..691e6574cfe0d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -8409,71 +8409,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("commits a selected file while an unselected file's clean filter fails", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* git(cwd, ["config", "filter.broken.clean", "cat"]); - yield* git(cwd, ["config", "filter.broken.required", "true"]); - yield* writeTextFile(cwd, ".gitattributes", "other.txt filter=broken\n"); - yield* writeTextFile(cwd, "other.txt", "base\n"); - yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "Add other"]); - yield* writeTextFile(cwd, "README.md", "selected\n"); - yield* writeTextFile(cwd, "other.txt", "changed\n"); - yield* git(cwd, ["config", "filter.broken.clean", "false"]); - - yield* driver.commit(cwd, "Selected", "", { stage: { filePaths: ["README.md"] } }); - - assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "README.md"); - }), - ); - - it.effect("commits a selected file beside more than 1 MB of conflicted path names", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.GitVcsDriver; - const dir = ["a", "b", "c"].map((letter) => letter.repeat(200)).join("/"); - yield* fs.makeDirectory(path.join(cwd, dir), { recursive: true }); - const files = Array.from( - { length: 1_250 }, - (_, index) => `${dir}/${String(index).padStart(4, "0")}-${"x".repeat(240)}`, - ); - assert.isAbove(files.join("\0").length, 1_000_000); - const writeAll = (content: string) => - Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), content), { - concurrency: 16, - discard: true, - }); - yield* writeAll("base\n"); - yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-qm", "Add files"]); - yield* writeAll("stashed\n"); - yield* git(cwd, ["stash", "-q"]); - yield* writeAll("committed\n"); - yield* git(cwd, ["commit", "-qam", "Change files"]); - const head = yield* git(cwd, ["rev-parse", "HEAD"]); - const pop = yield* driver.execute({ - operation: "test.stashPop", - cwd, - args: ["stash", "pop"], - allowNonZeroExit: true, - maxOutputBytes: 16 * 1024 * 1024, - }); - assert.equal(pop.exitCode, 1); - yield* writeTextFile(cwd, "other.txt", "other\n"); - - yield* driver.commit(cwd, "Add other", "", { stage: { filePaths: ["other.txt"] } }); - - assert.equal(yield* git(cwd, ["rev-parse", "HEAD~1"]), head); - assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "other.txt"); - }), - ); - const commitWithHooks = (hooks: Record) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index fec11d5b71ecf..db73d7848e17a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2885,7 +2885,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* env ? { env } : {}, ); } - return preserveUnselectedConflicts ? filePaths : undefined; + return preserveUnselectedConflicts && filePaths + ? filePaths.map((file) => `:(top,literal)${file}`) + : undefined; }); const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = @@ -3039,8 +3041,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } + const onlyFromStdin = partialCommitPaths ? yield* supportsPathspecFromFile(cwd) : false; if (partialCommitPaths) { - args.push("--only", "--pathspec-from-file=-", "--pathspec-file-nul"); + args.push( + "--only", + ...(onlyFromStdin + ? ["--pathspec-from-file=-", "--pathspec-file-nul"] + : ["--", ...partialCommitPaths]), + ); } const progress = options?.progress?.onOutputLine === undefined @@ -3054,8 +3062,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, - ...(partialCommitPaths - ? { stdin: partialCommitPaths.map((file) => `:(top,literal)${file}\0`).join("") } + ...(partialCommitPaths && onlyFromStdin + ? { stdin: partialCommitPaths.map((pathspec) => `${pathspec}\0`).join("") } : {}), ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), From 8adabcbdfd540e86238a0754ba263053b08fd5ec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 05:54:14 +0000 Subject: [PATCH 8/8] fix(server): a hook's staged edit reaches the index after a selected-file commit git commit --only runs hooks against a temporary index, so a pre-commit hook that formats and stages a selected file left the real index holding the pre-hook content. Reset the selected paths to HEAD after the commit; a path-limited reset leaves other files' conflict entries alone. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 15 +++++++++-- apps/server/src/vcs/GitVcsDriverCore.ts | 27 +++++++++++++------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 691e6574cfe0d..cc66c329ff003 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -8393,13 +8393,24 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* writeTextFile(cwd, "other.txt", "other\n"); const head = yield* git(cwd, ["rev-parse", "HEAD"]); const unmerged = yield* git(cwd, ["ls-files", "--unmerged"]); - const nested = (yield* Path.Path).join(cwd, "nested"); - yield* (yield* FileSystem.FileSystem).makeDirectory(nested); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nested = path.join(cwd, "nested"); + yield* fs.makeDirectory(nested); + // A formatting hook stages its edit into the temporary index that --only commits from. + yield* writeTextFile( + cwd, + ".git/hooks/pre-commit", + "#!/bin/sh\necho formatted >> other.txt\ngit add other.txt\n", + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/pre-commit"), 0o755); yield* driver.commit(cwd, "Add other", "", { stage: { filePaths: ["other.txt"] } }); assert.equal(yield* git(cwd, ["rev-parse", "HEAD~1"]), head); assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "other.txt"); + assert.equal(yield* git(cwd, ["show", "HEAD:other.txt"]), "other\nformatted"); + assert.equal(yield* git(cwd, ["show", ":other.txt"]), "other\nformatted"); assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); const all = yield* driver.prepareCommitContext(nested).pipe(Effect.flip); assert.equal( diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index db73d7848e17a..316e0acb2884a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3042,13 +3042,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* args.push("-m", trimmedBody); } const onlyFromStdin = partialCommitPaths ? yield* supportsPathspecFromFile(cwd) : false; + const onlyPathspecArgs = onlyFromStdin + ? ["--pathspec-from-file=-", "--pathspec-file-nul"] + : ["--", ...(partialCommitPaths ?? [])]; + const onlyPathspecStdin = + partialCommitPaths && onlyFromStdin + ? { stdin: partialCommitPaths.map((pathspec) => `${pathspec}\0`).join("") } + : {}; if (partialCommitPaths) { - args.push( - "--only", - ...(onlyFromStdin - ? ["--pathspec-from-file=-", "--pathspec-file-nul"] - : ["--", ...partialCommitPaths]), - ); + args.push("--only", ...onlyPathspecArgs); } const progress = options?.progress?.onOutputLine === undefined @@ -3062,12 +3064,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, - ...(partialCommitPaths && onlyFromStdin - ? { stdin: partialCommitPaths.map((pathspec) => `${pathspec}\0`).join("") } - : {}), + ...onlyPathspecStdin, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), }).pipe(Effect.asVoid); + if (partialCommitPaths) { + // --only runs hooks on a temporary index, so a hook's `git add` never reaches the real one. + yield* runGit( + "GitVcsDriver.commit.syncSelectedIndex", + cwd, + ["reset", "--quiet", "HEAD", ...onlyPathspecArgs], + onlyPathspecStdin, + ); + } const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD",