From c8ec6277bccd88d040a7c53441158e40b98adf80 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:04:11 +1030 Subject: [PATCH 01/12] Add failing tests for checkout executable bit (#10) Checkout, clone, restore, switch and reset must apply a 100755 entry's mode, and status and diff must report a mode-only change the way git does. These fail on main. --- test/commands/add.test.ts | 12 ++++++++- test/commands/checkout.test.ts | 31 ++++++++++++++++++++++- test/commands/clone.test.ts | 19 +++++++++++++- test/commands/commit.test.ts | 12 ++++++++- test/commands/diff.test.ts | 39 +++++++++++++++++++++++++++- test/commands/reset.test.ts | 19 +++++++++++++- test/commands/restore.test.ts | 28 ++++++++++++++++++++- test/commands/status.test.ts | 24 +++++++++++++++++- test/commands/switch.test.ts | 22 +++++++++++++++- test/lib/worktree.test.ts | 46 ++++++++++++++++++++++++++++++++++ test/memory-fs.test.ts | 24 ++++++++++++++++++ test/tree-backed-fs.test.ts | 42 +++++++++++++++++++++++++++++++ test/util.ts | 22 ++++++++++++++++ 13 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 test/lib/worktree.test.ts create mode 100644 test/tree-backed-fs.test.ts diff --git a/test/commands/add.test.ts b/test/commands/add.test.ts index c38d423a..a03e1a90 100644 --- a/test/commands/add.test.ts +++ b/test/commands/add.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, NESTED_REPO, TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, runScenario } from "../util"; +import { createTestBash, quickExec, runScenario, setupExecBitRepo } from "../util"; describe("git add", () => { describe("outside a git repo", () => { @@ -593,3 +593,13 @@ describe("git add", () => { }); }); }); + +describe("git add: executable bit", () => { + test("add stages a mode-only change", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh && git add run.sh"); + expect((await bash.exec("git ls-files -s")).stdout).toBe( + "100644 4163036efa65bd4a469e752267498f01ea36a55c 0\trun.sh\n", + ); + }); +}); diff --git a/test/commands/checkout.test.ts b/test/commands/checkout.test.ts index 93147d8c..9a0ec8dd 100644 --- a/test/commands/checkout.test.ts +++ b/test/commands/checkout.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, readFile, runScenario, setupClonePair } from "../util"; +import { + createTestBash, + permissionBits, + quickExec, + readFile, + runScenario, + setupClonePair, + setupExecBitRepo, +} from "../util"; describe("git checkout", () => { describe("outside a git repo", () => { @@ -657,3 +665,24 @@ describe("git checkout", () => { }); }); }); + +describe("git checkout: executable bit", () => { + test("checkout -- restores the executable bit", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("rm run.sh"); + const result = await bash.exec("git checkout -- run.sh"); + expect(result.exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); + + test("checkout flips the bit to match the branch", async () => { + const bash = await setupExecBitRepo(); + expect((await bash.exec("git checkout plain")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o644); + expect((await bash.exec("git status --short")).stdout).toBe(""); + expect((await bash.exec("git checkout main")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); +}); diff --git a/test/commands/clone.test.ts b/test/commands/clone.test.ts index 86ea3b78..adf5c8c3 100644 --- a/test/commands/clone.test.ts +++ b/test/commands/clone.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import { TEST_ENV as ENV } from "../fixtures"; -import { createTestBash, isDirectory, pathExists, readFile } from "../util"; +import { + createTestBash, + isDirectory, + pathExists, + permissionBits, + readFile, + setupExecBitRepo, +} from "../util"; async function setupSource(files: Record = {}) { const bash = createTestBash({ @@ -448,3 +455,13 @@ describe("git clone", () => { }); }); }); + +describe("git clone: executable bit", () => { + test("cloned executable files are executable", async () => { + const bash = await setupExecBitRepo(); + const result = await bash.exec("git clone /repo /clone", { cwd: "/" }); + expect(result.exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/clone/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short", { cwd: "/clone" })).stdout).toBe(""); + }); +}); diff --git a/test/commands/commit.test.ts b/test/commands/commit.test.ts index 9dcf482d..8e1c5411 100644 --- a/test/commands/commit.test.ts +++ b/test/commands/commit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, readFile, runScenario } from "../util"; +import { createTestBash, quickExec, readFile, runScenario, setupExecBitRepo } from "../util"; describe("git commit", () => { describe("outside a git repo", () => { @@ -834,3 +834,13 @@ describe("git commit", () => { }); }); }); + +describe("git commit: executable bit", () => { + test("commit after checkout records no mode change", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("rm run.sh && git checkout -- run.sh && git add -A"); + const result = await bash.exec("git commit -m noop"); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe("On branch main\nnothing to commit, working tree clean\n"); + }); +}); diff --git a/test/commands/diff.test.ts b/test/commands/diff.test.ts index fdcdaeea..ad7eb4e4 100644 --- a/test/commands/diff.test.ts +++ b/test/commands/diff.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, observeFsCalls, quickExec } from "../util"; +import { createTestBash, observeFsCalls, quickExec, setupExecBitRepo } from "../util"; describe("git diff", () => { describe("outside a git repo", () => { @@ -1195,3 +1195,40 @@ describe("git diff", () => { }); }); }); + +describe("git diff: executable bit", () => { + const MODE_ONLY = "diff --git a/run.sh b/run.sh\nold mode 100755\nnew mode 100644\n"; + + test("mode-only change prints old and new mode", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh"); + expect((await bash.exec("git diff")).stdout).toBe(MODE_ONLY); + expect((await bash.exec("git diff HEAD")).stdout).toBe(MODE_ONLY); + expect((await bash.exec("git diff --stat")).stdout).toBe( + " run.sh | 0\n 1 file changed, 0 insertions(+), 0 deletions(-)\n", + ); + await bash.exec("git add run.sh"); + expect((await bash.exec("git diff --cached")).stdout).toBe(MODE_ONLY); + expect((await bash.exec("git diff")).stdout).toBe(""); + }); + + test("content and mode change prints mode lines then an index line without mode", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh && printf '#!/bin/sh\\necho bye\\n' > run.sh"); + expect((await bash.exec("git diff")).stdout).toBe( + [ + "diff --git a/run.sh b/run.sh", + "old mode 100755", + "new mode 100644", + "index 4163036..ac23810", + "--- a/run.sh", + "+++ b/run.sh", + "@@ -1,2 +1,2 @@", + " #!/bin/sh", + "-echo hi", + "+echo bye", + "", + ].join("\n"), + ); + }); +}); diff --git a/test/commands/reset.test.ts b/test/commands/reset.test.ts index b86f750a..49043971 100644 --- a/test/commands/reset.test.ts +++ b/test/commands/reset.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, readFile } from "../util"; +import { createTestBash, permissionBits, readFile, setupExecBitRepo } from "../util"; describe("git reset", () => { describe("--quiet", () => { @@ -112,3 +112,20 @@ describe("git reset", () => { }); }); }); + +describe("git reset: executable bit", () => { + test("reset --hard restores the executable bit", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh"); + expect((await bash.exec("git reset --hard")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); + + test("reset --hard clears the executable bit", async () => { + const bash = await setupExecBitRepo(); + expect((await bash.exec("git reset --hard plain")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o644); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); +}); diff --git a/test/commands/restore.test.ts b/test/commands/restore.test.ts index 0f48a2f3..19f77d29 100644 --- a/test/commands/restore.test.ts +++ b/test/commands/restore.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import { EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, readFile, runScenario } from "../util"; +import { + createTestBash, + permissionBits, + quickExec, + readFile, + runScenario, + setupExecBitRepo, +} from "../util"; describe("git restore", () => { describe("errors", () => { @@ -325,3 +332,22 @@ describe("git restore", () => { }); }); }); + +describe("git restore: executable bit", () => { + test("restore sets the executable bit from the index", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh"); + const result = await bash.exec("git restore run.sh"); + expect(result.exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); + + test("restore --source writes the source tree's mode", async () => { + const bash = await setupExecBitRepo(); + const result = await bash.exec("git restore --source plain run.sh"); + expect(result.exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o644); + expect((await bash.exec("git status --short")).stdout).toBe(" M run.sh\n"); + }); +}); diff --git a/test/commands/status.test.ts b/test/commands/status.test.ts index e05d3cf9..d4cc6941 100644 --- a/test/commands/status.test.ts +++ b/test/commands/status.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, setSystemTime, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, NESTED_REPO, TEST_ENV } from "../fixtures"; -import { createTestBash, observeFsCalls, quickExec, runScenario } from "../util"; +import { createTestBash, observeFsCalls, quickExec, runScenario, setupExecBitRepo } from "../util"; describe("git status", () => { describe("outside a git repo", () => { @@ -767,3 +767,25 @@ describe("git status", () => { }); }); }); + +describe("git status: executable bit", () => { + test("chmod -x on a tracked executable shows as modified", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("chmod 644 run.sh"); + expect((await bash.exec("git status --short")).stdout).toBe(" M run.sh\n"); + expect((await bash.exec("git status")).stdout).toBe( + [ + "On branch main", + "Changes not staged for commit:", + ' (use "git add ..." to update what will be committed)', + ' (use "git restore ..." to discard changes in working directory)', + "\tmodified: run.sh", + "", + 'no changes added to commit (use "git add" and/or "git commit -a")', + "", + ].join("\n"), + ); + await bash.exec("git add run.sh"); + expect((await bash.exec("git status --short")).stdout).toBe("M run.sh\n"); + }); +}); diff --git a/test/commands/switch.test.ts b/test/commands/switch.test.ts index a4d7e210..e37160af 100644 --- a/test/commands/switch.test.ts +++ b/test/commands/switch.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, readFile, runScenario, setupClonePair } from "../util"; +import { + createTestBash, + permissionBits, + quickExec, + readFile, + runScenario, + setupClonePair, + setupExecBitRepo, +} from "../util"; describe("git switch", () => { describe("errors", () => { @@ -478,3 +486,15 @@ describe("git switch", () => { }); }); }); + +describe("git switch: executable bit", () => { + test("switch flips the bit to match the branch", async () => { + const bash = await setupExecBitRepo(); + expect((await bash.exec("git switch plain")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o644); + expect((await bash.exec("git status --short")).stdout).toBe(""); + expect((await bash.exec("git switch main")).exitCode).toBe(0); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + }); +}); diff --git a/test/lib/worktree.test.ts b/test/lib/worktree.test.ts new file mode 100644 index 00000000..1f3d2703 --- /dev/null +++ b/test/lib/worktree.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { MemoryFileSystem } from "../../src"; +import { writeObject } from "../../src/lib/object-db.ts"; +import { PackedObjectStore } from "../../src/lib/object-store.ts"; +import { FileSystemRefStore } from "../../src/lib/refs.ts"; +import type { GitContext } from "../../src/lib/types.ts"; +import { checkoutEntry } from "../../src/lib/worktree.ts"; + +const GIT_DIR = "/wt/.git"; + +async function context(fs: MemoryFileSystem): Promise { + await fs.mkdir(GIT_DIR, { recursive: true }); + return { + fs, + gitDir: GIT_DIR, + commonDir: GIT_DIR, + workTree: "/wt", + objectStore: new PackedObjectStore(fs, GIT_DIR), + refStore: new FileSystemRefStore(fs, GIT_DIR, GIT_DIR), + }; +} + +describe("checkoutEntry executable bit", () => { + test("applies 100755 and clears it for 100644 over an executable file", async () => { + const fs = new MemoryFileSystem(); + const ctx = await context(fs); + const hash = await writeObject(ctx, "blob", new TextEncoder().encode("#!/bin/sh\n")); + + await checkoutEntry(ctx, { path: "run.sh", hash, mode: 0o100755 }); + expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100755); + + await checkoutEntry(ctx, { path: "run.sh", hash, mode: "100644" }); + expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100644); + }); + + test("a FileSystem without chmod still checks out the file", async () => { + const fs = new MemoryFileSystem(); + Object.defineProperty(fs, "chmod", { value: undefined }); + const ctx = await context(fs); + const hash = await writeObject(ctx, "blob", new TextEncoder().encode("#!/bin/sh\n")); + + await checkoutEntry(ctx, { path: "run.sh", hash, mode: 0o100755 }); + expect(await fs.readFile("/wt/run.sh")).toBe("#!/bin/sh\n"); + expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100644); + }); +}); diff --git a/test/memory-fs.test.ts b/test/memory-fs.test.ts index 6d764b57..38ff6733 100644 --- a/test/memory-fs.test.ts +++ b/test/memory-fs.test.ts @@ -358,3 +358,27 @@ describe("MemoryFileSystem", () => { expect(await fs.readFile("/repo/feature.txt")).toBe("new feature"); }); }); + +describe("MemoryFileSystem chmod", () => { + test("chmod sets permission bits and keeps the file type", async () => { + const fs = new MemoryFileSystem({ "/run.sh": "#!/bin/sh\n" }); + await fs.chmod("/run.sh", 0o755); + expect((await fs.stat("/run.sh")).mode).toBe(0o100755); + await fs.chmod("/run.sh", 0o644); + expect((await fs.stat("/run.sh")).mode).toBe(0o100644); + expect(await fs.readFile("/run.sh")).toBe("#!/bin/sh\n"); + }); + + test("chmod follows symlinks", async () => { + const fs = new MemoryFileSystem({ "/target": "x" }); + await fs.symlink("target", "/link"); + await fs.chmod("/link", 0o755); + expect((await fs.stat("/target")).mode).toBe(0o100755); + expect((await fs.lstat("/link")).mode).toBe(0o120000); + }); + + test("chmod throws ENOENT for a missing path", async () => { + const fs = new MemoryFileSystem(); + await expect(fs.chmod("/missing", 0o755)).rejects.toThrow("ENOENT"); + }); +}); diff --git a/test/tree-backed-fs.test.ts b/test/tree-backed-fs.test.ts new file mode 100644 index 00000000..1eea4c08 --- /dev/null +++ b/test/tree-backed-fs.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { writeBlob, writeTree } from "../src/repo/writing.ts"; +import { MemoryStorage } from "../src/server/memory-storage.ts"; +import { createStorageAdapter } from "../src/server/storage.ts"; +import { TreeBackedFs } from "../src/tree-backed-fs.ts"; + +async function treeFs(): Promise { + const repo = await createStorageAdapter(new MemoryStorage()).createRepo("test"); + const blob = await writeBlob(repo, "#!/bin/sh\n"); + const tree = await writeTree(repo, [ + { name: "plain.sh", hash: blob, mode: "100644" }, + { name: "exec.sh", hash: blob, mode: "100755" }, + ]); + return new TreeBackedFs(repo.objectStore, tree, "/wt"); +} + +describe("TreeBackedFs chmod", () => { + test("chmod on a tree-backed file changes its mode and keeps its content", async () => { + const fs = await treeFs(); + await fs.chmod("/wt/plain.sh", 0o755); + expect((await fs.lstat("/wt/plain.sh")).mode).toBe(0o100755); + expect((await fs.stat("/wt/plain.sh")).mode).toBe(0o100755); + expect(await fs.readFile("/wt/plain.sh")).toBe("#!/bin/sh\n"); + await fs.chmod("/wt/exec.sh", 0o644); + expect((await fs.lstat("/wt/exec.sh")).mode).toBe(0o100644); + }); + + test("chmod on an overlay file changes its mode", async () => { + const fs = await treeFs(); + await fs.writeFile("/wt/new.sh", "echo\n"); + await fs.chmod("/wt/new.sh", 0o755); + expect((await fs.stat("/wt/new.sh")).mode).toBe(0o100755); + expect(await fs.readFile("/wt/new.sh")).toBe("echo\n"); + }); + + test("chmod throws ENOENT for a missing or removed path", async () => { + const fs = await treeFs(); + await expect(fs.chmod("/wt/missing", 0o755)).rejects.toThrow("ENOENT"); + await fs.rm("/wt/plain.sh"); + await expect(fs.chmod("/wt/plain.sh", 0o755)).rejects.toThrow("ENOENT"); + }); +}); diff --git a/test/util.ts b/test/util.ts index b28ce270..ca703353 100644 --- a/test/util.ts +++ b/test/util.ts @@ -104,3 +104,25 @@ export async function isFile(fs: IFileSystem, path: string): Promise { const stat = await fs.stat(path); return stat.isFile; } + +/** + * Repo at /repo with an executable `run.sh` committed on `main`, plus a + * `plain` branch where the same content is committed as 100644. Ends on `main`. + */ +export async function setupExecBitRepo(): Promise { + const bash = createTestBash({ + files: { "/repo/run.sh": "#!/bin/sh\necho hi\n" }, + env: TEST_ENV, + }); + await bash.exec("git init && chmod 755 run.sh && git add run.sh && git commit -m exec"); + await bash.exec( + "git switch -c plain && chmod 644 run.sh && git add run.sh && git commit -m plain", + ); + await bash.exec("git switch main"); + return bash; +} + +/** Permission bits of a path in the virtual filesystem (e.g. `0o755`). */ +export async function permissionBits(fs: IFileSystem, path: string): Promise { + return (await fs.stat(path)).mode & 0o777; +} From 6319fc0673ad3fbb27158327243aa5588b705744 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:04:11 +1030 Subject: [PATCH 02/12] Fix #10: checkout sets the executable bit and status/diff report mode changes Add an optional FileSystem.chmod, implement it for MemoryFileSystem and TreeBackedFs, and have checkoutEntry apply or clear the executable bit. Compare modes as well as hashes when diffing the index against the worktree and against HEAD, and in unpack-trees' same(), so a mode-only change is reported and checked out instead of silently committed. --- .changeset/checkout-exec-bit.md | 5 ++++ docs/FILE_REFERENCE.md | 2 +- src/commands/diff.ts | 10 +++---- src/fs.ts | 11 +++++++- src/lib/command-utils.ts | 8 ++++-- src/lib/diff-algorithm.ts | 10 +++++-- src/lib/status-format.ts | 2 +- src/lib/symlink.ts | 6 ++++ src/lib/types.ts | 2 ++ src/lib/unpack-trees.ts | 44 ++++++++++++++++++++++++------ src/lib/worktree.ts | 47 ++++++++++++++++++++++++-------- src/memory-fs.ts | 6 ++++ src/tree-backed-fs.ts | 32 ++++++++++++++++++++++ test/lib/unpack-trees/helpers.ts | 2 ++ 14 files changed, 153 insertions(+), 34 deletions(-) create mode 100644 .changeset/checkout-exec-bit.md diff --git a/.changeset/checkout-exec-bit.md b/.changeset/checkout-exec-bit.md new file mode 100644 index 00000000..b5a3ffed --- /dev/null +++ b/.changeset/checkout-exec-bit.md @@ -0,0 +1,5 @@ +--- +"just-git": patch +--- + +Checkout applies the executable bit of `100755` entries through the new optional `FileSystem.chmod`, and `status`/`diff` report mode-only changes (`old mode 100755` / `new mode 100644`) instead of silently committing them. diff --git a/docs/FILE_REFERENCE.md b/docs/FILE_REFERENCE.md index 3811d1df..7727db81 100644 --- a/docs/FILE_REFERENCE.md +++ b/docs/FILE_REFERENCE.md @@ -250,7 +250,7 @@ Exported symbols from `src/lib/`. Generated by `bun scripts/gen-lib-reference.ts ### lib/symlink.ts -- `lstatSafe`, `isSymlinkMode`, `isSubmoduleMode`, `readWorktreeContent` +- `lstatSafe`, `isSymlinkMode`, `isExecutableMode`, `isSubmoduleMode`, `readWorktreeContent` ### lib/tree-ops.ts diff --git a/src/commands/diff.ts b/src/commands/diff.ts index f2e7ffce..169b6f62 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -229,15 +229,13 @@ async function collectUnstaged( const indexEntry = stage0.get(diff.path); if (!indexEntry) continue; - const oldMode = fmtMode(indexEntry.mode); - items.push({ path: diff.path, status: diff.status === "deleted" ? "D" : "M", oldHash: indexEntry.hash, newHash: diff.worktreeHash, - oldMode, - newMode: oldMode, + oldMode: fmtMode(indexEntry.mode), + newMode: fmtMode(diff.worktreeMode ?? indexEntry.mode), newFromWorkTree: diff.status === "modified", }); } @@ -390,7 +388,7 @@ async function collectCached( newHash: entry.hash, newMode: fmtMode(entry.mode), }); - } else if (baseEntry.hash !== entry.hash) { + } else if (baseEntry.hash !== entry.hash || Number.parseInt(baseEntry.mode, 8) !== entry.mode) { diffs.push({ path, status: "modified", @@ -585,7 +583,7 @@ async function collectCommitToWorkTree( const effectiveHash = workTreeDiff?.status === "modified" ? workTreeDiff.worktreeHash : indexEntry.hash; - const effectiveMode = fmtMode(indexEntry.mode); + const effectiveMode = fmtMode(workTreeDiff?.worktreeMode ?? indexEntry.mode); if (effectiveHash !== entry.hash || effectiveMode !== entry.mode) { items.push({ path, diff --git a/src/fs.ts b/src/fs.ts index 80992a97..3a93a789 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -38,7 +38,9 @@ export interface FileStat { * * The three optional symlink methods (`lstat`, `readlink`, `symlink`) enable * symlink support. When omitted, symlinks degrade to plain files containing - * the target path as content (`core.symlinks=false` behavior). + * the target path as content (`core.symlinks=false` behavior). The optional + * `chmod` lets checkout apply the executable bit of `100755` entries; when + * omitted, every file is checked out non-executable. * * See {@link MemoryFileSystem} for a ready-made in-memory implementation. */ @@ -98,6 +100,13 @@ export interface FileSystem { * @throws Error if `path` already exists. */ symlink?(target: string, path: string): Promise; + /** + * Set the permission bits of a file (e.g. `0o755`), following symlinks. + * Checkout uses this to apply or clear the executable bit; without it, + * a `100755` blob is checked out as `100644`. + * @throws Error if the path doesn't exist. + */ + chmod?(path: string, mode: number): Promise; /** * Move/rename a file or directory from `src` to `dest`. * diff --git a/src/lib/command-utils.ts b/src/lib/command-utils.ts index 1b5c3ef0..9aba35d2 100644 --- a/src/lib/command-utils.ts +++ b/src/lib/command-utils.ts @@ -214,14 +214,18 @@ export async function requireAuthor( * Compares stage-0 index entries against the tree map for modifications, * additions, and deletions. */ -export function hasStagedChanges(index: Index, headMap: Map): boolean { - const stage0 = new Map(); +export function hasStagedChanges( + index: Index, + headMap: Map, +): boolean { + const stage0 = new Map(); for (const e of index.entries) { if (e.stage === 0) stage0.set(e.path, e); } for (const [path, entry] of stage0) { const headEntry = headMap.get(path); if (!headEntry || headEntry.hash !== entry.hash) return true; + if (Number.parseInt(headEntry.mode, 8) !== entry.mode) return true; } for (const [path] of headMap) { if (!stage0.has(path)) return true; diff --git a/src/lib/diff-algorithm.ts b/src/lib/diff-algorithm.ts index 94d3d446..4bb7b355 100644 --- a/src/lib/diff-algorithm.ts +++ b/src/lib/diff-algorithm.ts @@ -1169,6 +1169,10 @@ function formatHashForIndexLine(hash?: string): string { return abbreviateHash(hash); } +function hasModeChange(opts: FormatOptions): boolean { + return !!opts.oldMode && !!opts.newMode && opts.oldMode !== opts.newMode; +} + function pushDiffGitHeader( out: string[], opts: FormatOptions, @@ -1199,7 +1203,7 @@ function formatBinaryDiff(opts: FormatOptions, oldIsBinary: boolean, newIsBinary const newPath = opts.renameTo ?? path; const isNew = opts.isNew ?? oldContent === ""; const isDeleted = opts.isDeleted ?? newContent === ""; - if (oldContent === newContent && !isRename) return ""; + if (oldContent === newContent && !isRename && !hasModeChange(opts)) return ""; const out: string[] = []; pushDiffGitHeader(out, opts, newPath, isNew, isDeleted, isRename); @@ -1281,7 +1285,7 @@ export function formatUnifiedDiff(opts: FormatOptions): string { } const hunks = buildHunks(edits, opts.contextLines); - if (hunks.length === 0 && !isRename) return ""; + if (hunks.length === 0 && !isRename && !hasModeChange(opts)) return ""; const isNew = opts.isNew ?? oldContent === ""; const isDeleted = opts.isDeleted ?? newContent === ""; @@ -1289,7 +1293,7 @@ export function formatUnifiedDiff(opts: FormatOptions): string { const out: string[] = []; pushDiffGitHeader(out, opts, newPath, isNew, isDeleted, isRename); - // For exact renames with no content change, stop here + // For exact renames and mode-only changes, stop after the header if (hunks.length === 0) { return `${out.join("\n")}\n`; } diff --git a/src/lib/status-format.ts b/src/lib/status-format.ts index e484f231..3376ffeb 100644 --- a/src/lib/status-format.ts +++ b/src/lib/status-format.ts @@ -452,7 +452,7 @@ export async function getStagedChanges( newHash: entry.hash, newMode: entry.mode.toString(8).padStart(6, "0"), }); - } else if (headEntry.hash !== entry.hash) { + } else if (headEntry.hash !== entry.hash || Number.parseInt(headEntry.mode, 8) !== entry.mode) { rawDiffs.push({ path, status: "modified", diff --git a/src/lib/symlink.ts b/src/lib/symlink.ts index fcc54f76..1631ccf6 100644 --- a/src/lib/symlink.ts +++ b/src/lib/symlink.ts @@ -13,6 +13,12 @@ export function isSymlinkMode(mode: number | string): boolean { return mode === 0o120000; } +/** Check whether a git mode (numeric or string) represents an executable file. */ +export function isExecutableMode(mode: number | string): boolean { + if (typeof mode === "string") return mode === "100755"; + return mode === 0o100755; +} + /** Check whether a git mode (numeric or string) represents a submodule (gitlink). */ export function isSubmoduleMode(mode: number | string): boolean { if (typeof mode === "string") return mode === "160000"; diff --git a/src/lib/types.ts b/src/lib/types.ts index 6f49209d..383513d0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -302,4 +302,6 @@ export interface WorkTreeDiff { indexHash?: ObjectId; /** Hash computed from modified worktree content during comparison. */ worktreeHash?: ObjectId; + /** Git mode of the modified worktree entry (e.g. 0o100755). */ + worktreeMode?: number; } diff --git a/src/lib/unpack-trees.ts b/src/lib/unpack-trees.ts index 2853c20a..87be8555 100644 --- a/src/lib/unpack-trees.ts +++ b/src/lib/unpack-trees.ts @@ -81,6 +81,9 @@ interface PathState { /** Hash currently in the index (stage 0), or null if not in index. */ indexHash: ObjectId | null; + /** Mode currently in the index (stage 0), or null if not in index. */ + indexMode: number | null; + /** Stage of the current index entry (0 for normal, >0 for conflict). */ indexStage: number; @@ -364,6 +367,7 @@ async function buildPathStates( const indexEntry = stage0Map.get(path); const indexHash = indexEntry?.hash ?? null; + const indexMode = indexEntry?.mode ?? null; const indexStage = conflictPaths.has(path) ? 1 : 0; const existsOnDisk = worktreeFiles.has(path); @@ -402,6 +406,7 @@ async function buildPathStates( headHash, remoteHash, indexHash, + indexMode, indexStage, existsOnDisk, isIgnoredOnDisk, @@ -418,6 +423,25 @@ async function buildPathStates( // SECTION 7: One-Way Merge // ===================================================================== +/** + * Git's `same()`: two entries match only when hash and mode agree, so a + * mode-only change (100644 ↔ 100755) still counts as a change. + */ +function same( + hashA: ObjectId | null, + modeA: string | number | null, + hashB: ObjectId | null, + modeB: string | number | null, +): boolean { + if (hashA !== hashB) return false; + if (hashA === null) return true; + return modeNumber(modeA) === modeNumber(modeB); +} + +function modeNumber(mode: string | number | null): number | null { + return typeof mode === "string" ? Number.parseInt(mode, 8) : mode; +} + /** * One-way merge: replace the index with a target tree. * Used by: git reset --hard, git merge --abort @@ -447,7 +471,7 @@ export function onewayMerge(state: PathState, opts: UnpackOptions): MergeDecisio } // Target present, index matches: keep (preserves stat info) - if (state.indexHash === target) { + if (same(state.indexHash, state.indexMode, target, state.remoteMode)) { return { action: MergeAction.KEEP, requirements: [], @@ -497,10 +521,14 @@ export function onewayMerge(state: PathState, opts: UnpackOptions): MergeDecisio */ export function twowayMerge(state: PathState, opts: UnpackOptions): MergeDecision { const { headHash: old, remoteHash: nu, indexHash: idx } = state; + const { headMode: oldMode, remoteMode: nuMode, indexMode: idxMode } = state; + const sameOldNew = same(old, oldMode, nu, nuMode); + const sameIdxOld = same(idx, idxMode, old, oldMode); + const sameIdxNew = same(idx, idxMode, nu, nuMode); // Handle conflicted index entry (stages > 0) if (state.indexStage > 0) { - if (old === nu) { + if (sameOldNew) { // Trees agree — resolve conflict if (nu === null) { return { action: MergeAction.DELETE, requirements: [] }; @@ -542,7 +570,7 @@ export function twowayMerge(state: PathState, opts: UnpackOptions): MergeDecisio if (old !== null) { // Staged deletion: file was in old tree but removed from index. // git: if (oldtree && !o->initial_checkout) { ... } - if (old === nu) { + if (sameOldNew) { // Old == new: trees unchanged, preserve staged deletion return { action: MergeAction.SKIP, @@ -584,12 +612,12 @@ export function twowayMerge(state: PathState, opts: UnpackOptions): MergeDecisio } // Cases 6/7: old absent, new matches index → keep index - if (old === null && nu === idx) { + if (old === null && sameIdxNew) { return { action: MergeAction.KEEP, caseNumber: 6, requirements: [] }; } // Case 10: old matches index, new absent → delete - if (old === idx && nu === null) { + if (sameIdxOld && nu === null) { return { action: MergeAction.DELETE, caseNumber: 10, @@ -598,17 +626,17 @@ export function twowayMerge(state: PathState, opts: UnpackOptions): MergeDecisio } // Cases 14/15: old==new (trees unchanged) → keep index - if (old !== null && old === nu) { + if (old !== null && sameOldNew) { return { action: MergeAction.KEEP, caseNumber: 14, requirements: [] }; } // Cases 18/19: index already matches new → keep index - if (old !== null && nu !== null && idx === nu) { + if (old !== null && nu !== null && sameIdxNew) { return { action: MergeAction.KEEP, caseNumber: 18, requirements: [] }; } // Case 20: index matches old, new differs → take new - if (old !== null && nu !== null && idx === old && idx !== nu) { + if (old !== null && nu !== null && sameIdxOld && !sameIdxNew) { return { action: MergeAction.TAKE, takeFrom: "remote", diff --git a/src/lib/worktree.ts b/src/lib/worktree.ts index 315eb927..c27f1fae 100644 --- a/src/lib/worktree.ts +++ b/src/lib/worktree.ts @@ -12,8 +12,9 @@ import { import { readObject, writeObject } from "./object-db.ts"; import { isInsideWorkTree, verifyPath, verifySymlinkTarget } from "./path-safety.ts"; import { dirname, join } from "./path.ts"; -import { isSubmoduleMode, isSymlinkMode, lstatSafe } from "./symlink.ts"; +import { isExecutableMode, isSubmoduleMode, isSymlinkMode, lstatSafe } from "./symlink.ts"; import { flattenTree } from "./tree-ops.ts"; +import type { FileStat } from "../fs.ts"; import type { GitContext, Index, IndexEntry, ObjectId, WorkTreeDiff } from "./types.ts"; const encoder = new TextEncoder(); @@ -88,14 +89,16 @@ export async function diffIndexToWorkTree( if (indexStatMatchesFile(entry, st, indexTimestamp)) continue; + const workTreeMode = worktreeMode(ctx, entry, st); const workTreeHash = await hashCleanedWorktreeEntry(ctx, fullPath, entry.hash, st); - if (workTreeHash !== entry.hash) { + if (workTreeHash !== entry.hash || workTreeMode !== entry.mode) { results.push({ path: entry.path, status: "modified", indexHash: entry.hash, worktreeHash: workTreeHash, + worktreeMode: workTreeMode, }); if (stopAfterFirst) return results; } @@ -120,6 +123,16 @@ export async function diffIndexToWorkTree( return results.sort((a, b) => comparePaths(a.path, b.path)); } +/** + * Mode git would record for a worktree entry. A filesystem without symlink + * support checks symlinks out as plain files, so a regular file at an index + * symlink's path still counts as the symlink (git's `core.symlinks=false`). + */ +function worktreeMode(ctx: GitContext, entry: IndexEntry, st: FileStat): number { + if (st.isFile && isSymlinkMode(entry.mode) && !ctx.fs.symlink) return entry.mode; + return gitModeFromFileStat(st); +} + // ── Checkout ──────────────────────────────────────────────────────── /** @@ -179,23 +192,33 @@ export async function checkoutEntry( } await ctx.fs.symlink(target, fullPath); } else { - // For regular files, also remove stale symlinks at the same path - // so that writeFile doesn't follow the old symlink. - if (ctx.fs.lstat) { - try { - const st = await ctx.fs.lstat(fullPath); - if (st.isSymbolicLink) { - await ctx.fs.rm(fullPath, { force: true }); - } - } catch { - // Path doesn't exist — fine + // For regular files, remove a stale symlink at the same path so that + // writeFile doesn't follow it. Otherwise remember the existing mode: + // writeFile keeps it on real filesystems, so a 100644 entry over an + // executable file must clear the bit explicitly. + let existingMode: number | null = null; + try { + const st = await lstatSafe(ctx.fs, fullPath); + if (st.isSymbolicLink) { + await ctx.fs.rm(fullPath, { force: true }); + } else { + existingMode = st.mode; } + } catch { + // Path doesn't exist — fine } // Smudge: with core.autocrlf=true, checkout writes CRLF line endings // (lfToCrlf declines for binary or already-CR content). const policy = await getEolPolicy(ctx); const content = policy.smudgeCrlf ? lfToCrlf(raw.content) : raw.content; await ctx.fs.writeFile(fullPath, content); + if (ctx.fs.chmod) { + if (entry.mode != null && isExecutableMode(entry.mode)) { + await ctx.fs.chmod(fullPath, 0o755); + } else if (existingMode !== null && existingMode & 0o111) { + await ctx.fs.chmod(fullPath, 0o644); + } + } } } diff --git a/src/memory-fs.ts b/src/memory-fs.ts index 392f999d..684ac068 100644 --- a/src/memory-fs.ts +++ b/src/memory-fs.ts @@ -262,6 +262,12 @@ export class MemoryFileSystem implements FileSystem { } } + async chmod(path: string, mode: number): Promise { + const entry = this.data.get(this.resolve(path)); + if (!entry) throw new Error(`ENOENT: no such file or directory, chmod '${path}'`); + entry.mode = (entry.mode & ~0o7777) | (mode & 0o7777); + } + async readlink(path: string): Promise { const resolved = this.resolveParent(path); const entry = this.data.get(resolved); diff --git a/src/tree-backed-fs.ts b/src/tree-backed-fs.ts index 9d5e57e2..d33cb3ca 100644 --- a/src/tree-backed-fs.ts +++ b/src/tree-backed-fs.ts @@ -45,6 +45,10 @@ function dirname(path: string): string { return i <= 0 ? "/" : path.slice(0, i); } +function withPermissionBits(current: number, mode: number): number { + return (current & ~0o7777) | (mode & 0o7777); +} + function treeModeToStatMode(mode: string): number { switch (mode) { case FM.EXECUTABLE: @@ -512,6 +516,34 @@ export class TreeBackedFs implements FileSystem { } } + async chmod(path: string, mode: number): Promise { + const norm = normalize(path); + const overlayEntry = this.overlay.get(norm); + if (overlayEntry) { + overlayEntry.mode = withPermissionBits(overlayEntry.mode, mode); + return; + } + if (this.removals.has(norm) || !(await this.exists(norm))) { + throw new Error(`ENOENT: no such file or directory, chmod '${path}'`); + } + // Tree-backed entries are immutable, so copy the entry into the overlay + // with the new bits. Symlinks keep their 120000 mode, as in git. + const st = await this.lstat(norm); + if (st.isSymbolicLink) return; + this.ensureOverlayParents(norm); + this.overlay.set( + norm, + st.isDirectory + ? { type: "directory", mode: withPermissionBits(st.mode, mode), mtime: this.epoch } + : { + type: "file", + content: await this.readFileBuffer(norm), + mode: withPermissionBits(st.mode, mode), + mtime: this.epoch, + }, + ); + } + async readlink(path: string): Promise { const norm = normalize(path); diff --git a/test/lib/unpack-trees/helpers.ts b/test/lib/unpack-trees/helpers.ts index c88c6e47..92fc6a83 100644 --- a/test/lib/unpack-trees/helpers.ts +++ b/test/lib/unpack-trees/helpers.ts @@ -33,6 +33,7 @@ interface PathStateInput { headHash?: ObjectId | null; remoteHash?: ObjectId | null; indexHash?: ObjectId | null; + indexMode?: number | null; indexStage?: number; existsOnDisk?: boolean; ignoredOnDisk?: boolean; @@ -63,6 +64,7 @@ export function makeState(input: PathStateInput = {}): PathState { headHash: input.headHash ?? null, remoteHash: input.remoteHash ?? null, indexHash, + indexMode: input.indexMode ?? (indexHash ? 0o100644 : null), indexStage: input.indexStage ?? 0, existsOnDisk, isIgnoredOnDisk: async () => ignoredOnDisk, From 55f800bf91b2e46ff4bb802b6737c3c5d0c27a4d Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:27:53 +1030 Subject: [PATCH 03/12] Drop narrating comments from the exec-bit change --- src/lib/diff-algorithm.ts | 1 - src/lib/symlink.ts | 1 - src/lib/types.ts | 1 - src/lib/unpack-trees.ts | 5 ----- src/lib/worktree.ts | 9 +-------- src/tree-backed-fs.ts | 2 -- test/util.ts | 5 ----- 7 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/lib/diff-algorithm.ts b/src/lib/diff-algorithm.ts index 4bb7b355..e31a3737 100644 --- a/src/lib/diff-algorithm.ts +++ b/src/lib/diff-algorithm.ts @@ -1293,7 +1293,6 @@ export function formatUnifiedDiff(opts: FormatOptions): string { const out: string[] = []; pushDiffGitHeader(out, opts, newPath, isNew, isDeleted, isRename); - // For exact renames and mode-only changes, stop after the header if (hunks.length === 0) { return `${out.join("\n")}\n`; } diff --git a/src/lib/symlink.ts b/src/lib/symlink.ts index 1631ccf6..0f10bb33 100644 --- a/src/lib/symlink.ts +++ b/src/lib/symlink.ts @@ -13,7 +13,6 @@ export function isSymlinkMode(mode: number | string): boolean { return mode === 0o120000; } -/** Check whether a git mode (numeric or string) represents an executable file. */ export function isExecutableMode(mode: number | string): boolean { if (typeof mode === "string") return mode === "100755"; return mode === 0o100755; diff --git a/src/lib/types.ts b/src/lib/types.ts index 383513d0..65a8e104 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -302,6 +302,5 @@ export interface WorkTreeDiff { indexHash?: ObjectId; /** Hash computed from modified worktree content during comparison. */ worktreeHash?: ObjectId; - /** Git mode of the modified worktree entry (e.g. 0o100755). */ worktreeMode?: number; } diff --git a/src/lib/unpack-trees.ts b/src/lib/unpack-trees.ts index 87be8555..22c09bb0 100644 --- a/src/lib/unpack-trees.ts +++ b/src/lib/unpack-trees.ts @@ -81,7 +81,6 @@ interface PathState { /** Hash currently in the index (stage 0), or null if not in index. */ indexHash: ObjectId | null; - /** Mode currently in the index (stage 0), or null if not in index. */ indexMode: number | null; /** Stage of the current index entry (0 for normal, >0 for conflict). */ @@ -423,10 +422,6 @@ async function buildPathStates( // SECTION 7: One-Way Merge // ===================================================================== -/** - * Git's `same()`: two entries match only when hash and mode agree, so a - * mode-only change (100644 ↔ 100755) still counts as a change. - */ function same( hashA: ObjectId | null, modeA: string | number | null, diff --git a/src/lib/worktree.ts b/src/lib/worktree.ts index c27f1fae..df015d31 100644 --- a/src/lib/worktree.ts +++ b/src/lib/worktree.ts @@ -123,11 +123,6 @@ export async function diffIndexToWorkTree( return results.sort((a, b) => comparePaths(a.path, b.path)); } -/** - * Mode git would record for a worktree entry. A filesystem without symlink - * support checks symlinks out as plain files, so a regular file at an index - * symlink's path still counts as the symlink (git's `core.symlinks=false`). - */ function worktreeMode(ctx: GitContext, entry: IndexEntry, st: FileStat): number { if (st.isFile && isSymlinkMode(entry.mode) && !ctx.fs.symlink) return entry.mode; return gitModeFromFileStat(st); @@ -204,9 +199,7 @@ export async function checkoutEntry( } else { existingMode = st.mode; } - } catch { - // Path doesn't exist — fine - } + } catch {} // Smudge: with core.autocrlf=true, checkout writes CRLF line endings // (lfToCrlf declines for binary or already-CR content). const policy = await getEolPolicy(ctx); diff --git a/src/tree-backed-fs.ts b/src/tree-backed-fs.ts index d33cb3ca..e19c21dc 100644 --- a/src/tree-backed-fs.ts +++ b/src/tree-backed-fs.ts @@ -526,8 +526,6 @@ export class TreeBackedFs implements FileSystem { if (this.removals.has(norm) || !(await this.exists(norm))) { throw new Error(`ENOENT: no such file or directory, chmod '${path}'`); } - // Tree-backed entries are immutable, so copy the entry into the overlay - // with the new bits. Symlinks keep their 120000 mode, as in git. const st = await this.lstat(norm); if (st.isSymbolicLink) return; this.ensureOverlayParents(norm); diff --git a/test/util.ts b/test/util.ts index ca703353..11c30c89 100644 --- a/test/util.ts +++ b/test/util.ts @@ -105,10 +105,6 @@ export async function isFile(fs: IFileSystem, path: string): Promise { return stat.isFile; } -/** - * Repo at /repo with an executable `run.sh` committed on `main`, plus a - * `plain` branch where the same content is committed as 100644. Ends on `main`. - */ export async function setupExecBitRepo(): Promise { const bash = createTestBash({ files: { "/repo/run.sh": "#!/bin/sh\necho hi\n" }, @@ -122,7 +118,6 @@ export async function setupExecBitRepo(): Promise { return bash; } -/** Permission bits of a path in the virtual filesystem (e.g. `0o755`). */ export async function permissionBits(fs: IFileSystem, path: string): Promise { return (await fs.stat(path)).mode & 0o777; } From 725894f623194fa4787b5ebf9f9270a555b7ec08 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:33:10 +1030 Subject: [PATCH 04/12] Treat a FileSystem without chmod as core.fileMode=false worktreeMode in lib/index.ts is now the one place that derives a worktree entry's git mode, mirroring git's ce_mode_from_stat. Without chmod the exec bit cannot be written back, so a tracked regular file keeps its index mode and a new file is staged as 100644. Before this, every 100755 file showed modified after clone on such a filesystem and commit -a flipped it to 100644. --- src/lib/index.ts | 35 ++++++++++++++++++++--- src/lib/symlink.ts | 5 ++++ src/lib/worktree.ts | 18 ++++-------- test/commands/file-mode.test.ts | 49 +++++++++++++++++++++++++++++++++ test/lib/index.test.ts | 28 +++++++++++++------ 5 files changed, 111 insertions(+), 24 deletions(-) create mode 100644 test/commands/file-mode.test.ts diff --git a/src/lib/index.ts b/src/lib/index.ts index cbbd874f..07bac1a2 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,9 +1,9 @@ -import type { FileStat } from "../fs.ts"; +import type { FileStat, FileSystem } from "../fs.ts"; import { bytesToHex, hexToBytes } from "./hex.ts"; import { verifyPath } from "./path-safety.ts"; import { join } from "./path.ts"; import { sha1 } from "./sha1.ts"; -import { isSubmoduleMode, lstatSafe } from "./symlink.ts"; +import { isRegularFileMode, isSubmoduleMode, isSymlinkMode, lstatSafe } from "./symlink.ts"; import type { GitContext, Index, IndexEntry, IndexStat } from "./types.ts"; // ── Constants ─────────────────────────────────────────────────────── @@ -192,13 +192,14 @@ export function indexStatFromFileStat(stat: FileStat, size: number = stat.size): * and must still be hashed. */ export function indexStatMatchesFile( + fs: FileSystem, entry: IndexEntry, stat: FileStat, indexTimestamp: Date | undefined, ): boolean { if (!indexTimestamp || !stat.isFile || stat.isSymbolicLink) return false; if (entry.stat.mtimeSeconds === 0 || stat.size < 0 || stat.size > 0xffffffff) return false; - if (gitModeFromFileStat(stat) !== entry.mode || stat.size !== entry.stat.size) return false; + if (worktreeMode(fs, entry, stat) !== entry.mode || stat.size !== entry.stat.size) return false; const fileMtimeMs = timestampMilliseconds(stat.mtime); const indexMtimeMs = timestampMilliseconds(indexTimestamp); @@ -231,6 +232,32 @@ export function gitModeFromFileStat(stat: FileStat): number { return stat.mode & 0o111 ? 0o100755 : 0o100644; } +function fileStandsInForSymlink( + fs: FileSystem, + entry: Pick, + stat: FileStat, +): boolean { + return stat.isFile && isSymlinkMode(entry.mode) && !fs.symlink; +} + +/** + * Git's `ce_mode_from_stat`: the mode a worktree path gets, given the index + * entry at that path. A filesystem without `chmod` cannot hold the exec bit, + * so it is treated as `core.fileMode=false`: a tracked regular file keeps its + * index mode and a new one is 100644, whatever `stat` reports. + */ +export function worktreeMode( + fs: FileSystem, + entry: Pick | undefined, + stat: FileStat, +): number { + if (entry && fileStandsInForSymlink(fs, entry, stat)) return entry.mode; + if (stat.isFile && !fs.chmod) { + return entry && isRegularFileMode(entry.mode) ? entry.mode : 0o100644; + } + return gitModeFromFileStat(stat); +} + /** * Refresh stat metadata for index entries whose content a caller has just * materialized in the worktree. Paths that cannot be verified by type/mode @@ -256,7 +283,7 @@ export async function refreshIndexStatsAfterCheckout( } const stat = await lstatSafe(ctx.fs, join(ctx.workTree, entry.path)).catch(() => null); - if (!stat || gitModeFromFileStat(stat) !== entry.mode) { + if (!stat || worktreeMode(ctx.fs, entry, stat) !== entry.mode) { entries.push(entry); continue; } diff --git a/src/lib/symlink.ts b/src/lib/symlink.ts index 0f10bb33..3045b5e4 100644 --- a/src/lib/symlink.ts +++ b/src/lib/symlink.ts @@ -18,6 +18,11 @@ export function isExecutableMode(mode: number | string): boolean { return mode === 0o100755; } +export function isRegularFileMode(mode: number | string): boolean { + if (typeof mode === "string") return mode === "100644" || mode === "100755"; + return mode === 0o100644 || mode === 0o100755; +} + /** Check whether a git mode (numeric or string) represents a submodule (gitlink). */ export function isSubmoduleMode(mode: number | string): boolean { if (typeof mode === "string") return mode === "160000"; diff --git a/src/lib/worktree.ts b/src/lib/worktree.ts index df015d31..297e4da5 100644 --- a/src/lib/worktree.ts +++ b/src/lib/worktree.ts @@ -5,16 +5,15 @@ import { addEntry, findEntry, getIndexTimestamp, - gitModeFromFileStat, indexStatFromFileStat, indexStatMatchesFile, + worktreeMode, } from "./index.ts"; import { readObject, writeObject } from "./object-db.ts"; import { isInsideWorkTree, verifyPath, verifySymlinkTarget } from "./path-safety.ts"; import { dirname, join } from "./path.ts"; import { isExecutableMode, isSubmoduleMode, isSymlinkMode, lstatSafe } from "./symlink.ts"; import { flattenTree } from "./tree-ops.ts"; -import type { FileStat } from "../fs.ts"; import type { GitContext, Index, IndexEntry, ObjectId, WorkTreeDiff } from "./types.ts"; const encoder = new TextEncoder(); @@ -87,9 +86,9 @@ export async function diffIndexToWorkTree( continue; } - if (indexStatMatchesFile(entry, st, indexTimestamp)) continue; + if (indexStatMatchesFile(ctx.fs, entry, st, indexTimestamp)) continue; - const workTreeMode = worktreeMode(ctx, entry, st); + const workTreeMode = worktreeMode(ctx.fs, entry, st); const workTreeHash = await hashCleanedWorktreeEntry(ctx, fullPath, entry.hash, st); if (workTreeHash !== entry.hash || workTreeMode !== entry.mode) { @@ -123,11 +122,6 @@ export async function diffIndexToWorkTree( return results.sort((a, b) => comparePaths(a.path, b.path)); } -function worktreeMode(ctx: GitContext, entry: IndexEntry, st: FileStat): number { - if (st.isFile && isSymlinkMode(entry.mode) && !ctx.fs.symlink) return entry.mode; - return gitModeFromFileStat(st); -} - // ── Checkout ──────────────────────────────────────────────────────── /** @@ -269,14 +263,14 @@ export async function stageFile( return { index: addEntry(index, entry), hash }; } + const existing = findEntry(index, path); const content = await ctx.fs.readFileBuffer(fullPath); - const blobContent = await cleanForCheckin(ctx, content, findEntry(index, path)?.hash); + const blobContent = await cleanForCheckin(ctx, content, existing?.hash); const hash = await writeObject(ctx, "blob", blobContent); - const mode = gitModeFromFileStat(st); const entry: IndexEntry = { path, - mode, + mode: worktreeMode(ctx.fs, existing, st), hash, stage: 0, // Index stat data describes the worktree file, not the cleaned blob. diff --git a/test/commands/file-mode.test.ts b/test/commands/file-mode.test.ts new file mode 100644 index 00000000..d01aa534 --- /dev/null +++ b/test/commands/file-mode.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { createGit, MemoryFileSystem } from "../../src"; +import { TEST_ENV } from "../fixtures"; + +async function repoWithExecutable(): Promise<{ + fs: MemoryFileSystem; + run: (cmd: string, cwd: string) => Promise<{ exitCode: number; stdout: string; stderr: string }>; +}> { + const fs = new MemoryFileSystem(); + const git = createGit({ fs }); + const run = (cmd: string, cwd: string) => git.exec(cmd, { cwd, env: TEST_ENV }); + await fs.mkdir("/repo", { recursive: true }); + await fs.writeFile("/repo/run.sh", "#!/bin/sh\necho hi\n"); + await fs.chmod("/repo/run.sh", 0o755); + await run("init", "/repo"); + await run("add run.sh", "/repo"); + await run("commit -m exec", "/repo"); + return { fs, run }; +} + +describe("FileSystem without chmod behaves like core.fileMode=false", () => { + test("clone, status, commit -a and add -A keep 100755 without the exec bit on disk", async () => { + const { fs, run } = await repoWithExecutable(); + Object.defineProperty(fs, "chmod", { value: undefined }); + + expect((await run("clone /repo /clone", "/")).exitCode).toBe(0); + expect((await fs.stat("/clone/run.sh")).mode).toBe(0o100644); + expect((await run("ls-files -s", "/clone")).stdout).toMatch(/^100755 /); + expect((await run("status --short", "/clone")).stdout).toBe(""); + + const commitAll = await run("commit -a -m noop", "/clone"); + expect(commitAll.exitCode).toBe(1); + expect(commitAll.stdout).toContain("nothing to commit, working tree clean"); + + await run("add -A", "/clone"); + expect((await run("ls-files -s", "/clone")).stdout).toMatch(/^100755 /); + expect((await run("status --short", "/clone")).stdout).toBe(""); + }); + + test("a new file is added as 100644 even when stat reports an exec bit", async () => { + const { fs, run } = await repoWithExecutable(); + await fs.writeFile("/repo/new.sh", "x\n"); + await fs.chmod("/repo/new.sh", 0o755); + Object.defineProperty(fs, "chmod", { value: undefined }); + + await run("add new.sh", "/repo"); + expect((await run("ls-files -s new.sh", "/repo")).stdout).toMatch(/^100644 /); + }); +}); diff --git a/test/lib/index.test.ts b/test/lib/index.test.ts index 6e978324..fc93f715 100644 --- a/test/lib/index.test.ts +++ b/test/lib/index.test.ts @@ -7,6 +7,9 @@ import { indexStatMatchesFile, } from "../../src/lib/index"; import type { IndexEntry } from "../../src/lib/types"; +import { MemoryFileSystem } from "../../src/memory-fs"; + +const fs = new MemoryFileSystem(); function regularStat(overrides: Partial = {}): FileStat { return { @@ -35,14 +38,14 @@ describe("index stat cache", () => { const stat = regularStat(); const entry = entryFor(stat); - expect(indexStatMatchesFile(entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(true); + expect(indexStatMatchesFile(fs, entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(true); }); test("rejects zero/default stat data", () => { const stat = regularStat(); const entry = { ...entryFor(stat), stat: defaultStat() }; - expect(indexStatMatchesFile(entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(false); + expect(indexStatMatchesFile(fs, entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(false); }); test("missing runtime timestamps degrade to hashing", () => { @@ -51,7 +54,9 @@ describe("index stat cache", () => { const withoutMtime = { ...stat, mtime: undefined } as unknown as FileStat; expect(indexStatFromFileStat(withoutMtime).mtimeSeconds).toBe(0); - expect(indexStatMatchesFile(entry, withoutMtime, new Date("2026-09-04T12:00:01Z"))).toBe(false); + expect(indexStatMatchesFile(fs, entry, withoutMtime, new Date("2026-09-04T12:00:01Z"))).toBe( + false, + ); }); test("rejects size and mode changes", () => { @@ -60,13 +65,19 @@ describe("index stat cache", () => { expect( indexStatMatchesFile( + fs, entry, { ...stat, size: stat.size + 1 }, new Date("2026-09-04T12:00:01Z"), ), ).toBe(false); expect( - indexStatMatchesFile(entry, { ...stat, mode: 0o100755 }, new Date("2026-09-04T12:00:01Z")), + indexStatMatchesFile( + fs, + entry, + { ...stat, mode: 0o100755 }, + new Date("2026-09-04T12:00:01Z"), + ), ).toBe(false); }); @@ -74,7 +85,7 @@ describe("index stat cache", () => { const stat = regularStat(); const entry = entryFor(stat); - expect(indexStatMatchesFile(entry, stat, stat.mtime)).toBe(false); + expect(indexStatMatchesFile(fs, entry, stat, stat.mtime)).toBe(false); }); test("rejects symlinks even when their metadata matches", () => { @@ -85,7 +96,7 @@ describe("index stat cache", () => { }); const entry = entryFor(stat); - expect(indexStatMatchesFile(entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(false); + expect(indexStatMatchesFile(fs, entry, stat, new Date("2026-09-04T12:00:01Z"))).toBe(false); }); test("uses optional ctime and identity fields when available", () => { @@ -99,10 +110,11 @@ describe("index stat cache", () => { const entry = entryFor(stat); const indexTimestamp = new Date("2026-09-04T12:00:01Z"); - expect(indexStatMatchesFile(entry, stat, indexTimestamp)).toBe(true); - expect(indexStatMatchesFile(entry, { ...stat, ino: 12 }, indexTimestamp)).toBe(false); + expect(indexStatMatchesFile(fs, entry, stat, indexTimestamp)).toBe(true); + expect(indexStatMatchesFile(fs, entry, { ...stat, ino: 12 }, indexTimestamp)).toBe(false); expect( indexStatMatchesFile( + fs, entry, { ...stat, ctime: new Date("2026-09-04T11:59:58Z") }, indexTimestamp, From 1da2492ac0cb33e746b3f28d9945c8bdcdb32d25 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:35:18 +1030 Subject: [PATCH 05/12] Refuse checkout over a local mode-only change WORKTREE_MUST_BE_UPTODATE compared only the worktree hash, so chmod -x followed by switch silently overwrote the change. PathState now exposes the worktree mode (from the same lstat the hash uses) and the check fails when it differs from the index mode, printing git's "Your local changes ... would be overwritten by checkout" and exiting 1. --- src/lib/unpack-trees.ts | 30 +++++++++++++++++-- test/commands/switch.test.ts | 17 +++++++++++ .../unpack-trees/check-requirement.test.ts | 15 ++++++++++ test/lib/unpack-trees/helpers.ts | 6 +++- 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/lib/unpack-trees.ts b/src/lib/unpack-trees.ts index 22c09bb0..c5098df6 100644 --- a/src/lib/unpack-trees.ts +++ b/src/lib/unpack-trees.ts @@ -18,12 +18,13 @@ */ import { comparePaths, err } from "./command-utils.ts"; -import { defaultStat, getStage0Entries } from "./index.ts"; +import { defaultStat, getStage0Entries, worktreeMode } from "./index.ts"; import { isInsideWorkTree, verifyPath } from "./path-safety.ts"; import { dirname, join } from "./path.ts"; import { hashCleanedWorktreeEntry } from "./eol.ts"; import { lstatSafe } from "./symlink.ts"; import { flattenTreeToMap } from "./tree-ops.ts"; +import type { FileStat } from "../fs.ts"; import type { GitContext, Index, IndexEntry, ObjectId } from "./types.ts"; import { checkoutEntry, cleanEmptyDirs, walkWorkTree } from "./worktree.ts"; @@ -102,6 +103,9 @@ interface PathState { */ getWorktreeHash: () => Promise; + /** Lazy worktree mode, null when the path is not on disk. */ + getWorktreeMode: () => Promise; + /** Mode from the head tree (for entry creation). */ headMode: string | null; @@ -383,22 +387,38 @@ async function buildPathStates( return cachedIgnored; }; + let cachedStat: FileStat | null | undefined; + const getWorktreeStat = async (): Promise => { + if (cachedStat !== undefined) return cachedStat; + cachedStat = + existsOnDisk && ctx.workTree + ? await lstatSafe(ctx.fs, join(ctx.workTree, path)).catch(() => null) + : null; + return cachedStat; + }; + let cachedHash: ObjectId | null | undefined; const getWorktreeHash = async (): Promise => { if (cachedHash !== undefined) return cachedHash; - if (!existsOnDisk || !ctx.workTree) { + const st = await getWorktreeStat(); + if (!st || !ctx.workTree) { cachedHash = null; return null; } const fullPath = join(ctx.workTree, path); try { - cachedHash = await hashCleanedWorktreeEntry(ctx, fullPath, indexHash ?? undefined); + cachedHash = await hashCleanedWorktreeEntry(ctx, fullPath, indexHash ?? undefined, st); } catch { cachedHash = null; } return cachedHash; }; + const getWorktreeMode = async (): Promise => { + const st = await getWorktreeStat(); + return st ? worktreeMode(ctx.fs, indexEntry, st) : null; + }; + states.push({ path, baseHash, @@ -410,6 +430,7 @@ async function buildPathStates( existsOnDisk, isIgnoredOnDisk, getWorktreeHash, + getWorktreeMode, headMode, remoteMode, }); @@ -782,6 +803,9 @@ export async function checkSingleRequirement( } return UnpackError.NOT_UPTODATE_FILE; } + if (wtHash !== null && (await state.getWorktreeMode()) !== state.indexMode) { + return UnpackError.NOT_UPTODATE_FILE; + } return null; } diff --git a/test/commands/switch.test.ts b/test/commands/switch.test.ts index e37160af..ce3f9721 100644 --- a/test/commands/switch.test.ts +++ b/test/commands/switch.test.ts @@ -497,4 +497,21 @@ describe("git switch: executable bit", () => { expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); expect((await bash.exec("git status --short")).stdout).toBe(""); }); + + test("switch refuses to overwrite a local mode-only change", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("git switch -c content && echo more >> run.sh && git commit -qam content"); + await bash.exec("git switch -q main && chmod 644 run.sh"); + const refused = + "error: Your local changes to the following files would be overwritten by checkout:\n\trun.sh\nPlease commit your changes or stash them before you switch branches.\nAborting\n"; + + for (const target of ["plain", "content"]) { + const result = await bash.exec(`git switch ${target}`); + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(refused); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o644); + expect((await bash.exec("git status --short")).stdout).toBe(" M run.sh\n"); + expect((await bash.exec("git branch --show-current")).stdout).toBe("main\n"); + } + }); }); diff --git a/test/lib/unpack-trees/check-requirement.test.ts b/test/lib/unpack-trees/check-requirement.test.ts index c9074f50..57762e85 100644 --- a/test/lib/unpack-trees/check-requirement.test.ts +++ b/test/lib/unpack-trees/check-requirement.test.ts @@ -211,6 +211,21 @@ describe("checkSingleRequirement", () => { expect(result).toBe(UnpackError.NOT_UPTODATE_FILE); }); + test("fails with NOT_UPTODATE_FILE when only the worktree mode differs", async () => { + const state = makeState({ + indexHash: HASH_A, + indexMode: 0o100755, + existsOnDisk: true, + worktreeMode: 0o100644, + }); + const result = await checkSingleRequirement( + PreconditionRequirement.WORKTREE_MUST_BE_UPTODATE, + state, + HASH_C, + ); + expect(result).toBe(UnpackError.NOT_UPTODATE_FILE); + }); + test("escape hatch: passes when worktree matches result (three-way)", async () => { const state = makeState({ indexHash: HASH_A, diff --git a/test/lib/unpack-trees/helpers.ts b/test/lib/unpack-trees/helpers.ts index 92fc6a83..73be3a24 100644 --- a/test/lib/unpack-trees/helpers.ts +++ b/test/lib/unpack-trees/helpers.ts @@ -38,6 +38,7 @@ interface PathStateInput { existsOnDisk?: boolean; ignoredOnDisk?: boolean; worktreeHash?: ObjectId | null; + worktreeMode?: number | null; headMode?: string | null; remoteMode?: string | null; } @@ -55,6 +56,8 @@ export function makeState(input: PathStateInput = {}): PathState { const existsOnDisk = input.existsOnDisk ?? indexHash !== null; // Default: worktree matches index (clean worktree) const worktreeHash = input.worktreeHash !== undefined ? input.worktreeHash : indexHash; + const indexMode = input.indexMode ?? (indexHash ? 0o100644 : null); + const worktreeMode = input.worktreeMode !== undefined ? input.worktreeMode : indexMode; const ignoredOnDisk = input.ignoredOnDisk ?? false; @@ -64,11 +67,12 @@ export function makeState(input: PathStateInput = {}): PathState { headHash: input.headHash ?? null, remoteHash: input.remoteHash ?? null, indexHash, - indexMode: input.indexMode ?? (indexHash ? 0o100644 : null), + indexMode, indexStage: input.indexStage ?? 0, existsOnDisk, isIgnoredOnDisk: async () => ignoredOnDisk, getWorktreeHash: async () => worktreeHash, + getWorktreeMode: async () => worktreeMode, headMode: input.headMode ?? (input.headHash ? "100644" : null), remoteMode: input.remoteMode ?? (input.remoteHash ? "100644" : null), }; From ac4aecf22db3df6ef0d306a805e649f0bac296cb Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:38:29 +1030 Subject: [PATCH 06/12] Stash a mode-only worktree change saveStash compared worktree blobs to the index by hash alone, so chmod -x on a tracked executable printed "No local changes to save". It now uses worktreeMode for both the dirty check and the recorded worktree tree. Popping that stash still leaves the mode untouched because merge-ort's triviallyResolve compares hashes only; that gap stays open. --- src/lib/stash.ts | 12 +++++++----- test/commands/stash.test.ts | 23 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/lib/stash.ts b/src/lib/stash.ts index ef67a9e9..bada8cd1 100644 --- a/src/lib/stash.ts +++ b/src/lib/stash.ts @@ -6,6 +6,7 @@ import { getConflictedPaths, getStage0Entries, readIndex, + worktreeMode, writeIndex, } from "./index.ts"; import { mergeOrtNonRecursive } from "./merge-ort.ts"; @@ -21,7 +22,7 @@ import { ZERO_HASH, } from "./reflog.ts"; import { branchNameFromRef, deleteRef, readHead, resolveHead, updateRef } from "./refs.ts"; -import { isSubmoduleMode } from "./symlink.ts"; +import { isSubmoduleMode, lstatSafe } from "./symlink.ts"; import { buildTreeFromIndex, diffTrees, flattenTree, flattenTreeToMap } from "./tree-ops.ts"; import type { GitContext, IndexEntry, ObjectId } from "./types.ts"; import { applyWorktreeOps, resetHard, type WorktreeOp } from "./unpack-trees.ts"; @@ -131,8 +132,9 @@ export async function saveStash( if (!indexEntry) continue; // skip untracked const fullPath = join(workTree, filePath); - const blobHash = await hashCleanedWorktreeEntry(ctx, fullPath, indexEntry.hash); - if (blobHash !== indexEntry.hash) { + const st = await lstatSafe(ctx.fs, fullPath); + const blobHash = await hashCleanedWorktreeEntry(ctx, fullPath, indexEntry.hash, st); + if (blobHash !== indexEntry.hash || worktreeMode(ctx.fs, indexEntry, st) !== indexEntry.mode) { hasWorkTreeChanges = true; break; } @@ -206,10 +208,10 @@ export async function saveStash( const content = await cleanForCheckin(ctx, raw, indexEntry?.hash ?? headEntry?.hash); const blobHash = await writeObject(ctx, "blob", content); - const mode = indexEntry ? indexEntry.mode : parseInt(headEntry?.mode ?? "100644", 8); + const tracked = indexEntry ?? { mode: parseInt(headEntry?.mode ?? "100644", 8) }; wtEntries.push({ path: filePath, - mode, + mode: worktreeMode(ctx.fs, tracked, await lstatSafe(ctx.fs, fullPath)), hash: blobHash, stage: 0, stat: defaultStat(), diff --git a/test/commands/stash.test.ts b/test/commands/stash.test.ts index 35de7f80..17f06f5a 100644 --- a/test/commands/stash.test.ts +++ b/test/commands/stash.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, readFile } from "../util"; +import { createTestBash, permissionBits, readFile, setupExecBitRepo } from "../util"; describe("git stash", () => { // ── Error cases ────────────────────────────────────────────────── @@ -826,3 +826,24 @@ describe("git stash", () => { }); }); }); + +describe("git stash: executable bit", () => { + test("stashes a mode-only change and pop restores it", async () => { + const bash = await setupExecBitRepo(); + const short = (await bash.exec("git rev-parse --short HEAD")).stdout.trim(); + await bash.exec("chmod 644 run.sh"); + + const saved = await bash.exec("git stash"); + expect(saved.exitCode).toBe(0); + expect(saved.stdout).toBe( + `Saved working directory and index state WIP on main: ${short} exec\n`, + ); + expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); + expect((await bash.exec("git status --short")).stdout).toBe(""); + expect((await bash.exec("git stash show -p")).stdout).toBe( + "diff --git a/run.sh b/run.sh\nold mode 100755\nnew mode 100644\n", + ); + }); + + test.todo("stash pop restores a mode-only change (merge-ort triviallyResolve compares hashes only)", () => {}); +}); From afadba4cbd726be4f0dcd1fade0028880b0ecc52 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:38:29 +1030 Subject: [PATCH 07/12] TreeBackedFs.chmod follows symlinks to their target The FileSystem.chmod contract says it follows symlinks, and MemoryFileSystem does. TreeBackedFs returned early on a symlink instead. --- src/tree-backed-fs.ts | 11 ++++++++++- test/tree-backed-fs.test.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/tree-backed-fs.ts b/src/tree-backed-fs.ts index e19c21dc..026c7fe8 100644 --- a/src/tree-backed-fs.ts +++ b/src/tree-backed-fs.ts @@ -49,6 +49,10 @@ function withPermissionBits(current: number, mode: number): number { return (current & ~0o7777) | (mode & 0o7777); } +function resolveLinkTarget(linkPath: string, target: string): string { + return normalize(target.startsWith("/") ? target : `${dirname(linkPath)}/${target}`); +} + function treeModeToStatMode(mode: string): number { switch (mode) { case FM.EXECUTABLE: @@ -519,6 +523,9 @@ export class TreeBackedFs implements FileSystem { async chmod(path: string, mode: number): Promise { const norm = normalize(path); const overlayEntry = this.overlay.get(norm); + if (overlayEntry?.type === "symlink") { + return this.chmod(resolveLinkTarget(norm, overlayEntry.target), mode); + } if (overlayEntry) { overlayEntry.mode = withPermissionBits(overlayEntry.mode, mode); return; @@ -527,7 +534,9 @@ export class TreeBackedFs implements FileSystem { throw new Error(`ENOENT: no such file or directory, chmod '${path}'`); } const st = await this.lstat(norm); - if (st.isSymbolicLink) return; + if (st.isSymbolicLink) { + return this.chmod(resolveLinkTarget(norm, await this.readlink(norm)), mode); + } this.ensureOverlayParents(norm); this.overlay.set( norm, diff --git a/test/tree-backed-fs.test.ts b/test/tree-backed-fs.test.ts index 1eea4c08..3974b738 100644 --- a/test/tree-backed-fs.test.ts +++ b/test/tree-backed-fs.test.ts @@ -7,14 +7,32 @@ import { TreeBackedFs } from "../src/tree-backed-fs.ts"; async function treeFs(): Promise { const repo = await createStorageAdapter(new MemoryStorage()).createRepo("test"); const blob = await writeBlob(repo, "#!/bin/sh\n"); + const link = await writeBlob(repo, "plain.sh"); const tree = await writeTree(repo, [ { name: "plain.sh", hash: blob, mode: "100644" }, { name: "exec.sh", hash: blob, mode: "100755" }, + { name: "link", hash: link, mode: "120000" }, ]); return new TreeBackedFs(repo.objectStore, tree, "/wt"); } describe("TreeBackedFs chmod", () => { + test("chmod follows a tree-backed symlink to its target", async () => { + const fs = await treeFs(); + await fs.chmod("/wt/link", 0o755); + expect((await fs.lstat("/wt/plain.sh")).mode).toBe(0o100755); + expect((await fs.lstat("/wt/link")).isSymbolicLink).toBe(true); + expect((await fs.lstat("/wt/link")).mode).toBe(0o120000); + }); + + test("chmod follows an overlay symlink to its target", async () => { + const fs = await treeFs(); + await fs.symlink("exec.sh", "/wt/link2"); + await fs.chmod("/wt/link2", 0o644); + expect((await fs.lstat("/wt/exec.sh")).mode).toBe(0o100644); + expect((await fs.lstat("/wt/link2")).isSymbolicLink).toBe(true); + }); + test("chmod on a tree-backed file changes its mode and keeps its content", async () => { const fs = await treeFs(); await fs.chmod("/wt/plain.sh", 0o755); From a4874bcd9cc51676f7d0d67852c43d2774ae15a8 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:41:34 +1030 Subject: [PATCH 08/12] Add tests that pin the exec-bit decision points Each test fails when one of these is reverted: the chmod(0o644) clear in checkoutEntry (on a filesystem whose writeFile keeps the old mode), the mode compare in hasStagedChanges (describe --dirty), hasModeChange in formatBinaryDiff, and the symlink stand-in rule in worktreeMode. --- test/commands/commit.test.ts | 17 +++++++++++++++++ test/commands/describe.test.ts | 10 +++++++++- test/commands/diff.test.ts | 13 +++++++++++++ test/commands/file-mode.test.ts | 24 ++++++++++++++++++++++++ test/lib/worktree.test.ts | 22 ++++++++++++++++++++++ 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/test/commands/commit.test.ts b/test/commands/commit.test.ts index 8e1c5411..8ea93a7c 100644 --- a/test/commands/commit.test.ts +++ b/test/commands/commit.test.ts @@ -844,3 +844,20 @@ describe("git commit: executable bit", () => { expect(result.stdout).toBe("On branch main\nnothing to commit, working tree clean\n"); }); }); + +describe("git commit: staged mode-only change", () => { + test("commits when only the mode differs from HEAD", async () => { + const bash = await setupExecBitRepo(); + const before = (await bash.exec("git rev-parse HEAD")).stdout; + await bash.exec("chmod 644 run.sh && git add run.sh"); + + const result = await bash.exec("git commit -m mode"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/^\[main [0-9a-f]+\] mode\n/); + expect((await bash.exec("git rev-parse HEAD")).stdout).not.toBe(before); + expect((await bash.exec("git status --short")).stdout).toBe(""); + expect((await bash.exec("git diff HEAD~1 HEAD")).stdout).toBe( + "diff --git a/run.sh b/run.sh\nold mode 100755\nnew mode 100644\n", + ); + }); +}); diff --git a/test/commands/describe.test.ts b/test/commands/describe.test.ts index 8ad32a42..87b0d5a0 100644 --- a/test/commands/describe.test.ts +++ b/test/commands/describe.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { EMPTY_REPO, envAt, TEST_ENV } from "../fixtures"; -import { createTestBash, quickExec, runScenario } from "../util"; +import { createTestBash, quickExec, runScenario, setupExecBitRepo } from "../util"; describe("git describe", () => { describe("basic", () => { @@ -212,6 +212,14 @@ describe("git describe", () => { expect(result.stdout.trim()).toBe("v1.0.0-dirty"); }); + test("appends -dirty when only a mode change is staged", async () => { + const bash = await setupExecBitRepo(); + await bash.exec('git tag -a v1.0.0 -m "r1"'); + await bash.exec("chmod 644 run.sh && git add run.sh"); + const result = await bash.exec("git describe --dirty"); + expect(result.stdout).toBe("v1.0.0-dirty\n"); + }); + test("no -dirty when worktree is clean", async () => { const { results } = await runScenario( [ diff --git a/test/commands/diff.test.ts b/test/commands/diff.test.ts index ad7eb4e4..7666f405 100644 --- a/test/commands/diff.test.ts +++ b/test/commands/diff.test.ts @@ -1232,3 +1232,16 @@ describe("git diff: executable bit", () => { ); }); }); + +describe("git diff: mode-only change on a binary file", () => { + test("prints the mode lines and no binary notice", async () => { + const bash = createTestBash({ files: EMPTY_REPO, env: TEST_ENV }); + await bash.fs.writeFile("/repo/tool.bin", new Uint8Array([0x00, 0x01, 0x02])); + await bash.exec("git init && chmod 755 tool.bin && git add tool.bin && git commit -m bin"); + await bash.exec("chmod 644 tool.bin"); + + expect((await bash.exec("git diff")).stdout).toBe( + "diff --git a/tool.bin b/tool.bin\nold mode 100755\nnew mode 100644\n", + ); + }); +}); diff --git a/test/commands/file-mode.test.ts b/test/commands/file-mode.test.ts index d01aa534..a3590dab 100644 --- a/test/commands/file-mode.test.ts +++ b/test/commands/file-mode.test.ts @@ -47,3 +47,27 @@ describe("FileSystem without chmod behaves like core.fileMode=false", () => { expect((await run("ls-files -s new.sh", "/repo")).stdout).toMatch(/^100644 /); }); }); + +describe("FileSystem without symlinks behaves like core.symlinks=false", () => { + test("status is clean after checking out a 120000 entry as a plain file", async () => { + const fs = new MemoryFileSystem(); + const git = createGit({ fs }); + const run = (cmd: string, cwd: string) => git.exec(cmd, { cwd, env: TEST_ENV }); + await fs.mkdir("/repo", { recursive: true }); + await fs.writeFile("/repo/a.txt", "a\n"); + await fs.symlink("a.txt", "/repo/link"); + await run("init", "/repo"); + await run("add .", "/repo"); + await run("commit -m link", "/repo"); + for (const method of ["symlink", "readlink", "lstat"]) { + Object.defineProperty(fs, method, { value: undefined }); + } + + expect((await run("clone /repo /clone", "/")).exitCode).toBe(0); + expect((await fs.stat("/clone/link")).isFile).toBe(true); + expect(await fs.readFile("/clone/link")).toBe("a.txt"); + expect((await run("ls-files -s link", "/clone")).stdout).toMatch(/^120000 /); + expect((await run("status --short", "/clone")).stdout).toBe(""); + expect((await run("diff", "/clone")).stdout).toBe(""); + }); +}); diff --git a/test/lib/worktree.test.ts b/test/lib/worktree.test.ts index 1f3d2703..a82beaf1 100644 --- a/test/lib/worktree.test.ts +++ b/test/lib/worktree.test.ts @@ -44,3 +44,25 @@ describe("checkoutEntry executable bit", () => { expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100644); }); }); + +class ModePreservingFs extends MemoryFileSystem { + override async writeFile(path: string, content: string | Uint8Array): Promise { + const existing = await this.stat(path).catch(() => null); + await super.writeFile(path, content); + if (existing) await this.chmod(path, existing.mode); + } +} + +describe("checkoutEntry on a filesystem whose writeFile keeps the old mode", () => { + test("a 100644 entry over an executable file clears the exec bit", async () => { + const fs = new ModePreservingFs(); + const ctx = await context(fs); + const hash = await writeObject(ctx, "blob", new TextEncoder().encode("#!/bin/sh\n")); + + await checkoutEntry(ctx, { path: "run.sh", hash, mode: 0o100755 }); + expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100755); + + await checkoutEntry(ctx, { path: "run.sh", hash, mode: 0o100644 }); + expect((await fs.stat("/wt/run.sh")).mode).toBe(0o100644); + }); +}); From 7ac4452103f6d8a6f69fcf47b10427d657fe8b00 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:42:50 +1030 Subject: [PATCH 09/12] Regenerate FILE_REFERENCE for worktreeMode and isRegularFileMode --- docs/FILE_REFERENCE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/FILE_REFERENCE.md b/docs/FILE_REFERENCE.md index 7727db81..7f6a9e34 100644 --- a/docs/FILE_REFERENCE.md +++ b/docs/FILE_REFERENCE.md @@ -97,7 +97,7 @@ Exported symbols from `src/lib/`. Generated by `bun scripts/gen-lib-reference.ts ### lib/index.ts -- `getIndexTimestamp`, `readIndex`, `writeIndex`, `addEntry`, `removeEntry`, `findEntry`, `hasConflicts`, `getConflictedPaths`, `getStage0Entries`, `clearIndex`, `buildIndex`, `defaultStat`, `indexStatFromFileStat`, `indexStatMatchesFile`, `gitModeFromFileStat`, `refreshIndexStatsAfterCheckout` +- `getIndexTimestamp`, `readIndex`, `writeIndex`, `addEntry`, `removeEntry`, `findEntry`, `hasConflicts`, `getConflictedPaths`, `getStage0Entries`, `clearIndex`, `buildIndex`, `defaultStat`, `indexStatFromFileStat`, `indexStatMatchesFile`, `gitModeFromFileStat`, `worktreeMode`, `refreshIndexStatsAfterCheckout` ### lib/log-format.ts @@ -250,7 +250,7 @@ Exported symbols from `src/lib/`. Generated by `bun scripts/gen-lib-reference.ts ### lib/symlink.ts -- `lstatSafe`, `isSymlinkMode`, `isExecutableMode`, `isSubmoduleMode`, `readWorktreeContent` +- `lstatSafe`, `isSymlinkMode`, `isExecutableMode`, `isRegularFileMode`, `isSubmoduleMode`, `readWorktreeContent` ### lib/tree-ops.ts From 12d44be130743f2a4c38ba8d8d92e783170d14a7 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:43:48 +1030 Subject: [PATCH 10/12] Revert stash of mode-only changes until merge-ort keeps modes (#10) Saving a mode-only change into the stash while stash pop cannot restore it (merge-ort triviallyResolve compares hashes only) would drop the change from the worktree. Leave stash as it was on main and track the gap separately. --- src/lib/stash.ts | 12 +++++------- test/commands/stash.test.ts | 23 +---------------------- 2 files changed, 6 insertions(+), 29 deletions(-) diff --git a/src/lib/stash.ts b/src/lib/stash.ts index bada8cd1..ef67a9e9 100644 --- a/src/lib/stash.ts +++ b/src/lib/stash.ts @@ -6,7 +6,6 @@ import { getConflictedPaths, getStage0Entries, readIndex, - worktreeMode, writeIndex, } from "./index.ts"; import { mergeOrtNonRecursive } from "./merge-ort.ts"; @@ -22,7 +21,7 @@ import { ZERO_HASH, } from "./reflog.ts"; import { branchNameFromRef, deleteRef, readHead, resolveHead, updateRef } from "./refs.ts"; -import { isSubmoduleMode, lstatSafe } from "./symlink.ts"; +import { isSubmoduleMode } from "./symlink.ts"; import { buildTreeFromIndex, diffTrees, flattenTree, flattenTreeToMap } from "./tree-ops.ts"; import type { GitContext, IndexEntry, ObjectId } from "./types.ts"; import { applyWorktreeOps, resetHard, type WorktreeOp } from "./unpack-trees.ts"; @@ -132,9 +131,8 @@ export async function saveStash( if (!indexEntry) continue; // skip untracked const fullPath = join(workTree, filePath); - const st = await lstatSafe(ctx.fs, fullPath); - const blobHash = await hashCleanedWorktreeEntry(ctx, fullPath, indexEntry.hash, st); - if (blobHash !== indexEntry.hash || worktreeMode(ctx.fs, indexEntry, st) !== indexEntry.mode) { + const blobHash = await hashCleanedWorktreeEntry(ctx, fullPath, indexEntry.hash); + if (blobHash !== indexEntry.hash) { hasWorkTreeChanges = true; break; } @@ -208,10 +206,10 @@ export async function saveStash( const content = await cleanForCheckin(ctx, raw, indexEntry?.hash ?? headEntry?.hash); const blobHash = await writeObject(ctx, "blob", content); - const tracked = indexEntry ?? { mode: parseInt(headEntry?.mode ?? "100644", 8) }; + const mode = indexEntry ? indexEntry.mode : parseInt(headEntry?.mode ?? "100644", 8); wtEntries.push({ path: filePath, - mode: worktreeMode(ctx.fs, tracked, await lstatSafe(ctx.fs, fullPath)), + mode, hash: blobHash, stage: 0, stat: defaultStat(), diff --git a/test/commands/stash.test.ts b/test/commands/stash.test.ts index 17f06f5a..35de7f80 100644 --- a/test/commands/stash.test.ts +++ b/test/commands/stash.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { EMPTY_REPO, TEST_ENV_NAMED as TEST_ENV } from "../fixtures"; -import { createTestBash, permissionBits, readFile, setupExecBitRepo } from "../util"; +import { createTestBash, readFile } from "../util"; describe("git stash", () => { // ── Error cases ────────────────────────────────────────────────── @@ -826,24 +826,3 @@ describe("git stash", () => { }); }); }); - -describe("git stash: executable bit", () => { - test("stashes a mode-only change and pop restores it", async () => { - const bash = await setupExecBitRepo(); - const short = (await bash.exec("git rev-parse --short HEAD")).stdout.trim(); - await bash.exec("chmod 644 run.sh"); - - const saved = await bash.exec("git stash"); - expect(saved.exitCode).toBe(0); - expect(saved.stdout).toBe( - `Saved working directory and index state WIP on main: ${short} exec\n`, - ); - expect(await permissionBits(bash.fs, "/repo/run.sh")).toBe(0o755); - expect((await bash.exec("git status --short")).stdout).toBe(""); - expect((await bash.exec("git stash show -p")).stdout).toBe( - "diff --git a/run.sh b/run.sh\nold mode 100755\nnew mode 100644\n", - ); - }); - - test.todo("stash pop restores a mode-only change (merge-ort triviallyResolve compares hashes only)", () => {}); -}); From 614f9f5ec8e21fc8278262ded5793859b4254bba Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:47:26 +1030 Subject: [PATCH 11/12] Name the fileMode=false rule and drop round-two comments (#10) --- src/lib/index.ts | 12 +++++------- src/lib/unpack-trees.ts | 1 - 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index 07bac1a2..94dbc676 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -240,19 +240,17 @@ function fileStandsInForSymlink( return stat.isFile && isSymlinkMode(entry.mode) && !fs.symlink; } -/** - * Git's `ce_mode_from_stat`: the mode a worktree path gets, given the index - * entry at that path. A filesystem without `chmod` cannot hold the exec bit, - * so it is treated as `core.fileMode=false`: a tracked regular file keeps its - * index mode and a new one is 100644, whatever `stat` reports. - */ +function fsTracksExecutableBit(fs: FileSystem): boolean { + return fs.chmod !== undefined; +} + export function worktreeMode( fs: FileSystem, entry: Pick | undefined, stat: FileStat, ): number { if (entry && fileStandsInForSymlink(fs, entry, stat)) return entry.mode; - if (stat.isFile && !fs.chmod) { + if (stat.isFile && !fsTracksExecutableBit(fs)) { return entry && isRegularFileMode(entry.mode) ? entry.mode : 0o100644; } return gitModeFromFileStat(stat); diff --git a/src/lib/unpack-trees.ts b/src/lib/unpack-trees.ts index c5098df6..8b92bf3c 100644 --- a/src/lib/unpack-trees.ts +++ b/src/lib/unpack-trees.ts @@ -103,7 +103,6 @@ interface PathState { */ getWorktreeHash: () => Promise; - /** Lazy worktree mode, null when the path is not on disk. */ getWorktreeMode: () => Promise; /** Mode from the head tree (for entry creation). */ From 86ecf4bddf5e8dcaca1938c363c5823f3b371dbd Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:59:52 +1030 Subject: [PATCH 12/12] Keep the executable bit across git mv (#10) --- src/commands/mv.ts | 2 ++ test/commands/mv.test.ts | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/commands/mv.ts b/src/commands/mv.ts index 419d9b95..887b4da1 100644 --- a/src/commands/mv.ts +++ b/src/commands/mv.ts @@ -200,7 +200,9 @@ export function registerMvCommand(parent: Command, ext?: GitExtensions) { } const content = await ctx.fs.readFileBuffer(move.srcAbs); + const srcMode = (await ctx.fs.stat(move.srcAbs)).mode; await ctx.fs.writeFile(move.dstAbs, content); + if (ctx.fs.chmod) await ctx.fs.chmod(move.dstAbs, srcMode & 0o7777); await ctx.fs.rm(move.srcAbs); // 2. Update the index: remove old entry, add new entry diff --git a/test/commands/mv.test.ts b/test/commands/mv.test.ts index eb7f9230..2142a61c 100644 --- a/test/commands/mv.test.ts +++ b/test/commands/mv.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import { BASIC_REPO, EMPTY_REPO, NESTED_REPO } from "../fixtures"; -import { createTestBash, pathExists, readFile, runScenario } from "../util"; +import { + createTestBash, + pathExists, + permissionBits, + readFile, + runScenario, + setupExecBitRepo, +} from "../util"; const TEST_ENV = { GIT_AUTHOR_NAME: "Test", @@ -314,3 +321,14 @@ describe("git mv", () => { }); }); }); + +describe("git mv: executable bit", () => { + test("keeps the executable bit and commits as a pure rename", async () => { + const bash = await setupExecBitRepo(); + await bash.exec("rm run.sh && git checkout -- run.sh"); + await bash.exec("git mv run.sh bin.sh"); + expect(await permissionBits(bash.fs, "/repo/bin.sh")).toBe(0o755); + const status = await bash.exec("git status --short"); + expect(status.stdout).toBe("R run.sh -> bin.sh\n"); + }); +});