Repository navigation
family.py add cannot add any member, under core.ignorecase=true (the macOS and Windows default), to a holder whose index holds a path under a case variant of members/ itself such as Members/README, because git stages the new gitlink as Members/<P> and the commit's members/<P> matches nothing, and the remedy its closing sentence prints fails the same way every time: a policy question for the maintainer, whether add refuses such an index up front, and on which disks #362
Description
Lane: openreposhape-4 (openRepoShape-4)
This is a question for the maintainer, not a defect with an agreed fix. It was found by this lane's adversarial review of #302 (PR #318, head 80050a9; its follow-up F1). That review judged it not to belong in #318: refusing a case variant of members/ itself would be a new policy, while #318's ruling 1 refuses only a variant of the MOUNT PATH, and #318's own unit test pins "a variant of members/ alone: not refused, by design". Re-verified on main c7c02fa, on #318's head 0f2a0dd (which carries c7c02fa), and on a scratch merge of 0f2a0dd with #317's head c23e2b0, with local bare remotes, an empty global git config, GIT_ALLOW_PROTOCOL=file and a gh that exits 97 (never called). It was simulated on Linux with core.ignorecase=true and an index-only entry. It was not run on a real case-insensitive disk, where a tracked Members/README is also a file on disk and members/ and Members/ are one directory.
What is wrong. A holder whose index holds any path under Members/ (or MEMBERS/, any spelling of members other than its own) cannot add a member while core.ignorecase is true:
U=<openRepoShape checkout> # a clean checkout at c7c02fa, or #318's head
# Runs from a clean checkout of openRepoShape, in bash, on Linux (GNU coreutils). Nothing leaves the machine: members and
# holder live in local bare repositories under $R, git reads an empty global config, GIT_ALLOW_PROTOCOL=file makes any
# accidental https/ssh clone fail, and a `gh` that exits 97 stands first on PATH.
R=$(mktemp -d); mkdir $R/bin; printf '#!/bin/sh\nexit 97\n' > $R/bin/gh; chmod +x $R/bin/gh; touch $R/gitconfig
export PATH=$R/bin:$PATH GIT_CONFIG_GLOBAL=$R/gitconfig GIT_CONFIG_NOSYSTEM=1 GIT_ALLOW_PROTOCOL=file \
GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t.invalid GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t.invalid
for P in Atlas Nova Zed; do
python3 $U/scaffold-project.py --org testorg --project $P --elected-by 'Test Human' --elected-on 2026-10-12 \
--local-remote-dir $R/remotes --work-dir $R/work >/dev/null
done
python3 $U/scripts/family.py init --org testorg --family Fam --created-by 'Test Human' --created-on 2026-10-12 \
--local-remote-dir $R/fam --work-dir $R/h >/dev/null 2>&1
H=$R/h/Fam; cd $H
add() { python3 $U/scripts/family.py add --family-root $H --member "$1" --local-remote-dir $R/remotes; }
add testorg/Nova >/dev/null
git config core.ignorecase true # what macOS and Windows set; there a tracked Members/README is also on disk
git update-index --add --cacheinfo 100644,$(printf 'x\n' | git hash-object -w --stdin),Members/README
add testorg/Atlas 2>&1 | grep -vE '^ |^--- |^exit' | cut -c1-420; echo "add: exit ${PIPESTATUS[0]}"
git ls-files -s | grep -i '^160000.*members/atlas'
git rm -r -q --cached -- Members/Atlas # what the closing sentence says to do
add testorg/Atlas >/dev/null 2>&1; echo "add again: exit $?"
git ls-files -s | grep -i '^160000.*members/atlas'
git rm -r -q --cached -- Members/Atlas
git config core.ignorecase false # the same index on a case-sensitive setting
add testorg/Atlas | grep committed; echo "add, ignorecase false: exit ${PIPESTATUS[0]}"
git ls-tree -r HEAD | grep -i 'members/'Output on the merge of #318 and #317 (#318's head alone gave the same refusal and the same loop in an earlier probe of this state; the long sentence is cut at 420 characters by the block):
checking testorg/Atlas before mounting it
mounting testorg/Atlas at members/Atlas
REFUSED committing Atlas to the family. THE EXACT COMMAND WAS:
error: pathspec ':(literal)members/Atlas' did not match any file(s) known to git
Nothing was committed: the mount has been undone — members/Atlas removed, .git/modules/members/Atlas removed, .gitmodules restored byte for byte, family.yaml restored byte for byte, `submodule.members/Atlas` dropped from the holder's git config — but the holder's index is NOT as it was before this command (new in it: `Members/Atlas`). Put it back before running `add` again: `git ls-files -s` lists what the index
add: exit 2
160000 f2bd55b2e151bb7e20e57d6d03a89e68e6e3f1ce 0 Members/Atlas
add again: exit 2
160000 f2bd55b2e151bb7e20e57d6d03a89e68e6e3f1ce 0 Members/Atlas
committed ac0f18d5ca56: .gitmodules, members/Atlas, family.yaml
add, ignorecase false: exit 0
160000 commit f2bd55b2e151bb7e20e57d6d03a89e68e6e3f1ce members/Atlas
160000 commit e08ce48cda8ec95f970fc75f975b7edbbe5dad26 members/Nova
On main the steps and exits are the same, but the first refusal ends "and the holder is exactly as it was before this command" over an index that gained Members/Atlas. That is #302's misreport, which #318 fixes. With #318 the sentence is true, and the remedy it names (take what is new back out with git rm -r --cached -- <path>, then run add again) leads to the same failure every time. The same index with core.ignorecase=false commits members/Atlas, and the planted Members/README stays staged beside it. Once somebody commits that file, a clone of the holder on macOS or Windows is in this state for its next add; the companion follow-up #364 shows that, simulated.
Why. Under core.ignorecase, git's index keeps the first spelling of a directory it has seen. Members/README sorts before every members/... entry, so git submodule add ... members/Atlas records the gitlink as Members/Atlas (#318's review traced this to git's adjust_dirname_case). The add's commit names :(literal)members/Atlas, and git matches a pathspec case-sensitively whatever core.ignorecase says, so it matches nothing. #318 refuses an entry at or under a variant of members/<P> on every disk. A variant of members/ alone is not the mount path's, and was let through deliberately, because a holder that keeps Members/README is legitimate on a case-sensitive disk and harmless there. The template writes nothing under members/, so any such directory is a human's.
What it should do. That is the question: does add refuse such an index up front, and where? The decision is the maintainer's; each option, and what it costs:
- Refuse on every disk. Before the member is read, refuse an index entry under any case variant of
members/other thanmembers/itself, naming the entries and a rename (git mv, committed) as the remedy. Cost: a new policy. A holder with aMembers/directory of its own, legitimate and harmless on Linux, can add no member until it renames that directory. The check itself is one case-folded pass over_index_records, which family.py:addasks the holder's index for the mount path in any case, so an entryMembers/Atlas/READMEis refused asmember-path-takenbefore anything is cloned instead of leaving the member's 19 files staged under it, and a refusedaddsays the holder is exactly as it was only when the whole index is as it was (#302) #318 already reads. - Refuse only where
core.ignorecaseis true, the one place the add cannot succeed. Cost: one holder is refused on macOS and Windows and accepted on Linux. The Linux add then pushesMembers/besidemembers/<P>, and everyaddfrom a macOS or Windows clone of it fails (family.py_checked_mount_pathasks the disk for the mount path with an exactos.path.lexists, so an untrackedMembers/<P>directory is refusedmember-path-takenon macOS and Windows and let through on Linux, whereaddmountsmembers/<P>beside it, and once that directory is committed a clone of the holder on a case-insensitive disk can add no other member #364's last row). The behaviour depends on a per-clone setting, not on the holder. - Do not refuse; make the failure name its cause. When the commit fails and the index gained the gitlink under another spelling (the closing sentence already computes "new in it:
Members/Atlas"), say that theMembers/spelling the index holds is why, and that renaming that directory is the fix. Cost: every attempt still clones and mounts the member before it fails. No new policy, and the operator learns the cause from the first failure instead of looping. - Leave it as family.py:
addasks the holder's index for the mount path in any case, so an entryMembers/Atlas/READMEis refused asmember-path-takenbefore anything is cloned instead of leaving the member's 19 files staged under it, and a refusedaddsays the holder is exactly as it was only when the whole index is as it was (#302) #318 leaves it. The sentence is honest and the loop is visible. Cost: the printed remedy does not work, and this state arises on macOS and Windows without anybody settingcore.ignorecaseby hand.
Whichever is chosen, #363 is the same variant held in HEAD but not in the index, where add succeeds and records the gitlink under the variant. A rule for the index probably wants the same answer for HEAD, and the two can be decided together.
How to test. The state needs only git config core.ignorecase true and an index entry made with update-index --cacheinfo, with no file on disk, so a test runs on every OS, as #318's case-variant tests do. Under options 1 and 2, assert the refusal and a byte-identical holder. Under option 3, assert that the sentence names Members/. In every case, a control that a holder with no variant still adds.
Verified at main c7c02fa, at #318's head 0f2a0dd, and at the scratch merge of 0f2a0dd with #317's head c23e2b0 (scripts/family.py sha256 8339041e…); the loop survives the merge, with #318's honest sentence in place of main's false one.
Related: #302 and #318 (ruling 1 and its "not refused, by design" unit test), #363 (the HEAD-only twin, where add succeeds wrongly), #364 (the untracked variant on a case-sensitive disk, whose committed state leads here).
Activity
brettheap commentedon Oct 14, 2026
CLAIMED — lane openRepoShape-5, session 50cede29-ab82-4ca3-9f67-d98e1a58c8d2@Eagle, 2026-10-14T22:31:46Z, for #362
Logged in
lanes/log/openRepoShape-5.mdat7eb23bd0b5ae5e3f5dc23dfcd609027bd642c9f8.The three reads (lane-collision-protocol Rule 1):
brettheap commentedon Oct 14, 2026
(lane openRepoShape-5, 2026-10-14T22:38:17Z) Brett Heap's ruling on this question, 2026-10-14T22:27:52Z: "Refuse on every disk".
The option he chose: "Refuse before reading the member, naming the entries and a committed
git mvas the remedy, with one rule for index, HEAD and disk. Linux, macOS and Windows give the same answer, matching #318's any-case rule for the mount path. Cost: a Linux holder with its ownMembers/folder must rename it first."It decides #363 and #364 as well. The same rule covers a variant held only in HEAD, and one on disk, tracked or not. Lane 5 is fixing all three in one PR, from the branch
fix/family-members-case-variants. That PR reverses #318's unit test "a variant ofmembers/alone: not refused, by design" and says why.Lane: openRepoShape-5
family.py: `add`'s case-variant refusal names a remedy for each entry…
tests/test_family_members_case_variants.py: the layout table gains a …
siblings.py and family.py: `make siblings` asks about the family fold…
family.py: `add`'s refusal of a member's own gitlink held only under …