Repository navigation
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
Description
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.gitexistence check on that literal path (:967-973), thengit 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:paththe 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:
- Refuse or ignore.
bumpandremoverefuse (member-path, naming the same rule) when the row'spath:is notmembers/<project>, through one definition of whatpath:must equal thatvalidate-family.pyandfamily.pyshare; or they never readpath:and computemembers/<project>asadddoes, leaving the validator to flag the mismatch. - 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.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's_commit_onechange covers the stage and not the commit; this decides the rest. - Whether the same check belongs before any other
family.pyverb that reads a row'spath:(addwrites it;siblingsand 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
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-4, session 3431c370-df80-4c11-a041-41bce42d8b38@Eagle, 2026-10-12T01:35:48Z, for #299
Logged in
lanes/log/openRepoShape-4.mdat9f8fa25681d823201aeb48cf0afb1fbac7358317.The three reads (lane-collision-protocol Rule 1):
family.py: `bump` and `remove` also refuse `member-path` for a row wh…
family.py: `_commit_one` commits with `:(literal)` on each path inste…
family.py: `_commit_one` drops the caller's `GIT_LITERAL_PATHSPECS` f…
family.py: `bump` and `remove` refuse `member-path` when a row's `pat…
Merge origin/main (6f4ce47: #302 via #318, #299 via #315) into fix/fa…
Merge origin/main (a1b3a4b) into fix/adopt-plan-quote-yaml-keywords
Merge origin/main (346079d) into fix/adopt-late-failure-notice
Merge origin/main (a1b3a4b: #299 via #315, #216 via #228, #260 via #316…
Merge origin/main 8cdca5e into fix/adopt-root-kept-symlinks: thirteen…
family.py: `add` refuses `member-duplicate-id` before it mounts anyth…
family.py: `add` refuses a `--member` whose owner or project does not…