Skip to content

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

Merged
blindmansion merged 12 commits into
blindmansion:mainfrom
Hazzng:fix/checkout-exec-bit
Oct 14, 2026
Merged

blindmansion merged 12 commits into
blindmansion:mainfrom
Hazzng:fix/checkout-exec-bit

Conversation

@Hazzng

@Hazzng Hazzng commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

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.

Hazzng added 12 commits October 9, 2026 15:04
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.
… 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.
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.
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.
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.
The FileSystem.chmod contract says it follows symlinks, and
MemoryFileSystem does. TreeBackedFs returned early on a symlink instead.
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.
…ansion#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.
@Hazzng

Hazzng commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

@blindmansion could you review this when you have a moment? Checkout now sets the executable bit on 100755 files, so the next commit no longer silently records 100755 -> 100644, and status/diff show mode-only changes. We need this in prod.

@blindmansion

Copy link
Copy Markdown
Owner

I'll need to take a closer look at this one and run the larger oracle suites, I should be able to get this in over the weekend.

@Hazzng

Hazzng commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

I'll need to take a closer look at this one and run the larger oracle suites, I should be able to get this in over the weekend.

Sure thanks @blindmansion, can you also look at other PR as well pls ? quite a small change

Hazzng added a commit to Hazzng/sql-fs that referenced this pull request Oct 14, 2026
Carry blindmansion/just-git#15 (fixes #10) and #14 (fixes #12) until a
just-git release includes them. git status and git diff report a
chmod-only change, a branch switch applies a mode-only difference and
refuses to overwrite a local one, and git ls-tree works.

The wrapper keeps applying index modes because just-git's merge still
leaves a conflicted file with its old mode.
@blindmansion
blindmansion merged commit 903e03e into blindmansion:main Oct 14, 2026
2 checks passed
blindmansion added a commit that referenced this pull request Oct 14, 2026
Fold the four changesets into a 1.10.0 section covering Hazzng's PRs #14, #15, #20 and #21 and the follow-up fixes since, with a Changed note for FileSystems without chmod and a known issue for just-bash overwrites dropping the exec bit. The version bump is a separate commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Checkout never sets the executable bit, and the next commit silently records 100755 -> 100644

2 participants