diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index af5ac9ed72570..25401ec98544c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6507,7 +6507,17 @@ 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 unresolved = yield* driver + .commit(commitCwd, "Unresolved merge", "", { stage: {} }) + .pipe(Effect.result); + assert.isTrue(Result.isFailure(unresolved), "committed an unresolved conflict"); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), mainHead); + assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); + // A lone "=======" is a heading underline, not a conflict boundary. + yield* writeTextFile(cwd, "README.md", "Heading\n=======\n\nresolved\n"); + assert.isNotNull(yield* driver.prepareCommitContext(commitCwd)); + } yield* driver.commit(commitCwd, "Finish merge", "", { stage: {} }); assert.equal( yield* git(cwd, ["show", "-s", "--format=%P", "HEAD"]), @@ -6517,7 +6527,10 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(cwd, ["show", "HEAD:feature.txt"]), "feature"); assert.equal(yield* git(cwd, ["show", "HEAD:unrelated.txt"]), "unrelated"); if (conflict) - assert.equal(yield* git(cwd, ["show", "HEAD:README.md"]), "resolved version"); + assert.equal( + yield* git(cwd, ["show", "HEAD:README.md"]), + "Heading\n=======\n\nresolved", + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f9043e504cde7..a8e4fb9265f3d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2543,6 +2543,78 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); } else { + // add -A marks unmerged paths resolved, so refuse while one still has conflict markers. + const unmergedPaths = (yield* runGitStdoutWithOptions( + "GitVcsDriver.commit.unmergedPaths", + cwd, + ["-c", "diff.relative=false", "diff", "--name-only", "--diff-filter=U", "-z"], + env ? { env } : {}, + )) + .split("\0") + .filter((file) => file.length > 0); + if (unmergedPaths.length > 0) { + const check = yield* executeGit( + "GitVcsDriver.commit.conflictMarkers", + cwd, + [ + "-c", + "diff.relative=false", + "diff", + "--check", + "--no-color", + "--", + ...unmergedPaths.map((file) => `:(top,literal)${file}`), + ], + { + ...(env ? { env } : {}), + allowNonZeroExit: true, + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + const reports = check.stdout.split("\n"); + const conflicted: string[] = []; + let topLevel: string | undefined; + for (const file of unmergedPaths) { + const markerLines = new Set( + reports.flatMap((line) => { + const match = line.startsWith(`${file}:`) + ? /^(\d+): leftover conflict marker$/.exec(line.slice(file.length + 1)) + : null; + return match ? [Number(match[1]) - 1] : []; + }), + ); + if (markerLines.size === 0) continue; + topLevel ??= (yield* runGitStdout("GitVcsDriver.commit.topLevel", cwd, [ + "rev-parse", + "--show-toplevel", + ])).trim(); + const lines = (yield* fileSystem.readFileString(path.join(topLevel, file)).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.commit.conflictMarkers", + command: "git commit", + cwd, + detail: `Could not check ${file} for conflict markers.`, + cause, + }), + ), + )).split("\n"); + // Git also reports a lone "=======", which is valid content such as a heading underline. + const reported = (marker: string) => + lines.some((line, index) => markerLines.has(index) && line.startsWith(marker)); + if (reported("<") && reported(">")) conflicted.push(file); + } + if (conflicted.length > 0) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.commit", + command: "git commit", + cwd, + detail: `Cannot commit with unresolved merge conflicts in ${conflicted.join(", ")}. Resolve them, then commit.`, + }); + } + } yield* runGit( "GitVcsDriver.commit.addAll", cwd,