From b003101ad1ec3e963ab6ae6c1cfd7e5b38918884 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Wed, 14 Oct 2026 12:42:30 +1030 Subject: [PATCH 1/2] Add failing tests for merge mode changes (#18) Merge, fast-forward, cherry-pick and commit cases that lose a mode change made on one side, or print it as a create/delete pair, compared with git 2.34.1. --- test/commands/cherry-pick.test.ts | 24 ++++ test/commands/commit.test.ts | 24 ++++ test/commands/merge.test.ts | 199 ++++++++++++++++++++++++++++++ test/interop/merge-modes.test.ts | 92 ++++++++++++++ test/lib/merge.test.ts | 46 +++++++ 5 files changed, 385 insertions(+) create mode 100644 test/interop/merge-modes.test.ts diff --git a/test/commands/cherry-pick.test.ts b/test/commands/cherry-pick.test.ts index 98fafe14..0eec8b51 100644 --- a/test/commands/cherry-pick.test.ts +++ b/test/commands/cherry-pick.test.ts @@ -836,4 +836,28 @@ describe("git cherry-pick", () => { expect(result.stderr).toContain("no cherry-pick or revert in progress"); }); }); + + describe("file modes", () => { + test("applies a mode-only commit", async () => { + const bash = createTestBash({ files: EMPTY_REPO, env: envAt("100") }); + const setup = await bash.exec(` +c() { git add -A && git commit -qm "$1"; } +git init -q -b main +printf "a\\n" > f; chmod 644 f; echo x > x; c base +git checkout -q -b theirs; chmod 755 f; c theirs +git checkout -q main; chmod 644 f; echo y > x; c ours`); + expect(setup.exitCode).toBe(0); + + const result = await bash.exec("git cherry-pick theirs"); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatch(/^\[main [0-9a-f]{7}\] theirs\n/); + expect(result.stdout).toEndWith( + " 1 file changed, 0 insertions(+), 0 deletions(-)\n" + " mode change 100644 => 100755 f\n", + ); + expect((await bash.exec("git ls-files -s f")).stdout).toBe( + "100755 78981922613b2afb6025042ff6bd878ac1994e85 0\tf\n", + ); + }); + }); }); diff --git a/test/commands/commit.test.ts b/test/commands/commit.test.ts index 9dcf482d..bb24f92d 100644 --- a/test/commands/commit.test.ts +++ b/test/commands/commit.test.ts @@ -833,4 +833,28 @@ describe("git commit", () => { ); }); }); + + describe("file modes", () => { + test("summary prints a mode change line", async () => { + const bash = createTestBash({ files: EMPTY_REPO, env: TEST_ENV }); + const setup = await bash.exec(` +git init -q -b main +printf "a\\n" > f; chmod 644 f; git add -A; git commit -qm base`); + expect(setup.exitCode).toBe(0); + + await bash.exec("chmod 755 f && git add f"); + const modeOnly = await bash.exec("git commit -m mode"); + expect(modeOnly.exitCode).toBe(0); + expect(modeOnly.stdout).toEndWith( + " 1 file changed, 0 insertions(+), 0 deletions(-)\n mode change 100644 => 100755 f\n", + ); + + await bash.exec('printf "a\\nb\\n" > f && chmod 644 f && git add f'); + const withContent = await bash.exec("git commit -m both"); + expect(withContent.exitCode).toBe(0); + expect(withContent.stdout).toEndWith( + " 1 file changed, 1 insertion(+)\n mode change 100755 => 100644 f\n", + ); + }); + }); }); diff --git a/test/commands/merge.test.ts b/test/commands/merge.test.ts index e27b48fb..bcca11aa 100644 --- a/test/commands/merge.test.ts +++ b/test/commands/merge.test.ts @@ -814,4 +814,203 @@ describe("git merge", () => { expect(unresolved.stdout).toBe("U\tmain.txt\n"); }); }); + + // ── File modes ────────────────────────────────────────────────── + + describe("file modes", () => { + // Each commit sets its own modes: checkout does not rewrite permission + // bits here, so a branch switch can leave the previous branch's mode behind. + async function modeRepo(script: string) { + const bash = createTestBash({ files: EMPTY_REPO, env: envAt("100") }); + const setup = await bash.exec( + `c() { git add -A && git commit -qm "$1"; }\ngit init -q -b main\n${script}`, + ); + expect(setup.exitCode).toBe(0); + return bash; + } + + test("takes theirs' mode-only change", async () => { + const bash = await modeRepo(` +printf "a\\n" > f; chmod 644 f; c base +git checkout -q -b theirs; chmod 755 f; c theirs +git checkout -q main; chmod 644 f; echo o > o; c ours`); + + const result = await bash.exec("git merge -m merge theirs"); + expect(result).toMatchObject({ + exitCode: 0, + stderr: "", + stdout: + "Merge made by the 'ort' strategy.\n" + + " f | 0\n" + + " 1 file changed, 0 insertions(+), 0 deletions(-)\n" + + " mode change 100644 => 100755 f\n", + }); + expect((await bash.exec("git ls-files -s f")).stdout).toBe( + "100755 78981922613b2afb6025042ff6bd878ac1994e85 0\tf\n", + ); + }); + + test("keeps ours' mode change when theirs changes the content", async () => { + const bash = await modeRepo(` +printf "1\\n2\\n" > f; chmod 644 f; c base +git checkout -q -b theirs; printf "1\\nT\\n" > f; c theirs +git checkout -q main; chmod 755 f; c ours`); + + const result = await bash.exec("git merge -m merge theirs"); + expect(result).toMatchObject({ + exitCode: 0, + stderr: "", + stdout: + "Merge made by the 'ort' strategy.\n" + + " f | 2 +-\n" + + " 1 file changed, 1 insertion(+), 1 deletion(-)\n", + }); + expect((await bash.exec("git ls-files -s f")).stdout).toBe( + "100755 3f5a8cf24dd26339d0bd4f5873c9a8766e5660bd 0\tf\n", + ); + }); + + test("keeps theirs' mode change in a clean content merge", async () => { + const bash = await modeRepo(` +printf "1\\n2\\n3\\n4\\n5\\n" > f; chmod 644 f; c base +git checkout -q -b theirs; printf "1\\n2\\n3\\n4\\nT\\n" > f; chmod 755 f; c theirs +git checkout -q main; printf "O\\n2\\n3\\n4\\n5\\n" > f; chmod 644 f; c ours`); + + const result = await bash.exec("git merge -m merge theirs"); + expect(result).toMatchObject({ + exitCode: 0, + stderr: "", + stdout: + "Auto-merging f\n" + + "Merge made by the 'ort' strategy.\n" + + " f | 2 +-\n" + + " 1 file changed, 1 insertion(+), 1 deletion(-)\n" + + " mode change 100644 => 100755 f\n", + }); + expect((await bash.exec("git ls-files -s f")).stdout).toBe( + "100755 9d8d2f23dd74ac4621ad6af8f309ae779b7d4323 0\tf\n", + ); + }); + + test("conflicted merge keeps each side's mode in its stage and applies theirs' mode-only change", async () => { + const bash = await modeRepo(` +printf "1\\n2\\n" > f; echo k > m; chmod 644 f m; c base +git checkout -q -b theirs; printf "T\\n2\\n" > f; chmod 755 f m; c theirs +git checkout -q main; printf "O\\n2\\n" > f; chmod 644 f m; c ours`); + + const result = await bash.exec("git merge -m merge theirs"); + expect(result).toMatchObject({ + exitCode: 1, + stderr: "", + stdout: + "Auto-merging f\n" + + "CONFLICT (content): Merge conflict in f\n" + + "Automatic merge failed; fix conflicts and then commit the result.\n", + }); + expect((await bash.exec("git ls-files -s f m")).stdout).toBe( + "100644 1191247b6d9a206f6ba3d8ac79e26d041dd86941 1\tf\n" + + "100644 7fae8bc2659a610af0b10ee31e213f52278a63ac 2\tf\n" + + "100755 14fcbf669fee1a6e95015dd492056cc9bb527afa 3\tf\n" + + "100755 b68fde2a051d9af2fe3ff4c96c0898e5a3212e4d 0\tm\n", + ); + }); + + test("both sides making the same mode change keep it", async () => { + const bash = await modeRepo(` +printf "a\\n" > f; chmod 644 f; c base +git checkout -q -b theirs; chmod 755 f; echo t > t; c theirs +git checkout -q main; chmod 755 f; echo o > o; c ours`); + + const result = await bash.exec("git merge -m merge theirs"); + expect(result).toMatchObject({ + exitCode: 0, + stderr: "", + stdout: + "Merge made by the 'ort' strategy.\n" + + " t | 1 +\n" + + " 1 file changed, 1 insertion(+)\n" + + " create mode 100644 t\n", + }); + expect((await bash.exec("git ls-files -s f")).stdout).toBe( + "100755 78981922613b2afb6025042ff6bd878ac1994e85 0\tf\n", + ); + }); + + test("add/add with different modes conflicts even when the content matches", async () => { + const bash = await modeRepo(` +echo x > x; c base +git checkout -q -b theirs; echo a > f; chmod 755 f; c theirs +git checkout -q main; rm -f f; echo a > f; chmod 644 f; c ours`); + + const result = await bash.exec("git merge -m merge theirs"); + expect(result).toMatchObject({ + exitCode: 1, + stderr: "", + stdout: + "CONFLICT (add/add): Merge conflict in f\n" + + "Automatic merge failed; fix conflicts and then commit the result.\n", + }); + expect((await bash.exec("git ls-files -s f")).stdout).toBe( + "100644 78981922613b2afb6025042ff6bd878ac1994e85 2\tf\n" + + "100755 78981922613b2afb6025042ff6bd878ac1994e85 3\tf\n", + ); + expect(await readFile(bash.fs, "/repo/f")).toBe("a\n"); + }); + + test("takes theirs' mode change to a file ours renamed", async () => { + const bash = await modeRepo(` +printf "1\\n2\\n3\\n4\\n5\\n6\\n7\\n8\\n" > f; chmod 644 f; c base +git checkout -q -b theirs; chmod 755 f; c theirs +git checkout -q main; chmod 644 f; git mv f g; c ours`); + + const result = await bash.exec("git merge -m merge theirs"); + expect(result).toMatchObject({ + exitCode: 0, + stderr: "", + stdout: + "Merge made by the 'ort' strategy.\n" + + " g | 0\n" + + " 1 file changed, 0 insertions(+), 0 deletions(-)\n" + + " mode change 100644 => 100755 g\n", + }); + expect((await bash.exec("git ls-files -s f g")).stdout).toBe( + "100755 535d2b01d3397c2228490875defc92370602ca46 0\tg\n", + ); + }); + + test("rename/rename gives both new paths the merged mode", async () => { + const bash = await modeRepo(` +printf "1\\n2\\n3\\n4\\n5\\n6\\n7\\n8\\n" > f; chmod 644 f; c base +git checkout -q -b theirs; git mv f h; chmod 755 h; c theirs +git checkout -q main; rm -f h; git checkout -q -- f; chmod 644 f; git mv f g; c ours`); + + const result = await bash.exec("git merge -m merge theirs"); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe( + "CONFLICT (rename/rename): f renamed to g in HEAD and to h in theirs.\n" + + "Automatic merge failed; fix conflicts and then commit the result.\n", + ); + expect((await bash.exec("git ls-files -s f g h")).stdout).toBe( + "100644 535d2b01d3397c2228490875defc92370602ca46 1\tf\n" + + "100755 535d2b01d3397c2228490875defc92370602ca46 2\tg\n" + + "100755 535d2b01d3397c2228490875defc92370602ca46 3\th\n", + ); + }); + + test("fast-forward summary prints a mode change line", async () => { + const bash = await modeRepo(` +printf "a\\n" > f; chmod 644 f; c base +git checkout -q -b theirs; chmod 755 f; c theirs +git checkout -q main`); + + const result = await bash.exec("git merge theirs"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toEndWith( + "Fast-forward\n" + + " f | 0\n" + + " 1 file changed, 0 insertions(+), 0 deletions(-)\n" + + " mode change 100644 => 100755 f\n", + ); + }); + }); }); diff --git a/test/interop/merge-modes.test.ts b/test/interop/merge-modes.test.ts new file mode 100644 index 00000000..a7a2a53b --- /dev/null +++ b/test/interop/merge-modes.test.ts @@ -0,0 +1,92 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { chmodSync, cpSync, readFileSync } from "fs"; +import { join } from "path"; +import { + createSandbox, + jg, + justBash, + realGit, + realGitIn, + removeSandbox, + writeToSandbox, +} from "./util"; + +// The repository from issue #18. Ours edits f and makes p executable. Theirs +// edits f and p and makes f and m executable. `other` edits f so that it +// conflicts with theirs. +describe("interop: merge keeps a mode change from either side", () => { + let source: string; + const copies: string[] = []; + + beforeAll(async () => { + source = createSandbox(); + const git = realGitIn(source); + const write = (path: string, content: string, mode = 0o644) => { + writeToSandbox(source, path, content); + chmodSync(join(source, path), mode); + }; + const commit = async (message: string) => { + await git.execAsync(["add", "-A"]); + await git.execAsync(["commit", "-q", "-m", message]); + }; + + await git.execAsync(["init", "-q", "-b", "main"]); + write("f", "1\n2\n3\n4\n5\n"); + write("m", "k\n"); + write("p", "q\n"); + await commit("base"); + write("f", "O\n2\n3\n4\n5\n"); + write("p", "q\n", 0o755); + write("z", "z\n"); + await commit("ours"); + await git.execAsync(["checkout", "-q", "-b", "theirs", "main~1"]); + write("f", "1\n2\n3\n4\nT\n", 0o755); + write("m", "k\n", 0o755); + write("p", "q\nT\n"); + await commit("theirs"); + await git.execAsync(["checkout", "-q", "-b", "other", "main~1"]); + write("f", "X\n2\n3\n4\nX\n"); + await commit("other"); + }); + afterAll(() => { + removeSandbox(source); + for (const dir of copies) removeSandbox(dir); + }); + + async function checkoutCopy(branch: string): Promise { + const dir = createSandbox(); + copies.push(dir); + cpSync(source, dir, { recursive: true }); + await realGitIn(dir).execAsync(["checkout", "-q", branch]); + return dir; + } + + async function mergeBoth(branch: string) { + const realDir = await checkoutCopy(branch); + const jgDir = await checkoutCopy(branch); + const expected = await realGit(realDir, "merge -m merge theirs"); + const actual = await jg(justBash(jgDir), "git merge -m merge theirs"); + return { realDir, jgDir, expected, actual }; + } + + test("clean merge", async () => { + const { realDir, jgDir, expected, actual } = await mergeBoth("main"); + expect(expected.exitCode).toBe(0); + expect({ stdout: actual.stdout, stderr: actual.stderr, exitCode: actual.exitCode }).toEqual( + expected, + ); + const realIndex = await realGit(realDir, "ls-files -s"); + expect(realIndex.stdout).toContain("100755 b68fde2a051d9af2fe3ff4c96c0898e5a3212e4d 0\tm\n"); + expect(await realGit(jgDir, "ls-files -s")).toEqual(realIndex); + }); + + test("conflicted merge", async () => { + const { realDir, jgDir, expected, actual } = await mergeBoth("other"); + expect(expected.exitCode).toBe(1); + expect({ stdout: actual.stdout, stderr: actual.stderr, exitCode: actual.exitCode }).toEqual( + expected, + ); + expect(await realGit(jgDir, "ls-files -s")).toEqual(await realGit(realDir, "ls-files -s")); + expect(readFileSync(join(jgDir, "f"), "utf8")).toBe(readFileSync(join(realDir, "f"), "utf8")); + }); +}); diff --git a/test/lib/merge.test.ts b/test/lib/merge.test.ts index 0cc580d9..a3570fad 100644 --- a/test/lib/merge.test.ts +++ b/test/lib/merge.test.ts @@ -4,6 +4,7 @@ import { mergeOrtNonRecursive } from "../../src/lib/merge-ort"; import { readCommit } from "../../src/lib/object-db"; import { resolveRef } from "../../src/lib/refs"; import { findRepo } from "../../src/lib/repo"; +import { flattenTreeToMap } from "../../src/lib/tree-ops"; import type { GitContext, ObjectId } from "../../src/lib/types"; import { EMPTY_REPO, envAt } from "../fixtures"; import { createTestBash } from "../util"; @@ -320,3 +321,48 @@ describe("mergeOrtNonRecursive", () => { expect(fileEntry!.stage).toBe(0); }); }); + +// ── File modes ────────────────────────────────────────────────────── + +describe("mergeOrtNonRecursive file modes", () => { + // The result tree is what checkout writes, so its entry for a conflicted + // path carries the mode the worktree file gets. + for (const { side, oursMode, theirsMode } of [ + { side: "theirs", oursMode: "644", theirsMode: "755" }, + { side: "ours", oursMode: "755", theirsMode: "644" }, + ]) { + test(`conflicted content merge keeps ${side}' mode change in the result tree`, async () => { + const bash = createTestBash({ files: EMPTY_REPO, env: envAt("100") }); + const setup = await bash.exec(` +c() { git add -A && git commit -qm "$1"; } +git init -q -b main +printf "1\\n2\\n" > f; chmod 644 f; c base +git checkout -q -b theirs; printf "T\\n2\\n" > f; chmod ${theirsMode} f; c theirs +git checkout -q main; printf "O\\n2\\n" > f; chmod ${oursMode} f; c ours`); + expect(setup.exitCode).toBe(0); + + const gitCtx = (await findRepo(bash.fs, "/repo"))!; + const mainHash = (await resolveRef(gitCtx, "refs/heads/main"))!; + const theirsHash = (await resolveRef(gitCtx, "refs/heads/theirs"))!; + const baseHash = (await findMergeBase(gitCtx, mainHash, theirsHash))!; + const result = await mergeOrtNonRecursive( + gitCtx, + (await readCommit(gitCtx, baseHash)).tree, + (await readCommit(gitCtx, mainHash)).tree, + (await readCommit(gitCtx, theirsHash)).tree, + ); + + expect(result.conflicts).toEqual([{ path: "f", reason: "content" }]); + const stages = result.entries + .filter((e) => e.path === "f") + .map((e) => [e.stage, e.mode.toString(8)]); + expect(stages).toEqual([ + [1, "100644"], + [2, `100${oursMode}`], + [3, `100${theirsMode}`], + ]); + const resultTree = await flattenTreeToMap(gitCtx, result.resultTree); + expect(resultTree.get("f")?.mode).toBe("100755"); + }); + } +}); From eb31efd2da8fb9fb9661721cbe56a04c1594b1cb Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Wed, 14 Oct 2026 12:42:37 +1030 Subject: [PATCH 2/2] Fix #18: merge keeps a mode change from either side merge-ort compared blob hashes only, so a side that changed only the mode looked unchanged, and the three-way paths wrote whichever side's mode that branch of the code used. Compare hash and mode in the match mask and in triviallyResolve, and pick a regular file's merged mode the way git's handle_content_merge does: if ours kept the base mode, take theirs. The same rule applies to the processEntry shortcuts, the clean diff3 result, the conflicted worktree blob, the plain rename branches, rename/rename to one path and rename/rename(1to2). An add/add of one path with different modes is a conflict, as in git. The commit and diffstat summaries print git's "mode change" line for a modified or renamed path whose mode changed. --- .changeset/merge-file-modes.md | 5 + src/lib/commit-summary.ts | 13 ++- src/lib/merge-ort.ts | 164 +++++++++++++++++---------------- 3 files changed, 103 insertions(+), 79 deletions(-) create mode 100644 .changeset/merge-file-modes.md diff --git a/.changeset/merge-file-modes.md b/.changeset/merge-file-modes.md new file mode 100644 index 00000000..83826e3f --- /dev/null +++ b/.changeset/merge-file-modes.md @@ -0,0 +1,5 @@ +--- +"just-git": patch +--- + +`merge`, `cherry-pick`, `rebase`, `revert` and `stash apply` keep a file mode change made on either side. Before, a branch that only ran `chmod +x` merged cleanly and the result kept the old mode. Two sides adding the same path with different modes now conflict, as in git. Commit and merge summaries print `mode change 100644 => 100755 ` instead of a `create mode`/`delete mode` pair. diff --git a/src/lib/commit-summary.ts b/src/lib/commit-summary.ts index a3dfa6a7..4429afe6 100644 --- a/src/lib/commit-summary.ts +++ b/src/lib/commit-summary.ts @@ -64,6 +64,7 @@ export async function computeDiffStats( const fileStats: FileStat[] = []; const createModes: { path: string; mode: string }[] = []; const deleteModes: { path: string; mode: string }[] = []; + const modeChanges: { path: string; oldMode: string; newMode: string }[] = []; for (const diff of diffs) { if (diff.status === "added" && diff.newHash && diff.newMode) { @@ -139,8 +140,7 @@ export async function computeDiffStats( }); } if (diff.oldMode && diff.newMode && diff.oldMode !== diff.newMode) { - deleteModes.push({ path: diff.path, mode: diff.oldMode }); - createModes.push({ path: diff.path, mode: diff.newMode }); + modeChanges.push({ path: diff.path, oldMode: diff.oldMode, newMode: diff.newMode }); } } } @@ -177,12 +177,21 @@ export async function computeDiffStats( for (const { path, mode } of deleteModes) { modeLines.push({ sortKey: path, text: ` delete mode ${mode} ${path}` }); } + for (const { path, oldMode, newMode } of modeChanges) { + modeLines.push({ sortKey: path, text: ` mode change ${oldMode} => ${newMode} ${path}` }); + } for (const rename of renames) { const d = formatRenamePath(rename.oldPath, rename.newPath); modeLines.push({ sortKey: rename.newPath, text: ` rename ${d} (${rename.similarity}%)`, }); + if (rename.oldMode && rename.newMode && rename.oldMode !== rename.newMode) { + modeLines.push({ + sortKey: rename.newPath, + text: ` mode change ${rename.oldMode} => ${rename.newMode}`, + }); + } } modeLines.sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); diff --git a/src/lib/merge-ort.ts b/src/lib/merge-ort.ts index afe4b277..8e13a01d 100644 --- a/src/lib/merge-ort.ts +++ b/src/lib/merge-ort.ts @@ -287,14 +287,11 @@ async function collectMergeInfo( const filemask = (base ? 1 : 0) | (ours ? 2 : 0) | (theirs ? 4 : 0); - // Compute match mask (which stages have identical hash) - const bh = base?.hash ?? null; - const oh = ours?.hash ?? null; - const th = theirs?.hash ?? null; + // Compute match mask (which stages have identical hash and mode) let matchMask = 0; - if (bh !== null && bh === oh) matchMask |= 3; // base == ours - if (bh !== null && bh === th) matchMask |= 5; // base == theirs - if (oh !== null && oh === th) matchMask |= 6; // ours == theirs + if (base && sameVersion(baseV, oursV)) matchMask |= 3; // base == ours + if (base && sameVersion(baseV, theirsV)) matchMask |= 5; // base == theirs + if (ours && sameVersion(oursV, theirsV)) matchMask |= 6; // ours == theirs // Trivial resolution check const ci: ConflictInfo = { @@ -325,14 +322,13 @@ async function collectMergeInfo( */ function triviallyResolve(ci: ConflictInfo): boolean { const [base, ours, theirs] = ci.stages; - const bh = base?.hash ?? null; - const oh = ours?.hash ?? null; - const th = theirs?.hash ?? null; + const oursIsBase = sameVersion(ours, base); + const theirsIsBase = sameVersion(theirs, base); // All three match or both sides match base (no changes) - if (oh === bh && th === bh) { + if (oursIsBase && theirsIsBase) { if (ours) { - ci.merged = { result: { hash: oh, mode: ours.mode }, clean: true }; + ci.merged = { result: { hash: ours.hash, mode: ours.mode }, clean: true }; } else { ci.merged = { result: null, clean: true }; // all absent or all deleted } @@ -340,21 +336,21 @@ function triviallyResolve(ci: ConflictInfo): boolean { } // Both sides made identical changes - if (oh === th && oh !== null) { - ci.merged = { result: { hash: oh, mode: ours!.mode }, clean: true }; + if (ours && sameVersion(ours, theirs)) { + ci.merged = { result: { hash: ours.hash, mode: ours.mode }, clean: true }; return true; } // Both deleted - if (oh === null && th === null) { + if (!ours && !theirs) { ci.merged = { result: null, clean: true }; return true; } // Only ours changed (theirs matches base) - if (th === bh && oh !== bh) { + if (theirsIsBase) { if (ours) { - ci.merged = { result: { hash: oh, mode: ours.mode }, clean: true }; + ci.merged = { result: { hash: ours.hash, mode: ours.mode }, clean: true }; } else { ci.merged = { result: null, clean: true }; // ours deleted } @@ -362,9 +358,9 @@ function triviallyResolve(ci: ConflictInfo): boolean { } // Only theirs changed (ours matches base) - if (oh === bh && th !== bh) { + if (oursIsBase) { if (theirs) { - ci.merged = { result: { hash: th, mode: theirs.mode }, clean: true }; + ci.merged = { result: { hash: theirs.hash, mode: theirs.mode }, clean: true }; } else { ci.merged = { result: null, clean: true }; // theirs deleted } @@ -374,6 +370,24 @@ function triviallyResolve(ci: ConflictInfo): boolean { return false; // needs content merge or is a conflict } +/** Same blob and same mode, as git's match mask compares them. Two absent sides match. */ +function sameVersion(a: VersionInfo | null, b: VersionInfo | null): boolean { + if (!a || !b) return a === b; + return a.hash === b.hash && a.mode === b.mode; +} + +const REGULAR_FILE_MODES = new Set(["100644", "100755"]); + +/** + * Mode of a merged regular file, as in git's handle_content_merge: the + * result keeps whichever side changed the mode. Null when any side is not + * a regular file (symlink, gitlink), which callers handle on their own. + */ +function mergeFileMode(base: string, ours: string, theirs: string): string | null { + if (![base, ours, theirs].every((mode) => REGULAR_FILE_MODES.has(mode))) return null; + return ours === base ? theirs : ours; +} + // ── Phase 2: Rename detection ─────────────────────────────────────── /** @@ -501,7 +515,10 @@ async function detectAndProcessRenames( const theirsEntry = theirsMap.get(theirRename.newPath)!; if (oursEntry.hash === theirsEntry.hash) { - output.entries.push(makeFlatEntry(ourRename.newPath, oursEntry)); + const mode = mergeFileMode(base.mode, oursEntry.mode, theirsEntry.mode); + output.entries.push( + makeEntryFromHash(ourRename.newPath, mode ?? oursEntry.mode, oursEntry.hash), + ); } else { // Set up for content merge via ConflictInfo const ci = getOrCreate(paths, ourRename.newPath); @@ -545,19 +562,15 @@ async function detectAndProcessRenames( basePath, `CONFLICT (rename/rename): ${basePath} renamed to ${ourRename.newPath} in ${oursLabel} and to ${theirRename.newPath} in ${theirsLabel}.`, ); + // Both new paths get the merged content and, for regular files, the merged mode. + const mode = mergeFileMode(base.mode, oursEntry.mode, theirsEntry.mode); + const oursMode = mode ?? oursEntry.mode; + const theirsMode = mode ?? theirsEntry.mode; output.entries.push(makeFlatEntry(basePath, base, 1)); - output.entries.push(makeEntryFromHash(ourRename.newPath, oursEntry.mode, merged.hash, 2)); - output.entries.push( - makeEntryFromHash(theirRename.newPath, theirsEntry.mode, merged.hash, 3), - ); - output.worktreeBlobs.set(ourRename.newPath, { - hash: merged.hash, - mode: oursEntry.mode, - }); - output.worktreeBlobs.set(theirRename.newPath, { - hash: merged.hash, - mode: theirsEntry.mode, - }); + output.entries.push(makeEntryFromHash(ourRename.newPath, oursMode, merged.hash, 2)); + output.entries.push(makeEntryFromHash(theirRename.newPath, theirsMode, merged.hash, 3)); + output.worktreeBlobs.set(ourRename.newPath, { hash: merged.hash, mode: oursMode }); + output.worktreeBlobs.set(theirRename.newPath, { hash: merged.hash, mode: theirsMode }); } } else if (ourRename) { handledPaths.add(ourRename.newPath); @@ -636,17 +649,14 @@ async function detectAndProcessRenames( ); } else { // Normal rename — content merge at new path - if (theirsEntry.hash === base.hash && oursEntry.hash === base.hash) { - output.entries.push(makeFlatEntry(ourRename.newPath, oursEntry)); - } else if (theirsEntry.hash === base.hash) { - output.entries.push(makeFlatEntry(ourRename.newPath, oursEntry)); + const mode = mergeFileMode(base.mode, oursEntry.mode, theirsEntry.mode) ?? oursEntry.mode; + if (theirsEntry.hash === base.hash) { + output.entries.push(makeEntryFromHash(ourRename.newPath, mode, oursEntry.hash)); } else if (oursEntry.hash === base.hash) { // Ours only renamed (no content change), theirs modified. // Trivial resolution: take theirs' content at ours' new path. // Git does NOT emit "Auto-merging" for this case. - output.entries.push( - makeEntryFromHash(ourRename.newPath, oursEntry.mode, theirsEntry.hash), - ); + output.entries.push(makeEntryFromHash(ourRename.newPath, mode, theirsEntry.hash)); } else { // Content merge needed — set up ConflictInfo const ci = getOrCreate(paths, ourRename.newPath); @@ -739,17 +749,14 @@ async function detectAndProcessRenames( ); } else { // Normal rename — content merge at new path - if (oursEntry.hash === base.hash && theirsEntry.hash === base.hash) { - output.entries.push(makeFlatEntry(theirRename.newPath, theirsEntry)); - } else if (oursEntry.hash === base.hash) { - output.entries.push(makeFlatEntry(theirRename.newPath, theirsEntry)); + const mode = mergeFileMode(base.mode, oursEntry.mode, theirsEntry.mode) ?? theirsEntry.mode; + if (oursEntry.hash === base.hash) { + output.entries.push(makeEntryFromHash(theirRename.newPath, mode, theirsEntry.hash)); } else if (theirsEntry.hash === base.hash) { // Theirs only renamed (no content change), ours modified. // Trivial resolution: take ours' content at theirs' new path. // Git does NOT emit "Auto-merging" for this case. - output.entries.push( - makeEntryFromHash(theirRename.newPath, theirsEntry.mode, oursEntry.hash), - ); + output.entries.push(makeEntryFromHash(theirRename.newPath, mode, oursEntry.hash)); } else { // Content merge needed — set up ConflictInfo const ci = getOrCreate(paths, theirRename.newPath); @@ -1541,8 +1548,23 @@ async function processEntry( // ── Add/add (no base) — attempt three-way merge with empty base ── if (bh === null && oh !== null && th !== null) { + // Git keeps ours' mode but reports a conflict when the two sides add + // a regular file with different modes, even if the content merges. + const modeConflict = + ours!.mode !== theirs!.mode && + REGULAR_FILE_MODES.has(ours!.mode) && + REGULAR_FILE_MODES.has(theirs!.mode); + const addAddConflict = (worktreeHash: ObjectId) => { + conflicts.push({ path, reason: "add-add" }); + pushMsg(path, `CONFLICT (add/add): Merge conflict in ${path}`, 1); + entries.push(makeEntry(path, oh, ours!.mode, 2)); + entries.push(makeEntry(path, th, theirs!.mode, 3)); + worktreeBlobs.set(path, { hash: worktreeHash, mode: ours!.mode }); + }; + if (oh === th) { - entries.push(makeEntry(path, oh!, ours!.mode)); + if (modeConflict) addAddConflict(oh); + else entries.push(makeEntry(path, oh, ours!.mode)); return; } @@ -1552,16 +1574,12 @@ async function processEntry( const theirsText = await readBlobContent(ctx, th); if (isBinaryStr(oursText) || isBinaryStr(theirsText)) { - conflicts.push({ path, reason: "add-add" }); pushMsg( path, `warning: Cannot merge binary files: ${path} (${labels?.a ?? "HEAD"} vs. ${labels?.b ?? "theirs"})`, -1, ); - pushMsg(path, `CONFLICT (add/add): Merge conflict in ${path}`, 1); - entries.push(makeEntry(path, oh!, ours!.mode, 2)); - entries.push(makeEntry(path, th!, theirs!.mode, 3)); - worktreeBlobs.set(path, { hash: oh!, mode: ours!.mode }); + addAddConflict(oh); return; } @@ -1574,15 +1592,8 @@ async function processEntry( }); if (driverResult !== null) { const driverHash = await writeObject(ctx, "blob", encoder.encode(driverResult.content)); - if (!driverResult.conflict) { - entries.push(makeEntry(path, driverHash, ours!.mode)); - return; - } - conflicts.push({ path, reason: "add-add" }); - pushMsg(path, `CONFLICT (add/add): Merge conflict in ${path}`, 1); - entries.push(makeEntry(path, oh!, ours!.mode, 2)); - entries.push(makeEntry(path, th!, theirs!.mode, 3)); - worktreeBlobs.set(path, { hash: driverHash, mode: ours!.mode }); + if (driverResult.conflict || modeConflict) addAddConflict(driverHash); + else entries.push(makeEntry(path, driverHash, ours!.mode)); return; } } @@ -1596,21 +1607,17 @@ async function processEntry( if (!mergeResult.conflict) { const mergedHash = await writeCleanMergeBlob(ctx, mergeResult.result); - entries.push(makeEntry(path, mergedHash, ours!.mode)); + if (modeConflict) addAddConflict(mergedHash); + else entries.push(makeEntry(path, mergedHash, ours!.mode)); return; } - conflicts.push({ path, reason: "add-add" }); - pushMsg(path, `CONFLICT (add/add): Merge conflict in ${path}`, 1); - entries.push(makeEntry(path, oh!, ours!.mode, 2)); - entries.push(makeEntry(path, th!, theirs!.mode, 3)); const markerContent = renderConflictMarkers(oursText, "", theirsText, { a: labels?.a ?? "HEAD", b: labels?.b ?? "theirs", conflictStyle: labels?.conflictStyle, }); - const markerHash = await writeObject(ctx, "blob", encoder.encode(markerContent)); - worktreeBlobs.set(path, { hash: markerHash, mode: ours!.mode }); + addAddConflict(await writeObject(ctx, "blob", encoder.encode(markerContent))); return; } @@ -1618,20 +1625,22 @@ async function processEntry( if (bh !== null && oh !== null && th !== null) { // Trivial cases — resolve without "Auto-merging" message. // These can occur when rename detection pushes a path through Phase 3 - // even though only one side actually modified the file content. + // even though only one side actually modified the file content, or + // when one side changed only the mode. + const fileMode = mergeFileMode(base!.mode, ours!.mode, theirs!.mode); if (oh === bh) { // Only theirs modified — take theirs - entries.push(makeEntry(path, th!, theirs!.mode)); + entries.push(makeEntry(path, th!, fileMode ?? theirs!.mode)); return; } if (th === bh) { // Only ours modified — take ours - entries.push(makeEntry(path, oh!, ours!.mode)); + entries.push(makeEntry(path, oh!, fileMode ?? ours!.mode)); return; } if (oh === th) { // Both made identical changes — take either - entries.push(makeEntry(path, oh!, ours!.mode)); + entries.push(makeEntry(path, oh!, fileMode ?? ours!.mode)); return; } @@ -1650,6 +1659,7 @@ async function processEntry( return; } + const resultMode = fileMode ?? ours!.mode; const baseText = await readBlobContent(ctx, bh); const oursText = await readBlobContent(ctx, oh); const theirsText = await readBlobContent(ctx, th); @@ -1669,7 +1679,7 @@ async function processEntry( entries.push(makeEntry(path, bh, base!.mode, 1)); entries.push(makeEntry(path, oh, ours!.mode, 2)); entries.push(makeEntry(path, th, theirs!.mode, 3)); - worktreeBlobs.set(path, { hash: oh, mode: ours!.mode }); + worktreeBlobs.set(path, { hash: oh, mode: resultMode }); return; } @@ -1683,7 +1693,7 @@ async function processEntry( if (driverResult !== null) { const driverHash = await writeObject(ctx, "blob", encoder.encode(driverResult.content)); if (!driverResult.conflict) { - entries.push(makeEntry(path, driverHash, ours!.mode)); + entries.push(makeEntry(path, driverHash, resultMode)); return; } const oursOrigPath = ci.pathnames[1]; @@ -1699,7 +1709,7 @@ async function processEntry( entries.push(makeEntry(path, bh, base!.mode, 1)); entries.push(makeEntry(path, oh, ours!.mode, 2)); entries.push(makeEntry(path, th, theirs!.mode, 3)); - worktreeBlobs.set(path, { hash: driverHash, mode: ours!.mode }); + worktreeBlobs.set(path, { hash: driverHash, mode: resultMode }); return; } } @@ -1715,7 +1725,7 @@ async function processEntry( if (!mergeResult.conflict) { const mergedHash = await writeCleanMergeBlob(ctx, mergeResult.result); - entries.push(makeEntry(path, mergedHash, ours!.mode)); + entries.push(makeEntry(path, mergedHash, resultMode)); } else { // Conflict — write stages 1-3 for the index const oursOrigPath = ci.pathnames[1]; @@ -1747,7 +1757,7 @@ async function processEntry( conflictStyle: labels?.conflictStyle, }); const markerHash = await writeObject(ctx, "blob", encoder.encode(markerContent)); - worktreeBlobs.set(path, { hash: markerHash, mode: ours!.mode }); + worktreeBlobs.set(path, { hash: markerHash, mode: resultMode }); } return; }