Repository navigation
family.py: MountRollback reads the holder's index with a case-sensitive pathspec (git ls-files -s -- <path>), so with core.ignorecase=true and an index entry Members/Atlas/README a refused add leaves the 19 files git submodule add staged under Members/Atlas/ and still prints "the holder is exactly as it was before this command" #302
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by this lane's adversarial review of #242's branch (fix/family-add-force-past-excludes, head 289e542; its nit N2, which showed the misreport identical on #236's code and called it a pre-existing misreport in MountRollback), and re-verified on #236's head b80afb4 (PR #236, fix/family-add-check-before-mount, main's #221 merged in) in a scratch worktree, gh a shim that was never called. #236 has since landed as 6d8d88b, whose scripts/family.py is byte-identical to b80afb4's (blob 9d8671b), so every line number below holds on main. It was simulated on Linux with core.ignorecase=true and an index entry planted with git update-index --add --cacheinfo, nothing on disk, and was not run on a real case-insensitive disk (macOS or Windows). Not claimed: why git submodule add stages the files under the variant (the effect was observed and the mechanism inside git was not traced), and any behaviour of git on a case-insensitive filesystem.
What is wrong. MountRollback records what add will change in the holder before it mounts, and undo puts it back. It reads the index through _entries (scripts/family.py:1255-1263): git ls-files -s -z -- <name>, keeping the records whose path equals name exactly. A pathspec matches case-sensitively unless git is given --icase-pathspecs, so an index entry that differs from members/Atlas by case is neither recorded (self.gitlink, :1219) nor undone (undo, :1339-1340). undo ends with a sentence that is printed whenever something was put back and nothing failed (:1372-1375), a promise about the whole holder made from six things it looked at:
# $U6: #236's head b80afb4. $R: scratch. Atlas scaffolded into local bare remotes ($R/remotes); the holder $H made with
# `family.py init --org testorg --family Fam --local-remote-dir $R/remotes --work-dir $R/fam`. gh a shim (no call logged).
cd $H && git config core.ignorecase true
git update-index --add --cacheinfo 100644,$(printf x | git hash-object -w --stdin),Members/Atlas/README # differs from members/Atlas by case; nothing on disk
git ls-files -s | wc -l # 16
git status --porcelain=v1 # AD Members/Atlas/README
python3 $U6/scripts/family.py add --family-root $H --member testorg/Atlas --local-remote-dir $R/remotes
# checking testorg/Atlas before mounting it
# mounting testorg/Atlas at members/Atlas
# REFUSED committing Atlas to the family. THE EXACT COMMAND WAS:
# git commit -q -F - -- .gitmodules members/Atlas family.yaml
# (in $H)
# exit 1
# --- output ---
# error: pathspec 'members/Atlas' did not match any file(s) known to git
# --- end output ---
# Nothing was added: the mount has been undone — members/Atlas removed, .git/modules/members/Atlas removed, .gitmodules removed, as there was none before, family.yaml restored byte for byte, `submodule.members/Atlas` dropped from the holder's git config — and the holder is exactly as it was before this command.
# exit 2
git ls-files -s | wc -l # 35
git ls-files -s | grep -c 'Members/Atlas/' # 20: the planted README and 19 more
git status --porcelain=v1 # 20 lines: AD Members/Atlas/.gitattributes, AD Members/Atlas/.github/workflows/validate.yml, ... (the member's own files)The refusal is right (nothing was committed) and the sentence is false: the index now holds 19 entries the holder did not have, the member's own files staged as regular files under Members/Atlas/, and a plain git commit in the holder commits them. (#242's branch, 289e542, ends the same way on the same state, per that review.)
Why. _entries is a pathspec read and a string compare, and a pathspec is case-sensitive; the entry that git submodule add touches here is spelled Members/Atlas/..., so the rollback never sees it. The model for the other behaviour is on main: adopt's _index_entries reads with --icase-pathspecs "whatever this disk's core.ignorecase says: the assembly is cloned on macOS and Windows too, where Spec and spec are ONE path, so it is refused on every disk" (adopt-project.py:2469-2492, the flag at :2483). The family's add has the same exposure for Members/Atlas against members/Atlas, and a rollback that claims the holder is unchanged is the part that does harm: the person trusts the sentence and does not look at git status.
What it should do. A refused add leaves the index as it found it, or says it did not. The decisions are the author's:
- Refuse before the mount, or put back. Refusing is the smaller change and what adopt does: an index entry at or under a case variant of
members/<P>(read with--icase-pathspecs, on every disk, as adopt reads it) is refused before anything is written. Putting back is what covers entries git stages that no pre-check foresaw: record the index under the path case-insensitively before the mount and haveundounstage what appeared. - The closing sentence. Print "exactly as it was" only for what was compared: the whole index before and after (a hash of
git ls-files -s -z, orgit diff --cached --quiet), or name the things put back and say nothing about the rest. _entrieskeeps exact-name records after its pathspec. With--icase-pathspecsthat filter needs a case-folded compare, or none.- Whether any of this changes on a case-insensitive disk, where the same state arises without
core.ignorecasebeing set by hand: not run here. CI's macOS and Windows runs are the check once a test exists.
How to test. The probe above as a test. It needs no file on disk, only git config core.ignorecase true and update-index --cacheinfo, so it runs on every OS: after the refused add, git ls-files -s -z is byte-identical to what it was before (the strongest form of "exactly as it was"), or the add refused before it mounted anything. #236's rollback tests already compare a whole-holder snapshot before and after a refused add, the index and git status included (tests/test_family_add_refusals.py:139-160); none of them plants a case variant (grep -n 'ignorecase\|Members' finds nothing there), so this state is the row that is missing, not a new kind of assertion.
Related: #203 (the issue #236 closes), #236, #242 (its review's N2), adopt's _index_entries (adopt-project.py:2469).
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-4, session 3431c370-df80-4c11-a041-41bce42d8b38@Eagle, 2026-10-12T04:02:10Z, for #302
Logged in
lanes/log/openRepoShape-4.mdat5d87972c6ca1f80b7b0254f6bad53e23e96fc094.The three reads (lane-collision-protocol Rule 1):
family.py: a refused `add` whose index differs points to `git ls-file…
family.py: the commit-first clause of `member-path-taken` says what s…
Merge origin/main (6f4ce47: #302 via #318, #299 via #315) into fix/fa…
Merge origin/main (a1b3a4b) into fix/adopt-plan-quote-yaml-keywords
Merge origin/main (a1b3a4b: #299 via #315, #216 via #228, #260 via #316…
Merge origin/main 8cdca5e into fix/adopt-root-kept-symlinks: thirteen…