Repository navigation
validate-family.py asks every members: row for its gitlink and never the reverse, so a gitlink under members/ that no row names (a member mounted by hand with git submodule add, or a row deleted by hand) and a .gitmodules section whose path no row names (what family.py remove leaves of two sections for one path, or a section for a case variant such as Members/Atlas, which add commits beside the member's own even after #317) all pass the holder's own gate, and the doctor calls the holder COMPLIANT #361
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by three of this lane's adversarial reviews:
- of family.py:
bumpandremovetake the git pathspec from the manifest row'spath:with no check and no--literal-pathspecs, so a hand-editedpath: members/*makesremove --member Atlasexit 0 with ONE commit that unmounts every member while dropping only Atlas's row, andpath: .makes itgit rm -r -fthe holder's own files and die on aFileNotFoundError, thoughvalidate-family.pyalready has a rule,member-path, for whatpath:must equal #299 (PR family.py:bumpandremoverefusemember-pathwhen a row'spath:is notmembers/<project>or itsproject:is not a directory name, and hand git every path literally, so a hand-editedpath: members/*orproject: .no longer makesremoveunmount every member in one commit while dropping one row (#299) #315, head 58aa4e3; its out-of-scope item OOS-2, the unnamed gitlink, which its SHOULD-2 probe produced by following the then-printed remedy "remove the row by hand"); - of family.py:
addnever reads.gitmodules, so a section under ANOTHER name whosepathis alreadymembers/<P>(a "ghost" section) does not stop it: it exits 0 and commits two sections for one path,validate-family.pysaysfamily ok, and a recursive clone takes the LATER section, so with the ghost second it dies fetching the ghost's URL #300 (PR family.py:addrefusesmember-path-registeredwhen a.gitmodulessection under another name already gives the mount path, andvalidate-family.pyfindsgitmodules-path-twice, soaddno longer commits two sections for one path that a recursive clone resolves to the later one, and a holder that already has such a ghost section fails its own gate (#300) #317, head 03ddadd; out-of-scope item 4, the unnamed section). family.py:addnever reads.gitmodules, so a section under ANOTHER name whosepathis alreadymembers/<P>(a "ghost" section) does not stop it: it exits 0 and commits two sections for one path,validate-family.pysaysfamily ok, and a recursive clone takes the LATER section, so with the ghost second it dies fetching the ghost's URL #300's own decision 2 asked for this finding, and PR family.py:addrefusesmember-path-registeredwhen a.gitmodulessection under another name already gives the mount path, andvalidate-family.pyfindsgitmodules-path-twice, soaddno longer commits two sections for one path that a recursive clone resolves to the later one, and a holder that already has such a ghost section fails its own gate (#300) #317 left it out by name ("A validator finding for a section whose path nomembers:row names"); - of family.py:
MountRollbackreads the holder's index with a case-sensitive pathspec (git ls-files -s -- <path>), so withcore.ignorecase=trueand an index entryMembers/Atlas/READMEa refusedaddleaves the 19 filesgit submodule addstaged underMembers/Atlas/and still prints "the holder is exactly as it was before this command" #302 (PR 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, head 80050a9; its follow-up F4, family.py:addrefusesmember-path-registeredwhen a.gitmodulessection under another name already gives the mount path, andvalidate-family.pyfindsgitmodules-path-twice, soaddno longer commits two sections for one path that a recursive clone resolves to the later one, and a holder that already has such a ghost section fails its own gate (#300) #317's exact comparison of.gitmodulespaths).
Re-verified on main c7c02fa and on a scratch merge of #318's head 0f2a0dd (which carries c7c02fa) with #317's head c23e2b0, with local bare remotes, an empty global git config, GIT_ALLOW_PROTOCOL=file and a gh shim that exits 97. The doctor's machine row asked the shim gh --version and gh auth status, which is #296's class; nothing else called it. Not claimed: macOS or Windows (row (c) is a string comparison and ran on Linux with core.ignorecase false and true alike), and what bootstrap.py does with an unnamed member, which was read, not run.
What is wrong. The holder's validator walks members: and asks each row for its gitlink, digest and identity (_check_members, templates/family-root/scripts/validate-family.py:573 at c7c02fa). Nothing walks the other way, from what members/ holds or what .gitmodules registers back to a row. #317 adds _check_gitmodules_paths, which finds two sections for one path, and its docstring says so plainly: "A section whose path no row names is another question, and not asked here." The doctor's family row runs this validator, and its members row reads the rows too. So:
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
cp -a $H $R/h/Fam3 # a holder without Atlas yet, for (c)
add testorg/Atlas >/dev/null
cp -a $H $R/h/Fam2
# (a) a member mounted by hand, `git submodule add`, with no `members:` row:
git -c protocol.file.allow=always submodule add -q -- $R/remotes/Zed.git members/Zed && git commit -qm 'Zed, by hand'
echo "gitlinks: $(git ls-files -s -- members | cut -f2 | tr '\n' ' ') rows: $(grep -c '^ - project:' family.yaml)"
python3 scripts/validate-family.py | tail -1; echo "validate: exit ${PIPESTATUS[0]}"
python3 $U/shape-doctor.py --root $H | tail -1
# (b) a section whose path no row names, and no gitlink (the state `remove` leaves of two sections for one path):
cd $R/h/Fam2
git config -f .gitmodules submodule.members/Old.path members/Old
git config -f .gitmodules submodule.members/Old.url /nowhere/old.git
git add .gitmodules && git commit -qm 'a section with no row'
python3 scripts/validate-family.py | tail -1; echo "validate: exit ${PIPESTATUS[0]}"
# (c) a section under another name for `Members/Atlas`, a case variant of the mount path, then `add`:
cd $R/h/Fam3
git config -f .gitmodules submodule.ghost.path Members/Atlas
git config -f .gitmodules submodule.ghost.url /nowhere/ghost.git
git add .gitmodules && git commit -qm 'a section for Members/Atlas'
python3 $U/scripts/family.py add --family-root $R/h/Fam3 --member testorg/Atlas --local-remote-dir $R/remotes \
| grep committed; echo "add: exit ${PIPESTATUS[0]}"
git show HEAD:.gitmodules | grep -E '^\[|path'
python3 scripts/validate-family.py | tail -1; echo "validate: exit ${PIPESTATUS[0]}"Output at c7c02fa (the merge of #318 and #317 prints the same lines, commit ids aside):
gitlinks: members/Atlas members/Nova members/Zed rows: 2
family ok: Fam (fam), 2 member(s)
validate: exit 0
COMPLIANT (exit 0)
family ok: Fam (fam), 2 member(s)
validate: exit 0
committed 34fb99715a8e: .gitmodules, members/Atlas, family.yaml
add: exit 0
[submodule "members/Nova"]
path = members/Nova
[submodule "ghost"]
path = Members/Atlas
[submodule "members/Atlas"]
path = members/Atlas
family ok: Fam (fam), 2 member(s)
validate: exit 0
What each leaves:
- (a) A repository every
git clone --recurse-submodulesof the holder fetches. So does the holder's ownbootstrap.pystep (a), a plaingit submodule update --init --recursive. Yet no row pins it, no row describes it, and no gate checks its digest or itsproject.yaml:validate-family.pyandbootstrap.py's step (b) both walk the rows. It is a member nobody declared, and the holder is COMPLIANT. - (b) Inert for a clone, since git follows gitlinks. But it is what the next
addof that name or path meets. Under the section's own name, git rewrites it in place and the member inherits any key it carried (family.py:addrefusesmember-path-registeredwhen a.gitmodulessection under another name already gives the mount path, andvalidate-family.pyfindsgitmodules-path-twice, soaddno longer commits two sections for one path that a recursive clone resolves to the later one, and a holder that already has such a ghost section fails its own gate (#300) #317's review saw a stalebranch = devbreakgit submodule update --remote). Under another name,addrefusesmember-path-registeredonce family.py:addrefusesmember-path-registeredwhen a.gitmodulessection under another name already gives the mount path, andvalidate-family.pyfindsgitmodules-path-twice, soaddno longer commits two sections for one path that a recursive clone resolves to the later one, and a holder that already has such a ghost section fails its own gate (#300) #317 lands. This is exactly the statefamily.py removeleaves when the file held two sections for the path (family.pyremove, when.gitmodulesgives the member's path to two sections, takes out only the one git finds by that path (the later in the file) and exits 0, so its commit still registersmembers/<P>with no gitlink and no row,validate-family.pypasses the result (after #317 it reported the doubled file and stops), and the nextaddof that member is refusedmember-path-registeredor silently reuses the leftover section and the.git/configregistrationdeinitnever touched #360). - (c) family.py:
addrefusesmember-path-registeredwhen a.gitmodulessection under another name already gives the mount path, andvalidate-family.pyfindsgitmodules-path-twice, soaddno longer commits two sections for one path that a recursive clone resolves to the later one, and a holder that already has such a ghost section fails its own gate (#300) #317'smember-path-registeredcompares the path exactly, by its ruling, because git reads.gitmodulesthat way. So a section forMembers/Atlasis not refused,addcommits the member's section beside it, andgitmodules-path-twicedoes not pair them. On macOS and Windows those two paths are one directory. 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 refuses an INDEX entry at a case variant of the mount path on every disk, for that reason, but a section with no gitlink has no index entry.
Why. Every check here starts from a row, so anything the rows do not mention is outside the question. The reverse question already has an implementation in the doctor: gitlinks_under (shape-doctor.py:1311 at c7c02fa) lists "each gitlink under the members directory that no row names". Only family_beside_project (#211) calls it, for a family.yaml committed beside a project.yaml, never for a holder.
What it should do. A holder whose members/ or .gitmodules carries something no row names fails its own gate. The decisions are the author's:
- Two findings, or one. A gitlink under
members/that no row's path names: read from the index at stage 0, else from HEAD, the orderrecorded_gitlinkuses. And a.gitmodulessection whose path no row names. Neither needs a member checked out, so whether they run under--no-membersas well, like family.pyaddcompares--memberwith the existing rows case-sensitively, so--member inkrouter/irrson a family that already pins InkRouter/IRRS mounts the same repository again asmembers/irrs, both gates pass a family that pins one project twice, andbump --member irss's refusal recommends exactly that add #206's id check and family.py:addnever reads.gitmodules, so a section under ANOTHER name whosepathis alreadymembers/<P>(a "ghost" section) does not stop it: it exits 0 and commits two sections for one path,validate-family.pysaysfamily ok, and a recursive clone takes the LATER section, so with the ghost second it dies fetching the ghost's URL #300's section check, is the author's call. A private family's CI falls back to that mode. - Case. Whether a path is compared to the rows with ASCII case folded, 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's_index_entriesdoes on every disk and validate-family.pymember-duplicateruns only with the members checked and comparesproject:exactly, so--no-memberspasses afamily.yamlthat names one row twice, and rowsIRRSandirrswith distinct ids pass, thoughmembers/IRRSandmembers/irrsare one directory on macOS and Windows #331 proposes formember-duplicate, so thatMembers/Atlasis reported as the variant ofmembers/Atlasit is. And whetheradd'smember-path-registeredfolds case too, so that row (c) is refused before the mount rather than found after it. That reopens a ruling of family.py:addrefusesmember-path-registeredwhen a.gitmodulessection under another name already gives the mount path, andvalidate-family.pyfindsgitmodules-path-twice, soaddno longer commits two sections for one path that a recursive clone resolves to the later one, and a holder that already has such a ghost section fails its own gate (#300) #317, so it is the coordinator's to make. - The remedy each finding names. For a gitlink:
family.py addcannot adopt an existing mount (it refusesmember-path-taken), so the remedy isgit rm --cachedand thenadd, or the row restored from git. For a section: family.py:addrefusesmember-path-registeredwhen a.gitmodulessection under another name already gives the mount path, andvalidate-family.pyfindsgitmodules-path-twice, soaddno longer commits two sections for one path that a recursive clone resolves to the later one, and a holder that already has such a ghost section fails its own gate (#300) #317's by-key removal. - The doctor. Its
familyrow reports whatever the holder's own validator finds, so a validator finding reaches it. Separately, should itsmembersrow list the unnamed gitlinks withgitlinks_under, as shape-doctor.py reads onlyproject.yamlat a root that also carries afamily.yaml, so an assembly root whosefamily.yamldeclares two unmounted members is COMPLIANT, and itsmanifest kindsrow says that kind has a registered validator, which is never run #211's row does? One reader either way. validate-family.pyis a copied file, so the finding reaches existing holders throughupdate-shape.py.
How to test. The repro's three rows as validator tests, each a finding (exit 1), plus a control that a holder whose every gitlink and section a row names still says family ok. Build the extra section in git's objects (bytes on stdin), as #317's tests do. The hand-mounted gitlink needs git submodule add from a local bare remote, as the existing family fixtures already do.
Verified at main c7c02fa and at the scratch merge of #318's head 0f2a0dd with #317's head c23e2b0 (validate-family.py sha256 7ec3eec0…, scripts/family.py 8339041e…); all three rows survive the merge.
Related: #300 (decision 2; closed by PR #317), #317 (_check_gitmodules_paths, and its exact comparison in member-path-registered), #299 (PR #315's SHOULD-2, whose first remedy produced row (a)), #211 (gitlinks_under), #318 (_index_entries, any case), #331 (the same _check_members, held by lane 4: a landing neighbour), #360 (remove leaving row (b)).
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-4, session e7cd5391-c790-49b0-a88f-a97d34b22582@Eagle, 2026-10-12T15:16:44Z, for #361
Logged in
lanes/log/openRepoShape-4.mdat1366a1a945497425c0d5b7e54d2f848a8649602e.The three reads (lane-collision-protocol Rule 1):
validate-family.py: a gitlink or a `.gitmodules` section at a case va…
validate-family.py and family.py: a gitlink no section of HEAD's `.gi…
bootstrap.py: a plain clone's update that dies at a gitlink no sectio…