Repository navigation
family.py: add never reads .gitmodules, so a section under ANOTHER name whose path is already members/<P> (a "ghost" section) does not stop it: it exits 0 and commits two sections for one path, validate-family.py says family ok, and a recursive clone takes the LATER section, so with the ghost second it dies fetching the ghost's URL #300
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by this lane's adversarial review of #242's branch (head 289e542; its nit N6, which the branch's own writer had also found), and re-verified on main d94d155 (#232's squash) with a holder, local bare remotes and gh a shim that was never called. It is the mirror of the member-name-taken refusal #242 adds (NOT on main yet): that one refuses when .gitmodules already has a section whose NAME is members/<P> for another path; this is a section whose PATH is members/<P> under another name. It is pre-existing, and it is not --force's doing: the review found the same exit 0 on #236's code (cc83d15 then), on #242's head and on a force-only variant of #236. Not claimed: a git other than 2.43 (the first run below used it; a recursive clone's choice between two sections for one path is git's, not this tool's), and Windows.
What is wrong. cmd_add (scripts/family.py:823-912) guards the mount with member-already-present (:827-834), member-path-taken (the directory exists on disk, :835-844) and member-git-dir-cached (:851-862), then runs git submodule add (:868). None of them reads .gitmodules, and git submodule add registers the path under the new name without asking whether another section already holds it:
# $U: a clean checkout at d94d155. Atlas scaffolded into local bare remotes ($R/remotes); the holder $H made with `family.py init --local-remote-dir`.
# A section named `ghost` is committed in the holder: [submodule "ghost"] path = members/Atlas, url = /nowhere/ghost.git
python3 $U/scripts/family.py add --family-root $H --member testorg/Atlas --local-remote-dir $R/remotes
# mounting testorg/Atlas at members/Atlas
# Atlas cc41816c22bb tree 2d49a3bad247… (project atlas)
# committed 8d1fc0eb5c6c: .gitmodules, members/Atlas, family.yaml exit 0
cat $H/.gitmodules
# [submodule "ghost"]
# path = members/Atlas
# url = /nowhere/ghost.git
# [submodule "members/Atlas"]
# path = members/Atlas
# url = $R/remotes/Atlas.git
python3 $H/scripts/validate-family.py --root $H # family ok: Fam (fam), 1 member(s)
# pushed to a bare repository of its own, then cloned:
git -c protocol.file.allow=always clone --recurse-submodules $R/remotes/Fam.git $R/clones/Fam
# (the ghost first, as `add` leaves it) exit 0, members/Atlas holds the member: git took the later section
# the same .gitmodules with the two sections the other way round (the ghost second), committed and pushed:
git -c protocol.file.allow=always clone --recurse-submodules $R/remotes/Fam.git $R/clones2/Fam
# fatal: repository '/nowhere/ghost.git' does not exist
# fatal: clone of '/nowhere/ghost.git' into submodule path '$R/clones2/Fam/members/Atlas' failed
# Failed to clone 'members/Atlas'. Retry scheduled
# ...
# Failed to clone 'members/Atlas' a second time, aborting exit 1So add commits a .gitmodules that registers one path twice; the holder passes its own validator; and which URL a recursive clone uses depends on which section is later in the file (in these two runs git took the later one). Because add appends, the member's own section is last at the moment of the commit and the clone works; the state is a landmine for the next edit, not a failure of this one. One more observation, from the sections the other way round: family.py remove --member Atlas then commits a .gitmodules that still holds a [submodule "members/Atlas"] section with no gitlink in the tree (once, in that order; not explored).
Why. member-path-taken asks the disk ((family.root / path).exists(), :836) and member-git-dir-cached asks .git/modules/members/<P> (:851-852); nothing asks the file that says which path belongs to which submodule name, and a section can exist, in .gitmodules and committed, with nothing on disk and no object store: a half-removed member, a hand edit, a merge that kept both sides. validate-family.py does not look either (the file has no reference to .gitmodules): it reads the rows of family.yaml and the gitlinks, and reports family ok for the committed result.
What it should do. add does not commit a second section for a path, and a holder that already has one is told. The decisions are the author's:
- Refuse before the mount. A section in
.gitmoduleswhosepathismembers/<P>, whatever its name, is refused (the mirror of family.pyaddrunsgit submodule addwithout--force(and_commit_onea plaingit add), so under an operator's global excludes namingmembersor the member's own name it exits 2 at the mount for every member, and the remediation it prints blames access (gh auth status, your SSH key) instead of the ignore rule #242'smember-name-taken, which is its model: it reads the working-tree file and the index orHEADcopy, because the file can be gone from the work tree and still be committed), naming the section and its URL and saying what to do:git rmit, orfamily.py removeif it was a member. Or replace it: drop the ghost section as part of the add's one commit, which edits a registration somebody wrote and is the less safe reading. - The validator.
validate-family.pygains a finding for two sections with onepath(and for a section whose path no row names), so a holder that already has one fails its own gate rather than passing it. It is a copied file, so that reaches holders throughupdate-shape.py. remove. Whetherremovealso takes every section for the path out, or refuses when there are two (the observation above, which needs its own probe before it is decided).
How to test. The first probe as a test: commit a section named ghost for members/Atlas in a holder and run add --member <org>/Atlas; assert the refusal (exit 2, nothing mounted, the holder byte-identical), and, if decision 2 is taken, that validate-family.py on a holder with two sections for one path is a finding. A second row with the ghost section present only in HEAD and deleted from the work tree.
Related: #242 (its review's N6; member-name-taken is the other half), #236 (the rollback these refusals leave untouched by refusing first), #203, #299 (remove and bump taking path: from the row unchecked), and validate-family.py.
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-4, session 3431c370-df80-4c11-a041-41bce42d8b38@Eagle, 2026-10-12T04:01:59Z, for #300
Logged in
lanes/log/openRepoShape-4.mdat107bb60242dfebf348fb4a7a416e745115a857d3.The three reads (lane-collision-protocol Rule 1):
family.py: `member-path-registered` prints a removal by key, `git con…
family.py: `member-path-registered` prints one `git config --unset-al…
tests/test_family_add_refusals.py: the same-url validator row has git…
Merge origin/main (6f4ce47: #302 via #318, #299 via #315) into fix/fa…
Merge origin/main (f795cf7: #295 via #312, #300 via #317, #255 via #270…
Merge origin/main f795cf7 into fix/adopt-relative-submodule-url: #255'…
Merge origin/main a2cb7d3 into fix/adopt-root-kept-symlinks: #255's `…
family.py: `add` refuses `member-duplicate-id` before it mounts anyth…
repo_shape.py, validate-pins.py, family.py: a refusal built without a…
family.py: `add` and `remove` also refuse an untracked `.gitmodules`,…
family.py: `remove` and `add` refuse a `.gitmodules` the work tree do…
validate-family.py and family.py: a gitlink no section of HEAD's `.gi…
validate-family.py: a hand mount's `git rm` line works typed: `git rm…