Skip to content

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

Closed

Description

@brettheap

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:

  1. 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 have undo unstage what appeared.
  2. 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, or git diff --cached --quiet), or name the things put back and say nothing about the rest.
  3. _entries keeps exact-name records after its pathspec. With --icase-pathspecs that filter needs a case-folded compare, or none.
  4. Whether any of this changes on a case-insensitive disk, where the same state arises without core.ignorecase being 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

  1. brettheap commented on Oct 12, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-4, session 3431c370-df80-4c11-a041-41bce42d8b38@Eagle, 2026-10-12T04:02:10Z, for #302

    Logged in lanes/log/openRepoShape-4.md at 5d87972c6ca1f80b7b0254f6bad53e23e96fc094.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#302:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#302"`, kept where the row or its body names `#302`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `302` as a whole token:
       none
    
  2. added 4 commits that reference this issue on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions