Fix #10: checkout sets the executable bit, and status/diff report mode changes

Merged
HA
5d ago
+761-67

Fixes #10

Summary

git clone, git checkout -- <path>, git restore, git switch, git checkout <branch> and git reset --hard now write 100755 blobs as executable files, and write 100644 blobs over an executable file as non-executable. git status and git diff report a mode-only change (old mode 100755 / new mode 100644), both unstaged and staged, the way git does with core.fileMode=true. git switch and git checkout <branch> refuse to overwrite a local mode-only change. The issue's repro now prints restored 755, nothing to commit, working tree clean and cloned 755, matching git 2.34.1.

A FileSystem without chmod is treated the way git treats a filesystem that cannot hold the exec bit (core.fileMode=false): a tracked regular file keeps its index mode whatever stat reports, and a new file is staged as 100644. Before this, every 100755 file showed modified right after clone on such a filesystem and commit -a flipped it to 100644.

Root cause

Several places assumed "same hash means unchanged", and nothing could write the bit.

  • FileSystem had no chmod, so checkoutEntry could only writeFile and the worktree never got the bit. gitModeFromFileStat then derived 100644 from stat on the next git add, which is where the phantom 100755 -> 100644 commit came from.
  • diffIndexToWorkTree reported "modified" only when the worktree hash differed from the index. getStagedChanges, collectCached in diff.ts and hasStagedChanges compared index entries to HEAD by hash alone. A mode-only change was invisible to status and diff until it was committed.
  • twowayMerge and onewayMerge in unpack-trees.ts compared tree entries by hash, so switching to a branch where only the mode differs kept the old file untouched. The WORKTREE_MUST_BE_UPTODATE check compared only the worktree hash, so a local chmod -x was silently overwritten by switch.

The fix

  • FileSystem gains an optional chmod?(path, mode) with the same signature as just-bash's IFileSystem.chmod. MemoryFileSystem and TreeBackedFs implement it; both keep the file-type bits and replace only the permission bits, and both follow a symlink to its target. TreeBackedFs copies a tree-backed entry into its overlay with the new mode.
  • checkoutEntry calls chmod(path, 0o755) after writing a 100755 entry, and chmod(path, 0o644) when a 100644 entry replaces a file that had an exec bit.
  • worktreeMode(fs, indexEntry, stat) in lib/index.ts is the one place that derives a worktree path's git mode, mirroring git's ce_mode_from_stat. It applies the symlink stand-in rule for filesystems without symlinks and the fileMode=false rule for filesystems without chmod. diffIndexToWorkTree, addFileToIndex, the index stat cache, refreshIndexStatsAfterCheckout and unpack-trees all go through it.
  • WorkTreeDiff carries worktreeMode. diffIndexToWorkTree marks an entry modified when hash or mode differs, so every consumer (status, diff, commit -a, reset, describe, the dirty-worktree checks) inherits the rule. The diff command uses worktreeMode for new mode, and the formatter no longer drops a patch whose only change is the mode, for text or binary files.
  • The three index-vs-HEAD comparisons also compare the mode.
  • unpack-trees.ts gets a same(hash, mode, hash, mode) helper and PathState.indexMode, used in the one-way and two-way decision rules. PathState also exposes the worktree mode, and WORKTREE_MUST_BE_UPTODATE fails when it differs from the index mode, printing git's Your local changes to the following files would be overwritten by checkout and exiting 1.
  • isExecutableMode and isRegularFileMode join isSymlinkMode and isSubmoduleMode in src/lib/symlink.ts.

core.fileMode is still not read from config. The presence of chmod on the FileSystem decides it.

Note for embedders. Every custom FileSystem adapter written before this change lacks chmod, so it now gets the fileMode=false behaviour. An adapter backed by a real disk, whose stat does report exec bits, used to stage a new executable file as 100755 and now stages it as 100644. Adding chmod to the adapter restores exec-bit tracking. init still writes core.filemode=true on these filesystems.

Tests

Added in test/commands/*.test.ts under "executable bit" describes, sharing a setupExecBitRepo helper in test/util.ts (an executable run.sh on main, the same content as 100644 on plain).

  • checkout -- <path>, restore, restore --source, switch, checkout <branch>, reset --hard, reset --hard <branch> and clone leave the right permission bits and a clean git status --short.
  • git add -A && git commit after a checkout exits 1 with nothing to commit, working tree clean. A staged mode-only change commits, and git describe --dirty reports it.
  • chmod -x on a tracked executable prints M run.sh, the long status, old mode 100755 / new mode 100644 from git diff, git diff HEAD and git diff --cached after add, and run.sh | 0 from --stat. A content plus mode change prints the mode lines followed by an index line without a mode, as git does. A mode-only change on a binary file prints only the mode lines.
  • git mv keeps the source file's permission bits, so moving an executable shows as a pure rename (R run.sh -> bin.sh). It used to copy the file and lose the bit, which the new mode-aware status would have reported as a change.
  • chmod -x then git switch plain or git switch content exits 1 with git's exact refusal, leaves the bit and HEAD alone. A unit test covers the WORKTREE_MUST_BE_UPTODATE mode-only case.
  • test/commands/file-mode.test.ts: on a MemoryFileSystem with chmod removed, clone + status + commit -a + add -A keep 100755 with a clean status, and a new file whose stat has an exec bit is added as 100644. On a MemoryFileSystem with symlink support removed, status and diff are clean after cloning a 120000 entry.
  • MemoryFileSystem.chmod and TreeBackedFs.chmod unit tests (including following tree-backed and overlay symlinks), checkoutEntry against a MemoryFileSystem with chmod removed, and against one whose writeFile keeps the old mode (as real disks do) to pin the chmod(0o644) clear.

bun test: 3521 pass, 29 skip, 0 fail. Four core oracle traces replay with the same two known and two failed steps as main at faa98c5 (both failures are message-text differences between local git 2.34.1 and the 2.53 target, unrelated to modes).

Open

  • merge-ort's triviallyResolve compares the three stages by hash only, so a mode-only change on one side is lost: cherry-pick of a mode-only commit says "The previous cherry-pick is now empty", merge drops the other side's mode change, and rebase loses it. Real git applies the mode in all three.
  • git stash after chmod -x still prints No local changes to save while status shows M, as on main. Saving the mode change is easy, but stash pop would not restore it until the merge-ort gap above is fixed, so the change would leave the worktree. Both should land together.
  • The commit summary prints create mode 100644 run.sh and delete mode 100755 run.sh for a mode-only commit where git prints mode change 100755 => 100644 run.sh.
  • A FileSystem that has chmod but whose stat returns mode: 0 shows every 100755 file as modified after checkout. No bundled filesystem does this.