Repository navigation
family.py remove, in a holder whose work-tree .gitmodules has been deleted and the deletion not committed, commits that deletion with the member's removal, so every other member's section leaves HEAD while remove exits 0 and the holder's validate-family.py passes, and once pushed a recursive clone exits 0 with each remaining member's directory empty and its own validate-family.py refusing member-uninitialized #407
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by this lane's adversarial review of #326 part 1 (PR #345). The review at head 875d802 named it out of scope: "remove with the work-tree .gitmodules deleted, uncommitted: the real commit deletes .gitmodules from HEAD, taking the neighbour's section with it". Its re-check at f54e20c added N9: in that state remove --dry-run prints no registration line, though deinit drops one. #345 landed as 3ae0109 and named both as follow-ups. Re-verified on main 3ae0109, with local bare remotes, an empty global git config, GIT_ALLOW_PROTOCOL=file and a gh that exits 97 (never called). Not claimed: macOS and Windows, which were not run.
What is wrong. cmd_remove (scripts/family.py:2484 at 3ae0109) runs four steps in order:
git submodule deinit -f(:2514);git rm -r -f(:2516);write_members(:2518);_commit_one(..., [GITMODULES, path, MANIFEST], add=[GITMODULES, MANIFEST])(:2527).
git rm edits .gitmodules only where the work tree has one. With none there, it edits nothing. _commit_one then stages .gitmodules by force (force_stage, a git add -f), and adding a tracked path that is missing from the work tree stages its DELETION. git commit -- .gitmodules members/<P> family.yaml commits that deletion. So the remove commit takes every other member's section out of HEAD:
U=<openRepoShape checkout> # a clean checkout at 3ae0109
# Runs in bash on Linux (GNU coreutils). Nothing leaves the machine: every repository is a local bare one 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; 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
for P in Atlas Nova; do python3 $U/scripts/family.py add --family-root $H --member testorg/$P --local-remote-dir $R/remotes >/dev/null; done
rm .gitmodules # deleted in the work tree and not committed
echo "registration keys before: Atlas $(git config --get-regexp '^submodule\.members/Atlas\.' | wc -l), Nova $(git config --get-regexp '^submodule\.members/Nova\.' | wc -l)"
python3 $U/scripts/family.py remove --family-root $H --member Nova --dry-run | grep -E '^(gitmodules|registration) ' | cut -c1-120
python3 $U/scripts/family.py remove --family-root $H --member Nova 2>&1 | grep -E '^ committed|^REFUSED|^warning'; echo "remove: exit ${PIPESTATUS[0]}"
git show --stat --format='the commit: %s' HEAD | grep -E 'commit:|\|'
echo "HEAD holds .gitmodules: $(git cat-file -e HEAD:.gitmodules 2>/dev/null && echo yes || echo no); HEAD's gitlink for Atlas: $(git rev-parse --short=12 HEAD:members/Atlas)"
echo "registration keys after: Atlas $(git config --get-regexp '^submodule\.members/Atlas\.' | wc -l), Nova $(git config --get-regexp '^submodule\.members/Nova\.' | wc -l)"
python3 scripts/validate-family.py 2>&1 | grep -E '^(FINDING|REFUSED|family ok)' | cut -c1-150; echo "validate-family.py: exit ${PIPESTATUS[0]}"
git push -q origin HEAD:main # the commit as its pull request would land it
git clone -q --recurse-submodules $R/fam/Fam.git $R/clone; echo "recursive clone: exit $?"
echo "the clone's members/Atlas holds $(ls -A $R/clone/members/Atlas | wc -l) entries"
(cd $R/clone && python3 scripts/validate-family.py 2>&1 | grep -E '^(FINDING|REFUSED|family ok)' | cut -c1-150; echo "the clone's validate-family.py: exit ${PIPESTATUS[0]}")
(cd $R/clone && git submodule update --init -- members/Atlas 2>&1 | tail -1; echo "git submodule update --init -- members/Atlas: exit ${PIPESTATUS[0]}")Output at 3ae0109:
registration keys before: Atlas 2, Nova 2
gitmodules none: git reads no section of .gitmodules in the work tree as giving members/Nova, so none is removed
committed f3280ab136db: .gitmodules, members/Nova, family.yaml
remove: exit 0
the commit: Remove Nova from the Fam family
.gitmodules | 6 ------
family.yaml | 10 ----------
members/Nova | 1 -
HEAD holds .gitmodules: no; HEAD's gitlink for Atlas: fd5c45073641
registration keys after: Atlas 2, Nova 0
family ok: Fam (fam), 1 member(s)
validate-family.py: exit 0
recursive clone: exit 0
the clone's members/Atlas holds 0 entries
REFUSED member-uninitialized: members/Atlas is not an initialized submodule checkout, so neither the pinned digest nor the project it claims can be re
the clone's validate-family.py: exit 2
fatal: No url found for submodule path 'members/Atlas' in .gitmodules
git submodule update --init -- members/Atlas: exit 128
- The commit says it unmounted Nova, and its stat shows
.gitmodules | 6 ------: Atlas's section went too. - In the holder it ran in, nothing looks wrong. Atlas's registration is still in
.git/config,validate-family.pysaysfamily ok, andremoveexits 0. - Once the commit is pushed,
git clone --recurse-submodulesexits 0 and leavesmembers/Atlasempty. The clone's own gate refusesmember-uninitialized, exit 2, and the repair that refusal points towards dies withNo url found for submodule path 'members/Atlas' in .gitmodules. - The dry run (family.py
bump(andadd,remove) take no--dry-run, though the file's exit-code paragraph says a dry run prints and exits 0 andbump-leg.pyhas one, so the only way to see what a bump would pin is to run it: fetch, check the member out at--toand rewritefamily.yaml#326) saysgitmodules none: ... so none is removed, which is true ofgit rm, and nothing about the commit. It prints noregistrationline, thoughdeinitdropssubmodule.members/Nova.*from.git/config(2 keys to 0).deinitfinds the name through the index's.gitmoduleswhen the work tree has none;_registeredis asked only for the name_gitmodules_namesreads from the work-tree file.
Why.
add=[GITMODULES, MANIFEST]assumesgit rmhas just rewritten.gitmodules, so the file holds exactly the change this run made. With no work-tree file, the add stages whatever state the work tree is in, which is a deletion the person may not know about.validate-family.pythen has nothing to object to. 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_check_gitmodules_pathsreads the file and finds nothing to check when there is no file. No check asks whether each row's path has a section, so the holder's gate passes a HEAD a recursive clone cannot populate. (validate-family.py asks everymembers:row for its gitlink and never the reverse, so a gitlink undermembers/that no row names (a member mounted by hand withgit submodule add, or a row deleted by hand) and a.gitmodulessection whose path no row names (whatfamily.py removeleaves of two sections for one path, or a section for a case variant such asMembers/Atlas, whichaddcommits beside the member's own even after #317) all pass the holder's own gate, and the doctor calls the holder COMPLIANT #361, in progress in this lane, adds the other two directions: a gitlink no row names, and a section no row names.)- How a work-tree
.gitmodulesgoes missing is ordinary: agit checkout <rev> -- .from a revision before the firstadd, a bad conflict resolution, an editor or a tool that rewrote and lost it.removecannot know which.
What it should do. remove never commits a change to .gitmodules that it did not make. The decisions are the author's:
- Refuse, or commit only its own change.
- Refuse before the first git call when
.gitmodulesis in HEAD or the index and the work tree's copy is missing or differs from the index. Name the restore (git checkout -- .gitmodules) and leave the holder byte-identical. This is where 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's refusal sits, in progress in this lane. - Or commit only the section this member had: take it out of the index's blob and stage that blob, never the work-tree state.
- Refuse before the first git call when
- The dry run says what the real run will do in this state: the refusal, or the section it takes out. It names the registration
deinitdrops by looking the name up asdeinitdoes (the work tree, else the index, else HEAD). That is N9. - The gate. Whether
validate-family.pyshould find a row whose path no.gitmodulessection in HEAD gives, beside validate-family.py asks everymembers:row for its gitlink and never the reverse, so a gitlink undermembers/that no row names (a member mounted by hand withgit submodule add, or a row deleted by hand) and a.gitmodulessection whose path no row names (whatfamily.py removeleaves of two sections for one path, or a section for a case variant such asMembers/Atlas, whichaddcommits beside the member's own even after #317) all pass the holder's own gate, and the doctor calls the holder COMPLIANT #361's two checks, so that CI catches a holder already in this state. It could go here or in its own issue.
How to test. A holder with two members whose work-tree .gitmodules is deleted, then remove one of them:
- Under a refusal: exit 2, and the holder's snapshot is unchanged (HEAD,
ls-files -s -z,status --porcelain=v1 -z,family.yaml's bytes,.git/config'ssubmodule.*keys). - Otherwise: exit 0, and HEAD's
.gitmodulesholds exactly the other member's section. - Either way: push, then a fresh
clone --recurse-submodulespopulates the other member, and itsvalidate-family.pyexits 0. --dry-runprints the refusal, or the section and the registration line, and changes nothing.- Under decision 3: a holder whose committed
.gitmoduleslacks a row's section is a finding.
Verified at main 3ae0109 (scripts/family.py sha256 bf059598…).
Related: #326 / PR #345 (the review; the dry run's N9), #360 (remove over two sections for one path) and #365 (remove's rollback), both in progress in this lane in the same function, #361 (the validator's reverse checks, in progress), #317 (_check_gitmodules_paths), #405 (the same review's other real-run item, in bump).
Activity
brettheap commentedon Oct 14, 2026
CLAIMED — lane openRepoShape-5, session 50cede29-ab82-4ca3-9f67-d98e1a58c8d2@Eagle, 2026-10-14T22:34:09Z, for #407
Logged in
lanes/log/openRepoShape-5.mdat3ff36b7bfcc3fa5149006ed16ef805cc0911ff4b.The three reads (lane-collision-protocol Rule 1):
family.py: `add` and `remove` also refuse an untracked `.gitmodules`,…
family.py: `remove` again commits a clean `.gitmodules` the index mar…
Merge main 526cd2c (#582, #523) into fix/522-adopt-nondefault-leg-paths
family.py and validate-family.py: `remove` refuses a holder with no `…
validate-family.py and family.py: a gitlink no section of HEAD's `.gi…