Fix #10: checkout sets the executable bit, and status/diff report mode changes
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.
FileSystemhad nochmod, socheckoutEntrycould onlywriteFileand the worktree never got the bit.gitModeFromFileStatthen derived100644fromstaton the nextgit add, which is where the phantom100755 -> 100644commit came from.diffIndexToWorkTreereported "modified" only when the worktree hash differed from the index.getStagedChanges,collectCachedindiff.tsandhasStagedChangescompared index entries to HEAD by hash alone. A mode-only change was invisible tostatusanddiffuntil it was committed.twowayMergeandonewayMergeinunpack-trees.tscompared tree entries by hash, so switching to a branch where only the mode differs kept the old file untouched. TheWORKTREE_MUST_BE_UPTODATEcheck compared only the worktree hash, so a localchmod -xwas silently overwritten byswitch.
The fix
FileSystemgains an optionalchmod?(path, mode)with the same signature as just-bash'sIFileSystem.chmod.MemoryFileSystemandTreeBackedFsimplement it; both keep the file-type bits and replace only the permission bits, and both follow a symlink to its target.TreeBackedFscopies a tree-backed entry into its overlay with the new mode.checkoutEntrycallschmod(path, 0o755)after writing a100755entry, andchmod(path, 0o644)when a100644entry replaces a file that had an exec bit.worktreeMode(fs, indexEntry, stat)inlib/index.tsis the one place that derives a worktree path's git mode, mirroring git'sce_mode_from_stat. It applies the symlink stand-in rule for filesystems without symlinks and thefileMode=falserule for filesystems withoutchmod.diffIndexToWorkTree,addFileToIndex, the index stat cache,refreshIndexStatsAfterCheckoutandunpack-treesall go through it.WorkTreeDiffcarriesworktreeMode.diffIndexToWorkTreemarks 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 usesworktreeModefornew 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.tsgets asame(hash, mode, hash, mode)helper andPathState.indexMode, used in the one-way and two-way decision rules.PathStatealso exposes the worktree mode, andWORKTREE_MUST_BE_UPTODATEfails when it differs from the index mode, printing git'sYour local changes to the following files would be overwritten by checkoutand exiting 1.isExecutableModeandisRegularFileModejoinisSymlinkModeandisSubmoduleModeinsrc/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>andcloneleave the right permission bits and a cleangit status --short.git add -A && git commitafter a checkout exits 1 withnothing to commit, working tree clean. A staged mode-only change commits, andgit describe --dirtyreports it.chmod -xon a tracked executable printsM run.sh, the long status,old mode 100755/new mode 100644fromgit diff,git diff HEADandgit diff --cachedafteradd, andrun.sh | 0from--stat. A content plus mode change prints the mode lines followed by anindexline without a mode, as git does. A mode-only change on a binary file prints only the mode lines.git mvkeeps 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 -xthengit switch plainorgit switch contentexits 1 with git's exact refusal, leaves the bit and HEAD alone. A unit test covers theWORKTREE_MUST_BE_UPTODATEmode-only case.test/commands/file-mode.test.ts: on aMemoryFileSystemwithchmodremoved,clone+status+commit -a+add -Akeep100755with a clean status, and a new file whosestathas an exec bit is added as100644. On aMemoryFileSystemwith symlink support removed, status and diff are clean after cloning a120000entry.MemoryFileSystem.chmodandTreeBackedFs.chmodunit tests (including following tree-backed and overlay symlinks),checkoutEntryagainst aMemoryFileSystemwithchmodremoved, and against one whosewriteFilekeeps the old mode (as real disks do) to pin thechmod(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
triviallyResolvecompares the three stages by hash only, so a mode-only change on one side is lost:cherry-pickof a mode-only commit says "The previous cherry-pick is now empty",mergedrops the other side's mode change, andrebaseloses it. Real git applies the mode in all three. git stashafterchmod -xstill printsNo local changes to savewhilestatusshowsM, as on main. Saving the mode change is easy, butstash popwould 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.shanddelete mode 100755 run.shfor a mode-only commit where git printsmode change 100755 => 100644 run.sh. - A
FileSystemthat haschmodbut whosestatreturnsmode: 0shows every100755file as modified after checkout. No bundled filesystem does this.