diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 76b1fb4f898b5..8f9fd37f110eb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -7396,30 +7396,49 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); it.effect.each([ - { location: "root", conflict: false }, - { location: "nested", conflict: false }, - { location: "worktree", conflict: false }, - { location: "root", conflict: true }, - { location: "nested", conflict: true }, - { location: "worktree", conflict: true }, + { location: "root", conflict: false, encoding: "utf8" }, + { location: "nested", conflict: false, encoding: "utf8" }, + { location: "worktree", conflict: false, encoding: "utf8" }, + { location: "root", conflict: true, encoding: "utf8" }, + { location: "nested", conflict: true, encoding: "utf8" }, + { location: "worktree", conflict: true, encoding: "utf8" }, + { location: "root", conflict: true, encoding: "utf16le" }, + { location: "root", conflict: true, encoding: "utf16le", large: true }, ])( - "rejects selected-file merge commits without mutation ($location, conflict: $conflict)", - ({ location, conflict }) => + "rejects selected-file merge commits without mutation ($location, conflict: $conflict, $encoding)", + ({ location, conflict, encoding, large }) => Effect.gen(function* () { let cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); + const writeReadme = (text: string) => + Effect.sync(() => + NodeFS.writeFileSync( + NodePath.join(cwd, "README.md"), + encoding === "utf16le" ? Buffer.from(`\ufeff${text}`, "utf16le") : text, + ), + ); + if (encoding === "utf16le") { + yield* writeTextFile( + cwd, + ".gitattributes", + "README.md text working-tree-encoding=UTF-16\n", + ); + yield* writeReadme("base\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Declare UTF-16 encoding"]); + } const driver = yield* GitVcsDriver.GitVcsDriver; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* git(cwd, ["checkout", "-b", "feature"]); yield* writeTextFile(cwd, "feature.txt", "feature\n"); - if (conflict) yield* writeTextFile(cwd, "README.md", "feature version\n"); + if (conflict) yield* writeReadme("feature version\n"); yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "Feature"]); const featureHead = yield* git(cwd, ["rev-parse", "HEAD"]); yield* git(cwd, ["checkout", initialBranch]); yield* writeTextFile(cwd, "main.txt", "main\n"); - if (conflict) yield* writeTextFile(cwd, "README.md", "main version\n"); + if (conflict) yield* writeReadme("main version\n"); yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "Main"]); const mainHead = yield* git(cwd, ["rev-parse", "HEAD"]); @@ -7468,6 +7487,7 @@ 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); + const resolvedText = `Heading\n=======\n\nresolved${large ? "x".repeat(17 * 1024 * 1024) : ""}\n`; if (conflict) { const unresolved = yield* driver .commit(commitCwd, "Unresolved merge", "", { stage: {} }) @@ -7476,8 +7496,10 @@ 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); // 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* writeReadme(resolvedText); + assert.isNotNull( + yield* driver.prepareCommitContext(commitCwd, undefined, { includePatch: false }), + ); } yield* driver.commit(commitCwd, "Finish merge", "", { stage: {} }); assert.equal( @@ -7487,7 +7509,12 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.isFalse(NodeFS.existsSync(path.join(gitDir, "MERGE_HEAD"))); assert.equal(yield* git(cwd, ["show", "HEAD:feature.txt"]), "feature"); assert.equal(yield* git(cwd, ["show", "HEAD:unrelated.txt"]), "unrelated"); - if (conflict) + if (large) { + assert.equal( + yield* git(cwd, ["cat-file", "-s", "HEAD:README.md"]), + String(Buffer.byteLength(resolvedText)), + ); + } else if (conflict) 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 280664cf8a136..bf58b74b6f067 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2696,7 +2696,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const reports = check.stdout.split("\n"); const conflicted: string[] = []; - let topLevel: string | undefined; + let emptyTree: string | undefined; for (const file of unmergedPaths) { const markerLines = new Set( reports.flatMap((line) => { @@ -2707,11 +2707,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); 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( + emptyTree ??= yield* readEmptyTreeHash(cwd); + // --check numbers lines after working-tree-encoding and clean filters; a diff from the + // empty tree prints that view. Read it in full so large resolved files can still commit. + const fileDiff = yield* runGitStdoutWithOptions( + "GitVcsDriver.commit.conflictMarkers", + cwd, + [ + "-c", + "diff.relative=false", + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--text", + emptyTree, + "--", + `:(top,literal)${file}`, + ], + { ...(env ? { env } : {}), maxOutputBytes: Number.POSITIVE_INFINITY }, + ).pipe( Effect.mapError( (cause) => new GitCommandError({ @@ -2722,7 +2737,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cause, }), ), - )).split("\n"); + ); + const diffLines = fileDiff.split("\n"); + const lines = diffLines + .slice(diffLines.findIndex((line) => line.startsWith("@@")) + 1) + .filter((line) => line.startsWith("+")) + .map((line) => line.slice(1)); // 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)); diff --git a/scripts/scenarios/utf16-merge-conflict-blocks-commit.e2e.ts b/scripts/scenarios/utf16-merge-conflict-blocks-commit.e2e.ts new file mode 100644 index 0000000000000..047d0f9a9e695 --- /dev/null +++ b/scripts/scenarios/utf16-merge-conflict-blocks-commit.e2e.ts @@ -0,0 +1,52 @@ +import { expect } from "e2e"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { run } from "./shell.ts"; +import { runPromise, test, WEB } from "./t3.ts"; + +const utf16 = (text: string) => Buffer.from(`\ufeff${text}`, "utf16le"); + +test( + "The commit dialog refuses a merge whose UTF-16 file still has conflict markers", + WEB, + async ({ t3, app, screen }) => { + const project = await t3.project("demo", { + ".gitattributes": "README.md text working-tree-encoding=UTF-16\n", + "README.md": utf16("base\n"), + }); + const git = (args: ReadonlyArray, allowExit: ReadonlyArray = [0]) => + runPromise( + run( + "git", + ["-c", "user.name=Scenario", "-c", "user.email=scenario@example.invalid", ...args], + { cwd: project, allowExit }, + ), + ); + const commitReadme = async (text: string) => { + await runPromise( + Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.writeFile(`${project}/README.md`, utf16(text)), + ), + ); + await git(["commit", "--quiet", "--all", "--message", text.trim()]); + }; + await git(["checkout", "--quiet", "-b", "feature"]); + await commitReadme("feature version\n"); + await git(["checkout", "--quiet", "main"]); + await commitReadme("main version\n"); + await git(["merge", "--quiet", "feature"], [1]); + const thread = await t3.thread({ project, title: "Merge", messages: ["merge"] }); + + await t3.open(thread); + await screen.getByRole("button", "Git action options").tap(); + await screen.getByRole("menuitem", "Commit").tap(); + await screen.getByPlaceholder("Leave empty to auto-generate").fill("Finish merge"); + await screen.getByRole("button", "Commit", { exact: true }).tap(); + // On a failed assertion e2e captures the committed merge itself. + await expect( + screen.getByText("unresolved merge conflicts in README.md", { exact: false }), + ).toBeVisible(); + await app.screenshot("refused"); + }, +);