Skip to content

family.py: bump and remove take the git pathspec from the manifest row's path: with no check and no --literal-pathspecs, so a hand-edited path: members/* makes remove --member Atlas exit 0 with ONE commit that unmounts every member while dropping only Atlas's row, and path: . makes it git rm -r -f the holder's own files and die on a FileNotFoundError, though validate-family.py already has a rule, member-path, for what path: must equal #299

Closed

Description

@brettheap

Lane: openreposhape-4 (openRepoShape-4)

Found by this lane's adversarial review of #242's branch (head 289e542; its nit N5, which said the commit body's no-glob argument "holds for add only"), and re-verified on main d94d155 (#232's squash) by reading and by two probes on a holder with two members, local bare remotes, gh a shim that was never called. It is pre-existing and not caused by #242. One correction to that review: it said validate-family.py does not check path: either. It has a rule for it (member-path, templates/family-root/scripts/validate-family.py:253-258, below), but neither command runs the validator before it acts. Not claimed: bump with a glob in path: (it needs a checkout at that literal path, below; read, not probed), and add (its path is built from MEMBER_RE and SAFE_ARG_RE, which admit no * ? [ \, per #242's review; not re-checked here).

What is wrong. cmd_bump and cmd_remove read the member's path from the row and hand it to git as it stands:

  • bump: path = str(row.get("path") or f"{MEMBERS_DIR}/{project}") (scripts/family.py:965), member = family.root / path (:966) and a .git existence check on that literal path (:967-973), then git add -- <path> (:1005), _nothing_to_record(root, [path, MANIFEST]) (git diff --quiet ... -- <paths>, :938), and _commit_one(..., [path, MANIFEST]) (git add -- at :368, git commit -q -F - -- <paths> at :370).
  • remove: path the same way (:1050), git submodule deinit -f -q -- <path> (:1055), git rm -r -q -f -- <path> (:1056), then _commit_one (:1059). There is no existence check on the literal path.

Nothing checks path: (only project goes through checked_value) and no call passes --literal-pathspecs, so each of those reads path: as a pathspec, with its globs and its magic. remove is the dangerous one, because it has nothing in front of git:

# $U: a clean checkout at d94d155. A holder $H with members Atlas and Borg: both scaffolded into local bare remotes, `family.py init`, `family.py add` twice.
# Atlas's row in family.yaml is hand-edited to `path: members/*` (line 87 of 97), and the edit left uncommitted. The git environment is isolated.
python3 $U/scripts/family.py remove --family-root $H --member Atlas
#   removed Atlas (testorg/Atlas) from members/*
#   committed 8e49a7a8f496: .gitmodules, members/*, family.yaml                         exit 0
git show --stat --format='%h %s' HEAD
#   8e49a7a Remove Atlas from the Fam family
#    .gitmodules   |  6 ------
#    family.yaml   | 10 ----------
#    members/Atlas |  1 -
#    members/Borg  |  1 -                                                               <- not the member that was named
python3 $H/scripts/validate-family.py --root $H                                         # Borg's row is still in family.yaml
#   REFUSED member-gitlink-absent: no gitlink (mode 160000) recorded at 'members/Borg'. Borg is named in family.yaml but this repository does not record it as a submodule.
#   exit 2
# the control: the same holder with no hand edit
python3 $U/scripts/family.py remove --family-root $H2 --member Atlas                    # .gitmodules 3 -, family.yaml 10 -, members/Atlas 1 -   (one member)
python3 $H2/scripts/validate-family.py --root $H2                                       # family ok: Fam (fam), 1 member(s)
# the widest spelling, the same holder with `path: .`
python3 $U/scripts/family.py remove --family-root $H3 --member Atlas
#   Traceback (most recent call last):
#     ...
#     File "$U/scripts/family.py", line 198, in write_members
#       text = self.path.read_text(encoding="utf-8")
#   FileNotFoundError: [Errno 2] No such file or directory: '$H3/family.yaml'            exit 1
git -C $H3 status --short | wc -l                                                       # 18: staged deletions; the work tree is empty but for members/

bump is safer only by accident: it refuses member-uninitialized unless a checkout with a .git sits at the LITERAL path members/* (:966-967), so it needs a hand edit and that directory, as #242's review said; once there, git add, git diff and git commit read the same text as a pathspec.

Why. The tool writes path: itself, as members/<project> and nothing else (_member_row, :250), and validate-family.py has a rule for exactly that: when path: is not members/<project> it records a member-path finding, "Atlas: path is 'members/*', expected 'members/Atlas'" (:253-258). On the hand-edited holder that finding is never printed, because the same run refuses first (REFUSED member-uninitialized: members/* is not an initialized submodule checkout, exit 2; the doctor's family row quotes it); and the validator runs in make validate, in CI and in the doctor, after a commit, never in front of family.py's own verbs; bump and remove are the documented way to change a member (AGENTS.md:365-370), and they trust the field the validator would refuse. A hand edit is outside AGENTS.md's supported path ("Do not hand-edit family.yaml's members: block", AGENTS.md:367-368), so this needs one; the refusal costs one comparison. #242's branch (289e542, not on main) makes _commit_one's git add literal (:386 there) and leaves the commit (:389) and remove's deinit and rm (:1692-1693) as pathspecs.

What it should do. bump and remove act on the one path a member can have, or refuse. The decisions are the author's:

  1. Refuse or ignore. bump and remove refuse (member-path, naming the same rule) when the row's path: is not members/<project>, through one definition of what path: must equal that validate-family.py and family.py share; or they never read path: and compute members/<project> as add does, leaving the validator to flag the mismatch.
  2. Literal pathspecs. Every git call that takes the path gets git --literal-pathspecs (add, diff, submodule deinit, rm, commit), whichever way decision 1 goes: a path that is a literal is the property that does not depend on the field being right. family.py add runs git submodule add without --force (and _commit_one a plain git add), so under an operator's global excludes naming members or 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's _commit_one change covers the stage and not the commit; this decides the rest.
  3. Whether the same check belongs before any other family.py verb that reads a row's path: (add writes it; siblings and the validator read it), or only these two.

How to test. The probes above as tests, in the style of tests/test_family.py: remove with path: members/* and with path: . refuses (no commit, the tree and the index unchanged, Borg's gitlink still there), and bump with a literal directory named members/* holding a .git does not commit anything other than that member. The control (an unedited row) keeps exiting 0 with one commit.

Related: #242 (its review's N5; its commit body's no-glob argument is for add), #221 and #206 (bump and remove finding a member in any case), #210 (the bump change that added bump's own git add -- <path>), validate-family.py's member-path.

Activity

  1. brettheap commented on Oct 12, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-4, session 3431c370-df80-4c11-a041-41bce42d8b38@Eagle, 2026-10-12T01:35:48Z, for #299

    Logged in lanes/log/openRepoShape-4.md at 9f8fa25681d823201aeb48cf0afb1fbac7358317.

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

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#299:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#299"`, kept where the row or its body names `#299`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `299` 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

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions