Repository navigation
family.py add, under core.ignorecase=true, commits the member's gitlink as Members/<P> when HEAD holds a path under a case variant of members/ that the index no longer does (a git rm -r Members not yet committed), so .gitmodules and family.yaml say members/<P>, the holder's own validator passes in the working clone, and every fresh clone of the pushed holder leaves the member's directory empty and is refused member-gitlink-absent #363
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by this lane's adversarial review of #302 (PR #318, head 80050a9; its follow-up F2 and nit N1, which reached this state by following #318's remediation without its commit-first step), and taken further by the re-check at 4302d85 (NIT-B, the three variant shapes). 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). New here: no refusal text is needed to get into this state. A plain git rm -r Members that has not been committed yet does it. It was simulated on Linux with core.ignorecase=true. It was not run on a case-insensitive disk, and the clone side ran on a case-sensitive disk. What CI does is read from the workflow, not run.
What is wrong. add asks the disk and the index about the mount path (_checked_mount_path; #318 makes the index question any-case) and never asks HEAD. Its commit is a partial commit of three named paths, which git builds from HEAD's tree. With core.ignorecase set, git files the new gitlink under the directory spelling HEAD already has:
U=<openRepoShape checkout> # a clean checkout at c7c02fa
# 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
mkdir Members && echo docs > Members/README.md && git add Members && git commit -qm 'docs under Members/'
git config core.ignorecase true # what macOS and Windows set
git rm -r -q Members # taken out, not committed yet
add testorg/Atlas | grep committed; echo "add: exit ${PIPESTATUS[0]}"
git ls-tree -r HEAD | grep -i 'members/'
git ls-files -s | grep -i members/atlas; git status --porcelain
grep -E 'path = |^ path:' .gitmodules family.yaml | grep -i atlas
python3 scripts/validate-family.py | tail -1; echo "validate in the holder: exit ${PIPESTATUS[0]}"
git clone -q --bare $H $R/pushed.git # what a push carries
git -c protocol.file.allow=always clone -q --recurse-submodules $R/pushed.git $R/fresh; echo "recursive clone: exit $?"
echo "files under the clone's Members/Atlas: $(ls -A $R/fresh/Members/Atlas | wc -l)"
cd $R/fresh && python3 scripts/validate-family.py > $R/v.out 2>&1; echo "validate in a fresh clone: exit $?"
head -1 $R/v.out | cut -c1-118Output at c7c02fa (the merge of #318 and #317 prints the same, commit ids aside; #318's head alone gave the same result for the git rm -r --cached -- Members/Atlas form of this state):
committed bd3d457efed2: .gitmodules, members/Atlas, family.yaml
add: exit 0
160000 commit 2be24d752a7b6a7c1c70c0b032a72982305869b6 Members/Atlas
100644 blob d8f8d46921aa81abc4c0d27703a8908333ae38c3 Members/README.md
160000 commit dbb3061b5aeef8cd68baeddaf33d58d488e3521a members/Nova
160000 2be24d752a7b6a7c1c70c0b032a72982305869b6 0 members/Atlas
D Members/README.md
R Members/Atlas -> members/Atlas
.gitmodules: path = members/Atlas
family.yaml: path: members/Atlas
family ok: Fam (fam), 2 member(s)
validate in the holder: exit 0
recursive clone: exit 0
files under the clone's Members/Atlas: 0
validate in a fresh clone: exit 2
REFUSED member-gitlink-absent: no gitlink (mode 160000) recorded at 'members/Atlas'. Atlas is named in family.yaml but
So the commit add reports records 160000 Members/Atlas, and it keeps Members/README.md too, because the pending deletion is not one of the three paths it names. .gitmodules and family.yaml say members/Atlas. The working clone's index holds members/Atlas (git status: R Members/Atlas -> members/Atlas), so the validator there passes. A fresh clone's index comes from HEAD. There git submodule update --init finds no .gitmodules section for Members/Atlas and skips it with exit 0, leaving the directory empty, and the validator refuses member-gitlink-absent for a member whose row is right there. The holder's CI goes the same way, by reading templates/family-root/.github/workflows/validate.yml: its fetch step's git submodule update --init --recursive exits 0, so members_available is true, the default mode runs, and the pull request is red with a refusal that names the row, not the spelling.
Why. recorded_gitlink reads the index first (#20), which is right for a staged bump, and is why the working clone passes. Pathspecs match case-sensitively whatever core.ignorecase says, which is why the fresh clone cannot find members/Atlas. #318's remediation for a committed variant of the mount path says this outcome in words ("where HEAD holds it too, commit that removal first ... otherwise ... with core.ignorecase set, records the member's gitlink under a spelling other than the members/Atlas that .gitmodules and family.yaml say"). But it is printed only when the index still holds the variant, and the variant here is of members/ itself, under which HEAD holds a file, not the mount path.
What it should do. add never commits the gitlink at a spelling other than members/<P>. The decisions are the author's:
- Ask HEAD before the mount. Refuse when HEAD holds an entry at or under a case variant of
members/<P>, or ofmembers/, that the index does not, naming the pending change to commit first. 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's commit-first clause becomes a refusal of its own. - Or check the result. After the commit, ask
git ls-tree HEADfor a gitlink at exactlymembers/<P>. When it is anywhere else, undo the commit this command made (HEAD moved only by it) and the mount, and refuse, naming the spelling git chose. This catches any cause, not only the ones foreseen here. - The validator. Whether
validate-family.pyalso reports a gitlink in HEAD under a case variant of a row's path, so that a holder already pushed this way is told why, not just that the gitlink is absent. - Decide with family.py
addcannot add any member, undercore.ignorecase=true(the macOS and Windows default), to a holder whose index holds a path under a case variant ofmembers/itself such asMembers/README, because git stages the new gitlink asMembers/<P>and the commit'smembers/<P>matches nothing, and the remedy its closing sentence prints fails the same way every time: a policy question for the maintainer, whetheraddrefuses such an index up front, and on which disks #362, the same variant held in the index, whereaddfails loudly instead.
How to test. The repro as a test in git's index and objects. Commit the variant with update-index --cacheinfo, take it out with git rm --cached, and set core.ignorecase explicitly, so it runs on every OS. Assert either the refusal with a byte-identical holder, or that git ls-tree -r HEAD holds the gitlink at members/<P> and nothing under Members/.
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 finding survives the merge.
Related: #302 and #318 (N1, NIT-B and the commit-first clause), #20 (recorded_gitlink reads the index first), #362 (the index-held twin, a policy question), #364 (the untracked variant on disk).
Activity
brettheap commentedon Oct 14, 2026
v1.1.0 (triage of 2026-10-14): one pull request with #362 and #364, family members under
core.ignorecase. Brett Heap ruled on 2026-10-14 that the three stay P2: aMembers/case variant is not counted as an ordinary path.brettheap commentedon Oct 14, 2026
CLAIMED — lane openRepoShape-5, session 50cede29-ab82-4ca3-9f67-d98e1a58c8d2@Eagle, 2026-10-14T22:32:34Z, for #363
Logged in
lanes/log/openRepoShape-5.mdat0336750177eeca882d024dac51338c92605d6f36.The three reads (lane-collision-protocol Rule 1):
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 …
validate-family.py and family.py: a gitlink no section of HEAD's `.gi…
bootstrap.py: `.gitmodules` is read as git's own parser reads it, a m…
family.py: `add`'s refusal of a member's own gitlink held only under …
bootstrap.py and validate-family.py: a member no credential would che…
validate-family.py: the wrong-case move back mends `.gitmodules`, add…
family.py add: a commit that records the member's gitlink anywhere bu…