Skip to content

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

Closed

Description

@brettheap

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:

  1. git submodule deinit -f (:2514);
  2. git rm -r -f (:2516);
  3. write_members (:2518);
  4. _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

Why.

What it should do. remove never commits a change to .gitmodules that it did not make. The decisions are the author's:

  1. Refuse, or commit only its own change.
  2. 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 deinit drops by looking the name up as deinit does (the work tree, else the index, else HEAD). That is N9.
  3. The gate. Whether validate-family.py should find a row whose path no .gitmodules section in HEAD gives, beside 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'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's submodule.* keys).
  • Otherwise: exit 0, and HEAD's .gitmodules holds exactly the other member's section.
  • Either way: push, then a fresh clone --recurse-submodules populates the other member, and its validate-family.py exits 0.
  • --dry-run prints the refusal, or the section and the registration line, and changes nothing.
  • Under decision 3: a holder whose committed .gitmodules lacks 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

  1. added this to the v1.1.0 milestone on Oct 14, 2026
  2. brettheap commented on Oct 14, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-5, session 50cede29-ab82-4ca3-9f67-d98e1a58c8d2@Eagle, 2026-10-14T22:34:09Z, for #407

    Logged in lanes/log/openRepoShape-5.md at 3ff36b7bfcc3fa5149006ed16ef805cc0911ff4b.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#407:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#407"`, kept where the row or its body names `#407`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `407` as a whole token:
       none
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions