Repository navigation
family.py remove, when .gitmodules gives 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 registers members/<P> with no gitlink and no row, validate-family.py passes the result (after #317 it reported the doubled file and stops), and the next add of that member is refused member-path-registered or silently reuses the leftover section and the .git/config registration deinit never touched #360
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by this lane's adversarial review of #300 (PR #317, head 03ddadd; its control rows C2 and C3 and its out-of-scope item 3), which reproduced the observation #300's own body made and left as its decision 3. PR #317 leaves remove alone by the coordinator's ruling and lists this under "Not in this change", so it stays open when #317 closes #300. 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 that exits 97 (never called). Not claimed: a git other than 2.43 (which section git's path lookup returns is git's choice; the later one won in every run here), and macOS or Windows.
What is wrong. cmd_remove (scripts/family.py:1926 at c7c02fa) hands the member's path to git submodule deinit and git rm and commits .gitmodules as git leaves it. git maps a path to ONE submodule name, the last section in the file to give that path, so when two sections give members/<P>, git rm removes that one section and deinit unsets only that name's keys in .git/config. The other section survives, in the commit remove writes, with no gitlink and no members: row behind it.
How a holder comes to have two sections: a hand edit, a merge that kept both sides, or a holder written by an add before #317 (on main today add commits them, which is #300). After #317 the holder's own validator names the state (gitmodules-path-twice), and add's new member-path-registered remediation offers "family.py remove, if it was a member" as one way out.
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/Atlas >/dev/null; add testorg/Nova >/dev/null
cp -a $H $R/h/Fam2 # a second copy, for the other order of the two sections
# (1) a second section for members/Atlas under another name, AFTER the member's own (git config appends):
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 second section for members/Atlas'
python3 scripts/validate-family.py 2>&1 >/dev/null | grep -o '^FINDING [a-z-]*'; echo "validate before: exit ${PIPESTATUS[0]}"
python3 $U/scripts/family.py remove --family-root $H --member Atlas | sed -n 1,2p; echo "remove: exit ${PIPESTATUS[0]}"
git show HEAD:.gitmodules | grep -E '^\[|path'
echo "gitlinks: $(git ls-files -s -- members | cut -f2 | tr '\n' ' ')"
git config --local --get-regexp '^submodule\.members/Atlas\.'
python3 scripts/validate-family.py | tail -1; echo "validate after: exit ${PIPESTATUS[0]}"
# (2) the same two sections the other way round, the second one FIRST:
cd $R/h/Fam2
{ printf '[submodule "ghost"]\n\tpath = members/Atlas\n\turl = /nowhere/ghost.git\n'; cat .gitmodules; } > $R/gm
cp $R/gm .gitmodules && git add .gitmodules && git commit -qm 'a second section for members/Atlas, first'
python3 $U/scripts/family.py remove --family-root $R/h/Fam2 --member Atlas >/dev/null; echo "remove: exit $?"
git show HEAD:.gitmodules | grep -E '^\[|path'
python3 scripts/validate-family.py | tail -1; echo "validate after: exit ${PIPESTATUS[0]}"
mv .git/modules/members/Atlas $R/store # the store `remove` leaves on purpose, out of the way
python3 $U/scripts/family.py add --family-root $R/h/Fam2 --member testorg/Atlas --local-remote-dir $R/remotes 2>&1 \
| grep -E '^REFUSED|committed' | cut -c1-120; echo "re-add: exit ${PIPESTATUS[0]}"Output at c7c02fa:
validate before: exit 0
removed Atlas (testorg/Atlas) from members/Atlas
committed 919bbd7c2139: .gitmodules, members/Atlas, family.yaml
remove: exit 0
[submodule "members/Atlas"]
path = members/Atlas
[submodule "members/Nova"]
path = members/Nova
gitlinks: members/Nova
submodule.members/Atlas.url $R/remotes/Atlas.git
submodule.members/Atlas.active true
family ok: Fam (fam), 1 member(s)
validate after: exit 0
remove: exit 0
[submodule "ghost"]
path = members/Atlas
[submodule "members/Nova"]
path = members/Nova
family ok: Fam (fam), 1 member(s)
validate after: exit 0
committed e30e9743313e: .gitmodules, members/Atlas, family.yaml
re-add: exit 0
On the merge of #318 and #317 the output is the same but in two places. The validator reports the doubled file before remove (FINDING gitmodules-path-twice, validate before: exit 1). Its exit 0 after remove is the same, so remove turns a finding into a pass while the registration stays. And the last re-add is refused, naming the leftover: REFUSED member-path-registered: .gitmodules already gives the path members/Atlas to the submodule named ghost (url /nowhere/ghost.git) ..., re-add: exit 2.
So in order (1) the commit keeps the member's OWN section, [submodule "members/Atlas"] with its url, and .git/config keeps submodule.members/Atlas.url and .active, because deinit deinitialised the name ghost. The re-add then rewrites that section in place and inherits any other key it carried (#317's review found a stale branch = dev breaking git submodule update --remote that way). In order (2) the commit keeps the other section, so on main the re-add commits two sections again, and after #317 it is refused until somebody removes the leftover by hand. Either way remove printed removed Atlas ... from members/Atlas, exit 0, and its commit message says the member was "Unmounted from members/Atlas and dropped from family.yaml".
Why. remove never reads .gitmodules itself. It lets git resolve the path, and git's submodule config holds one name per path, so a second section for the same path is invisible to both deinit and rm. #317 makes _submodule_paths read every section with all its keys, but only add asks it. The validator cannot catch the leftover either: after remove there is one section for the path, not two, and nothing reports a section whose path no row names (the companion follow-up #361).
What it should do. remove does not commit a .gitmodules that still gives the path it unmounts. The decisions are the author's:
- Refuse or clean. Either refuse before the first git call when
.gitmodulesgives the path to more than one section, naming each with its url and printing 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 for the ones that are not the member's own. That isadd's model, and it leaves whose registration it is to the human, as 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 ruling did. Or take every section that gives the path out in the same commit, by key (git config -f .gitmodules --unset-all submodule.<name>.<key>for each key, which 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 showed works where--remove-sectioncannot), and unset each name's registration in.git/config. That edits a section somebody else wrote. - Whatever is chosen, a test asserts it in both section orders: after
remove, no section inHEAD:.gitmodulesgivesmembers/<P>andgit config --local --get-regexp '^submodule\.members/<P>\.'is empty, or the holder is byte-identical after a refusal. - Whether 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-registeredremediation keeps offeringfamily.py remove"if it was a member", given whatremovedoes with two sections today.
How to test. The repro's two orders as tests beside #317's ghost tests in tests/test_family_add_refusals.py. Build the doubled .gitmodules in git's objects (hash-object -w --stdin with bytes, update-index --cacheinfo, commit) as #317's gitmodules_with does, so nothing depends on a checkout on Windows. Then assert decision 2, and that validate-family.py passes the result only when no section is left for the path.
Verified at main c7c02fa and at the scratch merge of #318's head 0f2a0dd with #317's head c23e2b0 (scripts/family.py sha256 8339041e…, validate-family.py 7ec3eec0…); the finding survives the merge, and after it the leftover in order (2) blocks the next add.
Related: #300 (decision 3 and the observation in its body; closed by PR #317), #317 (its _submodule_paths, member-path-registered and gitmodules-path-twice), #299 (cmd_remove's path checks, PR #315), #361 (the validator finding for a section, or a gitlink, that no row names).
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-4, session e7cd5391-c790-49b0-a88f-a97d34b22582@Eagle, 2026-10-12T15:21:13Z, for #360
Logged in
lanes/log/openRepoShape-4.mdatcebb8aa4dfac4dc8b37ca324b49458f11793aca4.The three reads (lane-collision-protocol Rule 1):
Merge branch 'fix/family-add-dry-run' (#400, 7062a47) into fix/family…
Merge origin/main (dd8d0ea, with #400's squash a92e60b) into fix/fami…
Merge branch 'fix/validate-family-unnamed-mounts' (#406, e83cc21) int…
validate-family.py: a gitlink in `members/` or a `.gitmodules` sectio…
family.py: `init` refuses to land in the standard's checkout and name…
family.py: `add` and `remove` also refuse an untracked `.gitmodules`,…
family.py: `remove` and `add` refuse a `.gitmodules` the work tree do…