Repository navigation
family.py: bump and remove refuse member-path when a row's path: is not members/<project> or its project: is not a directory name, and hand git every path literally, so a hand-edited path: members/* or project: . no longer makes remove unmount every member in one commit while dropping one row (#299) - #315
Merged
Merged
Conversation
…h:` is not `members/<project>` and take every path they hand git literally, so a hand-edited `path: members/*` no longer makes `remove --member Atlas` exit 0 with one commit that unmounts every member while dropping one row, and `path: .` no longer `git rm -r -f`s the holder's own files before dying on the missing manifest (#299) THE DEFECT. `cmd_bump` and `cmd_remove` read the member's path from the manifest row, `str(row.get("path") or f"{MEMBERS_DIR}/{project}")`, and handed it to git as it stood. Git reads a path as a pathspec, and nothing checked the field or passed `--literal-pathspecs`: - `path: members/*` made `remove --member Atlas` exit 0 with ONE commit that deleted every member's gitlink, `members/Atlas` and `members/Borg` alike, while dropping only Atlas's row. Borg was then in the manifest and not in the tree. - `path: .` made it `git rm -r -f` the holder's own files, then die on a `FileNotFoundError` reading the `family.yaml` it had just removed. - `bump` needed a second accident first: `member-uninitialized` wants a checkout with a `.git` at the literal path, so it took a directory named `members/*` as well as the hand edit. With one it exited 0: it fetched and checked `--to` out inside that clone, then staged and committed the clone as a NEW gitlink at `members/*` beside the rewritten row. The tool writes `path:` itself, as `members/<project>` and nothing else (`_member_row`), and `validate-family.py` has a rule for exactly that, `member-path`. But the validator runs after a commit, in CI and in the doctor, never in front of these two verbs, so they trusted the field it would refuse. THE THREE RULINGS (the issue's decisions, as the coordinator made them). 1. REFUSE, before any git call and any write. One new function, `_member_path(project, row=None)`, is the one definition of the path a member has. `_member_row` (the row `add` writes) calls it with no row, so `add` is unchanged; `bump` and `remove` call it with the row after their any-case lookup (#221, #206), so `project` is the row's own spelling. A row whose `path:` is not exactly `members/<project>` raises `Refusal("member-path", ...)`, the code the validator's finding uses for the same rule. The refusal names the row's value and the expected one, and its remediation says the tool writes that field itself: put the path back in `family.yaml`, or `git checkout -- family.yaml` if the edit is not committed. A row with no `path:` keeps defaulting; the comparison is exact, as the validator's is. `validate-family.py` and `repo_shape.py` are NOT changed: both ship into every holder, so a change there moves every pin. The validator keeps its own spelling of the rule, the second one. 2. LITERAL PATHSPECS, in the place git takes global options, on every git call in these two verbs that takes the member's path: `_nothing_to_record`'s `git diff`, `_commit_one`'s `git commit` (its stage was already literal through `force_stage`), and `remove`'s `git submodule deinit` and `git rm`. Proved on git 2.43.0 with `members/*` in a scratch holder: each of the four acts on the neighbours without the flag and does nothing with it, and `git submodule`, a command of its own, honours the global flag (it reaches it as `GIT_LITERAL_PATHSPECS`), so no per-pathspec `:(literal)` magic was needed. With ruling 1 the path is always `members/<project>`, so this is defence in depth that does not depend on the row being right. 3. SCOPE: `bump` and `remove` only. `add` builds its path from checked values; `siblings` and the validator only read the row. TESTS (`tests/test_family_member_path.py`, in `test_family.py`'s family and the offline environment of `test_family_add_refusals.py`: members from local bare remotes, a `gh` shim that logs and exits 97, an isolated git configuration). - `remove` and `bump` with Atlas's row hand-edited, uncommitted, to `members/*`, `.`, the neighbour's path, the member's own path with a trailing slash, and the member's path in another case: exit 2, the refusal text, `git ls-files -s -z` and `git status --porcelain=v1 -z` byte-identical, HEAD unchanged, the neighbour's gitlink, row and checkout still there, no `gh` call. - The same refusal with `subprocess.run` forbidden, so it is made before any git call and not merely before any write. - `bump` with a literal `members/*` checkout holding a `.git` refuses and leaves that clone's HEAD where it was (skipped on Windows, which refuses `*` in a file name; every other row needs no such directory). - One expectation moves in `tests/test_family_add_refusals.py`: the failed `git commit` an unwound `add` shows is now `git --literal-pathspecs commit ...`, because `_commit_one` is shared and `add`'s paths are literals either way. - The controls, the row the tool writes and a row with no `path:`, still exit 0 in ONE commit of exactly the paths each verb is for, and validate. - Each of the four git calls, with the glob `members/*`, reaches the neighbour without `--literal-pathspecs` (the premise) and does not with it. - Every call that names the path in a real `bump` and a real `remove` carries the flag among git's global options, the set of calls checked exactly. MUTANTS, each run on a scratch copy of the tree. - Refusal dropped, flags kept: the 10 refusal rows, the no-git-call rows, the literal-directory row and the one-definition row fail. `remove` with `members/*` still stops, at `deinit`, exit 2, nothing written; but `path: .` still dies on the `FileNotFoundError` with the whole tree staged for deletion, and `bump` with the literal `members/*` clone still exits 0 with the wrong commit, so the flags are no substitute for the refusal. - Refusal and every flag dropped (what main did): `remove` with `members/*` exits 0 with one commit whose `git show --stat` names `members/IRRS` and `members/IRSS`, 18 deletions, one row dropped. - Refusal dropped and `git rm`'s flag dropped alone: `deinit` stops the run first, so that flag is unreachable end to end. With `deinit` stubbed out to reach it, `git rm` without its flag stages the deletion of BOTH members (the literal `commit` then stops the commit); with it, exit 2 and the index is clean. - Each flag dropped alone (`deinit`, `rm`, `diff`, `commit`), refusal kept: the every-call test fails, and only it. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ose `project:` is `.` or `..`, because its `path:` is then equal to `members/<project>` and passed the comparison, so a hand-edited `project: .` with `path: members/.` no longer makes `remove` unmount every member in one commit and `project: ..` no longer stages the holder's own files for deletion (#299) The first commit of #299 refuses a row whose `path:` is not exactly `members/<project>`. A row hand-edited in BOTH fields, `project: .` and `path: members/.`, or `project: ..` and `path: members/..`, is equal to the path the tool computes for it, so it passed, and `remove` acted on a path that is not a member: - `project: .`: `remove --member .` exited 0 with ONE commit that deleted both members' gitlinks, `.gitmodules` and the manifest (18 deletions), because `git rm -r -f -- members/.` is the members directory itself. The literal-pathspecs flag does not help: a path that is a real directory is the same path literally. - `project: ..`: `git rm -r -f -- members/..` removed the holder's own files from the index, then `write_members` died on the `FileNotFoundError` of the `family.yaml` it had just deleted, exit 1, the tree staged for deletion. - `bump` was stopped by accident, not by design: `member-uninitialized` for `members/.`, and a `fatal: reference is not a tree` from the `git checkout` it ran in the holder's own root for `members/..`. `add` cannot write either row: it refuses the mount path as `member-path-taken` (probed: `add --member InkRouter/..` and `/.`), and `cmd_add` does not call `_member_path`, so `add`'s path and its refusal code are as they were. `_member_path(project, row)` now also refuses, under the same code, a row whose project is not a single directory name directly under `members/`: `.`, `..`, the empty name, or one with a `/`. The refusal says why, that the path would be `members/.` (the members directory itself) or `members/..` (the holder's root), not a member, and that no git command was run and nothing was written; its remediation says the tool never writes such a row, so restore `family.yaml` from git or remove the row by hand. The clause is made only when a row is given, so `_member_row`, which is what `add` writes, behaves as it did. `bump` and `remove` reach it through the call the first commit made; nothing in either verb changes. `MEMBER_RE` is NOT changed. It lives in `scripts/family.py` (line 104) and is read once, by `_checked_member`, which only `cmd_add` calls; `bump` and `remove` take `--member` through `checked_value` and `repo_basename` and never meet it, so tightening it would not have reached them. The validator's own `QUALIFIED_RE` (`templates/family-root/scripts/validate-family.py`, copied into every holder) already requires each segment to start with a letter or a digit. TESTS (`tests/test_family_member_path.py`): `remove` and `bump` against `project: .` with `path: members/.` and `project: ..` with `path: members/..` exit 2 with the refusal text, `git ls-files -s -z` and `git status --porcelain=v1 -z` byte-identical, HEAD unchanged, both members' gitlinks, rows and checkouts intact, and no `gh` call; the definition's own test covers `.`, `..`, the empty name and `a/b`, with and without a `path:`, and that no row means no refusal. MUTANT, the new clause dropped on a scratch copy: the four new rows and the definition's test fail, and the probe's wrong outcomes come back. `remove --member .` exits 0 with one commit deleting `members/IRRS` and `members/IRSS`, `.gitmodules` and the manifest's rows (4 files, 18 deletions); `remove --member ..` dies on the `FileNotFoundError` with the holder's files staged for deletion; `bump` falls through to the two accidents above. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 21 hours and 32 minutes by commenting |
Reviewer's GuideBump and remove now reject hand-edited or unsafe member paths before any Git or filesystem action, and pass member paths to every relevant Git command literally; extensive tests verify safety, unchanged valid behavior, and protection against glob and directory traversal effects. Sequence diagram for safe member path validation before bump or removesequenceDiagram
participant User
participant Family as family.py
participant Git
participant Filesystem
User->>Family: bump or remove
Family->>Family: _member_path(project, row)
alt path or project is unsafe
Family-->>User: Refusal(member-path)
Note over Family,Git: No Git command or write
else path is members/<project>
alt bump
Family->>Filesystem: checkout member
Family->>Git: git --literal-pathspecs diff
Family->>Git: git --literal-pathspecs commit
else remove
Family->>Git: git --literal-pathspecs submodule deinit
Family->>Git: git --literal-pathspecs rm
Family->>Filesystem: remove manifest row
Family->>Git: git --literal-pathspecs commit
end
end
Flow diagram for literal member path handlingflowchart TD
A[Read selected member row] --> B["_member_path(project, row)"]
B --> C{Exact members/<project> path and safe project name?}
C -->|No| D[Refuse with member-path]
C -->|Yes| E[Use members/<project>]
E --> F[Pass path to Git with --literal-pathspecs]
F --> G[Only selected member is checked, changed, or committed]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…ad of the global `--literal-pathspecs` so the holder's commit hooks no longer inherit `GIT_LITERAL_PATHSPECS`, the `.`/`..` refusal names a remedy that can work, `cmd_add` takes its path from `_member_path`, and `add` refuses a `.` or `..` segment as `member-malformed` so `add --member <org>/..` on a fresh holder no longer tells the human to stage the deletion of every file (#299) The review of 58aa4e3 found no MUST and two SHOULDs, and the coordinator ruled those, the one nit that matters and an out-of-scope finding in. 1. THE COMMIT'S PATHS ARE `:(literal)`, NOT GLOBAL. `git --literal-pathspecs` works by putting `GIT_LITERAL_PATHSPECS=1` in git's environment, and every child of `git commit` inherits it: the holder's `pre-commit`, `commit-msg` and `post-commit` hooks, its own or the operator's `core.hooksPath`. On git 2.43 a hook that picks the staged files with a glob (`git diff --cached --name-only -- '*.yaml'`) saw `family.yaml` on a plain commit and NOTHING under the flag, so a linter behind it checked nothing on every `add`, `bump` and `remove` commit. `_commit_one` now passes `-- :(literal)<path> ...` on the commit, and no global option. Proved on 2.43.0 on the form `git commit -q -F - -- <paths>`: `:(literal)members/*` is refused ("did not match any file(s)") while a neighbour's gitlink is staged, a path `git rm` has taken out of the index and the tree still commits (HEAD has it), and the hook sees the variable unset. `deinit`, `rm` and `diff` run no hooks and keep the global option; `force_stage`'s `add`, which is shared, is untouched. The failed commit an unwound `add` shows is now `git commit -q -F - -- :(literal).gitmodules :(literal)members/IRQS :(literal)family.yaml`, and `test_family_add_refusals.py` pins that. 2. THE `.`/`..` REFUSAL'S REMEDIATION. "Run the same command again" cannot succeed after either remedy (`--member .` then names no row), and "remove the row by hand" leaves the member's gitlink with no row, which the validator and the doctor both pass. It now says: restore `family.yaml` from git (`git checkout -- family.yaml` if the edit is not committed), or put the row's `project:` and `path:` back as the tool wrote them, then run the command again naming that member. 3. ONE DEFINITION. `cmd_add` spelled `members/<project>` itself, so the definition had two spellings in one file. It calls `_member_path(project)` now, with no row: the same string, the same behaviour. 4. `add --member <org>/..` ON A FRESH HOLDER. `MEMBER_RE`'s class admits the dot (for `.github`), so `<org>/..` matched, and its mount path `members/..` is the holder's root. With no `members/` directory, `_checked_mount_path` found the root's own files "in the index" under it and printed the remediation `git rm -r --cached -- members/..`, which stages the deletion of EVERY file the holder has (the reviewer pasted it on a scratch copy: 15 deletions). `_checked_member` now refuses a segment of `.` or `..`, owner or project, as `member-malformed`, add's existing argument refusal, before the member is read or anything is mounted, and its message says a directory reference is not a name and where the project would mount. `MEMBER_RE` (in `scripts/family.py`, read only there) is left as it was: `.github` stays legal. The `_member_path` docstring no longer says `add` refuses these as `member-path-taken`; that held only once `members/` existed. TESTS (`tests/test_family_member_path.py`, 28 -> 40 in the file). - A holder with a `pre-commit` hook that writes `GIT_LITERAL_PATHSPECS` and the staged files a `'*.yaml'` glob finds: through a real `add`, `bump` and `remove` it sees the variable unset and `family.yaml`. - The every-call recorder test now asserts `:(literal)` on each of the commit's paths, and the global option's absence, for the commit, and the global option on the other calls; the glob-reaches-a-neighbour test runs the commit in its magic form. - `add --member` of `<org>/..`, `<org>/.`, `../<Project>` and `./<Project>` is refused `member-malformed` on a fresh holder (no `members/`) and on one with members: exit 2, nothing on stdout, no clone, no `gh` call, the holder byte for byte as it was; names with a dot inside (`.github`, `a.b`) still pass. - The dot refusal's remediation text is pinned. MUTANTS, each on a scratch copy. - The global flag back on the commit: the hook test fails for all three verbs (the hook logs `literal=1` and its glob finds nothing), both every-call tests fail, and the pinned commit line in `test_family_add_refusals.py` fails; unmutated, all pass. - The `add` dot-segment refusal dropped: the eight `member-malformed` rows fail, and the fresh holder's `add --member InkRouter/..` prints the `git rm -r --cached -- members/..` remediation again. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rom its commit's environment, because that variable turns off the pathspec magic `:(literal)` is, so a caller who exports it no longer fails every `add`, `bump` and `remove` commit with `pathspec ':(literal)family.yaml' did not match` (#299) The re-check of 2f04085 found that `:(literal)` works only while pathspec magic is on. `GIT_LITERAL_PATHSPECS=1` turns magic off, so with it in the caller's environment git read `:(literal)family.yaml` as a file of that name, found none, and exited 1. `_commit_one` copies `os.environ` into the commit's environment, so the caller's value reached git: - `add` exited 2 with the failed commit shown, and unwound the mount; - `bump` and `remove` exited 2 with no rollback: the rewritten pin staged and the member checked out at `--to`, or the unmount and the dropped row staged and no commit; - main and 58aa4e3, whose commit used the global option, exited 0 in that environment, so this was a regression against both. Who has the variable set: a person or script that exports it as a safety default, or any `family.py` run from a hook or alias of a `git --literal-pathspecs` command, which exports it to its children. The fix is one line, `env.pop("GIT_LITERAL_PATHSPECS", None)`, on the environment `_commit_one` already builds for its commit, and only there. The commit's paths are literal by their `:(literal)` magic and need nothing from the environment; the hooks it runs then see the variable unset, as the hook test of the previous commit pins. The other pathspec variables (`GIT_GLOB_PATHSPECS`, `GIT_NOGLOB_PATHSPECS`, `GIT_ICASE_PATHSPECS`) leave magic on and are not touched; the reviewer found each harmless on 2.43. Wording: the `_commit_one` docstring said `deinit`, `rm` and `diff` "run no hooks". `git rm`, like `force_stage`'s `git add`, writes the index and so runs a `post-index-change` hook, which still sees `GIT_LITERAL_PATHSPECS=1`; it now says "no COMMIT hooks" and names that hook. The unused `global_options` helper in `tests/test_family_member_path.py` is removed. TEST (`tests/test_family_member_path.py`, 40 -> 43): with `GIT_LITERAL_PATHSPECS=1` exported to the run, `add`, `bump` and `remove` each exit 0 in ONE commit of exactly the paths each is for, leave nothing staged behind (`git status --porcelain=v1 -z` empty), and the holder validates. MUTANT, the `env.pop` dropped on a scratch copy: the three new rows fail with `error: pathspec ':(literal)family.yaml' did not match any file(s) known to git` on `git commit -q -F - -- :(literal)...`, exit 1; the other 40 pass. With the pop, all 43 pass. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
brettheap
added a commit
that referenced
this pull request
Oct 12, 2026
…s `_entry_findings` order sentence composed with #255's #313 (71b58a1, #262 and #263) makes `check` find a kept root path where the shape writes a directory or its `shape/` copy, through `_root_shape_findings` called from `_leg_findings`, and makes `shape_materialize` refuse a copy whose own path is taken. This branch appends each value `execute` writes into a file that no file can hold to `_entry_findings`. The one conflict is that function's docstring, the sentence naming the order of its findings: it keeps #313's words (each entry's `leg:`, the two mount paths and what the root keeps where the shape writes, then the source's own submodules) and appends this PR's clause, ", then each value `execute` writes into a file that no file can hold (#255)". Nothing else in the file conflicts: the return still reads `(findings + submodule_findings + unwritable_findings, unsafe_remediation + remediation + unwritable_remediation)`, and `_leg_findings` carries #313's findings inside `findings`. Main's c0ed615 (#308, family.py) comes with it and touches none of this branch's files. Main has since moved on to c7c02fa (#315, family.py); this merge stops at 71b58a1. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 12, 2026
This was referenced Oct 12, 2026
brettheap
added a commit
that referenced
this pull request
Oct 12, 2026
Main brings two commits since 71b58a1: - c7c02fa (#299 via #315): family.py's `bump` and `remove` refuse `member-path`; - 346079d (#216 via #228): `plan`'s follow-up `git grep` takes each name literally, and `_stage_the_split` adds with `git --literal-pathspecs`. It merged with no conflict. adopt-project.py and tests/test_adopt_ignored_shape_files.py changed on both sides. In each, the merge's diff against this branch is main's own diff (72 and 4 lines), and its diff against main is this branch's own diff (784 and 16 lines). Every other file is one side's. Main's hunks in adopt-project.py, by where they land: 1-7. `cross_leg_references`: names are literal (#216); the spec directory and each hit are spelled; `git --literal-pathspecs grep`; 8. after it, the new `_search_by_hand`; 9-10. `_stage_the_split`: its docstring, and `git --literal-pathspecs add -f`. Main's hunk in tests/test_adopt_ignored_shape_files.py is in `test_the_forced_paths_are_the_written_ones_in_posix_spelling`, which this branch does not change: the forced add is `git --literal-pathspecs add -f`. This branch changes none of these definitions. `_stage_the_split` runs inside `_commit_the_split`, after both legs are pushed, where this branch already reports any failure with the notice. No top-level name is bound twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py, scripts/shape_materialize.py, scripts/family.py or tests/conftest.py. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap
added a commit
that referenced
this pull request
Oct 12, 2026
…, #253 via #264, #302 via #318, #288 via #309, #279 via #310, #306 via #311, #334 via #336, #239 via #269, #243 via #268, #321 via #335) into fix/adopt-record-submodule-placement Brings in the twelve commits from c7c02fa to a1b3a4b on top of 71b58a1, merged by sha. No rebase. Among the files this branch changes: 346079d (#216) and e43fa95 (#243) change adopt-project.py; 34ecbc2 (#260) changes scripts/shape_materialize.py; 87373a6 (#306) changes shape-doctor.py and tests/test_shape_doctor.py; 7491530 (#288) changes AGENTS.md and tests/test_repo_hygiene.py. Two conflicts, each resolved keeping both sides: - adopt-project.py, the module docstring's `execute` paragraph. #243 ends "the root's only submodules are the two leg mounts, each at the commit pushed to its leg."; this branch follows "two leg mounts." with "The split's `project.yaml` records the leg each source submodule kept in a leg was answered into, under `submodules:`, for `shape-doctor.py` to check it against." The merge has #243's sentence, then this branch's, re-wrapped to the paragraph's width. The module docstring is not in `--help` (docs/cli.md holds neither sentence). - tests/test_repo_hygiene.py, `test_agents_md_is_short_enough_to_be_read`. Each side moved the AGENTS.md cap from 464 and wrote a paragraph saying why: #288 to 477, for thirteen lines, and this branch to 472, for eight. AGENTS.md itself merged cleanly and holds both: 485 lines, main's 477 plus this branch's +9 -1. The merge keeps #288's paragraph word for word, then this branch's, renumbered "2026-10-12: 477 -> 485" because it now follows #288's, and asserts `<= 485`. `git merge-tree --write-tree` of the two parents gives the same tree in every file but these two. Checked: - adopt-project.py: `_commit_the_split` was changed by both sides, in hunks that do not touch. #243 clones the assembly with `--template=`; this branch builds `append` with the `submodules:` record. The merged function holds both, and each side's text is unchanged. Every other definition one side changed is that side's text. - shape-doctor.py: #306 changes `validator_row` and `run_validator`. This branch's placement functions, F1's `gitmodules_the_record_answers` among them, are this branch's text. - No top-level name is bound twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py, scripts/shape_materialize.py, shape-doctor.py, validate-manifest.py, update-shape.py, scripts/family.py or scripts/bump-leg.py. Each merged module's names are the base's plus what either side added. - AGENTS.md is main's plus exactly this branch's changed lines. - The branch's diff against main is #189 alone: 12 files, +1694 -157. That is +1670 at 27d039b, plus F1's fix and test (+23), plus the one line the docstring re-wrap adds. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap
added a commit
that referenced
this pull request
Oct 12, 2026
…rints the `old -> new` row and its trailers without checking the member out or rewriting `family.yaml`, and `remove --dry-run` prints the row, gitlink, `.gitmodules` section and directory it would remove and changes nothing, so the module's `EXIT CODES` line is true of both (#326) (#345) `bump`, `add` and `remove` took no `--dry-run`, though the module's exit-code line said "1 nothing (a dry run prints and exits 0)", which was true of `init` and `siblings` only, and `bump-leg.py` has one. Seeing what a bump would pin meant running it: fetch, `git checkout` in the member, rewrite `family.yaml`, stage. Even #210's `nothing to do` cost the fetch and the checkout. The coordinator's rulings, as implemented: - `bump --dry-run` leaves after the fetch and before the checkout, the real run's first write, so every refusal made before that point is the same refusal, exit 2: the row, `--to`'s form, #315's `member-path` and `member-uninitialized`. It fetches, as the real run does. A fetch writes into the member's object store, which nothing the holder records, and the help and the docstring say so. After a failed fetch the closing line says `--to` was resolved from what the object store already held. `--to` is resolved as `git checkout` takes it (`^{commit}`), and a commit the member lacks is exit 2. The questions the real run asks after its checkout are also asked, of the member's objects: `project.yaml` through the same `_read_member_project` (`_member_id_at`), and `family.rewritten`. The digest is `tree_digest`, which reads objects, so it is the real run's. It prints `commit <old> -> <new>` and `tree`, `bump-leg.py`'s layout, then the gitlink `old -> new` and the whole row as `family.yaml` would carry it. The row comes from `_bumped_rows`, which the real run also calls. Exit 1 follows the real run's #210 rule, asked without the write: HEAD's gitlink is `--to`, and the rewritten manifest hashes (`git hash-object --path`, no `-w`) to HEAD's blob. A symlinked `project.yaml` is left to the real run, and the dry run says so. A member's local changes that `git checkout` would refuse are not asked, and the docstring says so. - `remove --dry-run` leaves after `member-path` and before `git submodule deinit`. `ls-files --error-unmatch` over the same literal pathspec fails where `deinit` fails, on git's same line. It is printed as a failed git command is printed, exit 2, never with `repo_shape`'s leg remediation; the dry runs ask git through `_git_bytes` for that reason. It asks `family-members-unrecognised` and prints the row, the gitlink, the `.gitmodules` section and the `.git/config` registration, and the directory. The section is named by git's own lookup over its work-tree reading of the file: a name keeps its last `path`; a name moving away takes the old path out of the path map; suspicious names are ignored. Every section of the name found is dropped. Where none is found it says none is removed, and it names the sections left that still give the path. The registration is printed only where the holder has one under that name. An unmerged `.gitmodules`, which only `git rm` refuses, is not asked. - `--trailer` with `--dry-run` prints the trailers after the row, worded as `bump-leg.py` words them, and never for `nothing to do`. - The `EXIT CODES` paragraph says which subcommands have a dry run, and new paragraphs say what each asks and what neither asks. `bump`'s and `remove`'s `--help` gained `--dry-run`, and `docs/cli.md` is regenerated. `add` gets none here. Its dry run follows the three changes to `cmd_add` in flight (#317, #318, lane 3's #330), and neither `cmd_add` nor any function only it calls is touched. AGENTS.md is unchanged; a line in its family section is a follow-up. tests/test_family_dry_run.py, over `test_family.py`'s local-bare-remote family, checks "changes nothing" as bytes: the index, `status`, `family.yaml`, `.gitmodules`, `.git/config`, the member's HEAD, the work tree and the holder's `.git/objects`. Then the real run writes exactly what the dry run printed. Covered: - a bump previewed and then made; - `--to` the pin; - both #210 repairs; - trailers printed and then committed; - six refusals compared stderr for stderr with the real run; - a commit the member lacks; - a failed fetch; - `member-not-a-project` at `--to`; - a linked manifest; - a remove previewed and then made, registration included; - a gitlink the index does not hold; - four `.gitmodules` hand edits, each compared with a real remove; - the unrecognised manifest; - in process, no git command that writes (`hash-object -w` included). Mutants, each killed by the test that must catch it: - the dry-run return moved after the checkout (`member HEAD changed`); - `write_members` before the return (`family.yaml changed`); - remove's return after `git rm` (`index changed`); - no `--error-unmatch` (exit 0 where 2); - `hash-object -w`; - a name keeping its first path; - a suspicious name read like any other. Part of #326 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap
added a commit
that referenced
this pull request
Oct 12, 2026
… of main's commits merge with no conflict, among them #243's template-less clone of the assembly and #216's literal `git grep` in adopt-project.py, and #260's one-pass `render` in shape_materialize.py origin/main moved 71b58a1 -> 8cdca5e. It was merged by sha, as the coordinator asked for e43fa95 or later: - c7c02fa (#299 via #315), scripts/family.py; - 346079d (#216 via #228), adopt-project.py: `plan`'s follow-up `git grep`; - 34ecbc2 (#260 via #316), scripts/shape_materialize.py: `render` substitutes in one pass; - 86da808 (#253 via #264), scripts/bump-leg.py; - 6f4ce47 (#302 via #318), scripts/family.py; - 7491530 (#288 via #309), AGENTS.md, README, docs and scaffold-project.py's --help; - 7591455 (#279 via #310), tests/conftest.py: `git()` takes its default identity as `-c` config; - 87373a6 (#306 via #311), shape-doctor.py; - 8a14ac9 (#334 via #336), tests/test_update_shape_landing.py; - c1b6732 (#239 via #269), update-shape.py; - e43fa95 (#243 via #268), adopt-project.py: the assembly root is cloned with `--template=`, and the verification refuses a mount whose gitlink is not the commit pushed to its leg (`adopt-mount-commit`); - a1b3a4b (#321 via #335), tests/test_git_output_decoding.py; - 8cdca5e (#295 via #312), the template's scripts/validate-pins.py. `git merge-tree` and this merge give the same tree, 07e6b49. adopt-project.py, scripts/shape_materialize.py and tests/test_adopt_ignored_shape_files.py auto-merge. In the last of these main's 346079d and this branch's 86a5e30 change different cases. Checked on the merged tree: - AST (fin183g/names.py, base 71b58a1): no top-level name is bound twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or scripts/shape_materialize.py, and none is loaded but bound nowhere. Main changed the signature of no function this branch calls. - Every sentence 86a5e30 changed is still there: in `_through`, in `_link_parts`, in `_verify`'s `_resolve` (which e43fa95's `adopt-mount-commit` leaves as it was), and in the three test files. - `_commit_the_split` now clones the assembly with `--template=`. The next commit, F3, reads what that clone holds in its own `.git`, which is why F3 comes after this merge. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap
added a commit
that referenced
this pull request
Oct 12, 2026
… begin with a letter or a digit as `member-malformed` before the member is read, so `testorg/_Zed`, `testorg/.Zed`, `testorg/-Zed`, `_org/Zed` and `.org/Zed` no longer exit 0 with a commit that the holder's own `validate-family.py` fails as `member-repository` (#366) (#399) `MEMBER_RE` and the validator's `QUALIFIED_RE` shared one character class; the validator also requires each segment's first character to be a letter or a digit. `add` writes `--member` verbatim as the row's `repository:`, so a name the looser pattern admitted was mounted, pinned and committed with exit 0, and the gate the holder ships then failed the commit `add` had just reported. `MEMBER_RE` now takes `QUALIFIED_RE`'s shape, refused as `member-malformed` before the member is read. `_checked_member`'s `.`/`..` clause is kept for its more specific sentence and read first, so it is still reachable. The rule is pinned rather than moved: `family.py` spells it a third time, because `repo_shape.py` is a copied file and a constant moved there would reach an existing holder only through `update-shape.py`, and `tests/test_family_member_name.py` reads every spelling and fails when they differ. The validator's rule is unchanged. The general sentence states the rule, and `--member=-org/Zed`, once refused later as `unsafe-value`, is `member-malformed` now. This narrows what #299 (PR #315) deliberately left alone: its comment kept `MEMBER_RE`'s class loose 'for `.github`', and its writer named this tightening as the follow-up. `testorg/.github`, and an owner of `.github`, are refused now; the validator refused both already, so no holder could keep such a row. Closes #366 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Lane: openreposhape-4 (openRepoShape-4)
Claim: #299 (comment)
What and why
Four commits on one branch, none rewritten: the refusal and the literal pathspecs (1e42241), the
./..project clause (58aa4e3), the first review's fix round (2f04085), and the second review's fix (8f8eb92).family.py bumpandremovetook the member's path from the manifest row'spath:and handed it to git as it stood: a pathspec, with its globs, and no--literal-pathspecs. The tool writes that field itself, asmembers/<project>and nothing else, so a different value is a hand edit; but nothing asked.path: members/*:remove --member Atlasexited 0 with ONE commit that deleted every member's gitlink while dropping only Atlas's row.path: .: itgit rm -r -f'd the holder's own files, then died on aFileNotFoundErrorreading thefamily.yamlit had just removed.bumpwith a directory literally namedmembers/*holding a.git: exit 0, the clone checked out at--toand committed as a NEW gitlink atmembers/*.validate-family.pyalready has a rule for it,member-path, but it runs after a commit, never in front of these two verbs.The rulings
_member_path(project, row=None), is the one definition of the path a member has._member_row(whataddwrites) andcmd_addcall it with no row;bumpandremovecall it with the row, after their any-case lookup (family.py:addrefuses a case-variant of a member the family already pins asmember-already-presentnaming the row that pins it,bumpandremovefind a member in any case, and validate-family.py refuses two rows with oneid, so--member inkrouter/irrsbesideInkRouter/IRRSno longer mounts one project twice past both gates andbump --member irssno longer recommends that add (#206) #221, family.pyaddcompares--memberwith the existing rows case-sensitively, so--member inkrouter/irrson a family that already pins InkRouter/IRRS mounts the same repository again asmembers/irrs, both gates pass a family that pins one project twice, andbump --member irss's refusal recommends exactly that add #206), soprojectis the row's own spelling. A row whosepath:is not exactlymembers/<project>raisesRefusal("member-path", ...), naming the row's value and the expected one and saying the tool writes that field itself: put the path back, orgit checkout -- family.yamlif the edit is not committed. A row with nopath:keeps defaulting. The comparison is exact, as the validator's is._nothing_to_record'sgit diffandremove'sgit submodule deinitandgit rmcarry the global--literal-pathspecs;git submoduleis a command of its own and honours it (it reaches it asGIT_LITERAL_PATHSPECS). The stage isforce_stage's, already literal. The commit is spelled differently, below. Proved on git 2.43.0 withmembers/*in a scratch holder: each call acts on the neighbours without the spelling and does nothing with it. With ruling 1 the path is alwaysmembers/<project>, so this is defence in depth that does not depend on the row being right.bumpandremove.siblingsand the validator only read the row;add's path is checked, and now also comes from_member_path.The second spelling
templates/family-root/scripts/validate-family.pykeeps its own spelling of the rule (_check_member_repository_and_path,member-path). It andscripts/repo_shape.pyship into every holder, so sharing the rule would have moved every holder's pin for the refusal of a hand edit; neither file is changed.family.pyhas one definition, and the validator has the other.A project that is not a directory name (58aa4e3)
A row hand-edited in BOTH fields,
project: .withpath: members/., orproject: ..withpath: members/.., is equal tomembers/<project>, so it passed the comparison.remove --member .then exited 0 with ONE commit deleting both members (members/.is the members directory itself; the literal flag does not help, a real directory is the same path literally), and--member ..staged the holder's own files for deletion and died on the sameFileNotFoundError.bumpwas stopped only by accident._member_path(project, row)also refuses, under the samemember-pathcode, a row whose project is not a single directory name directly undermembers/(.,.., the empty name, one with a/), saying why. Only a row is checked, so whataddwrites behaves as before.Fix round on the review of 58aa4e3 (2f04085)
The review found no MUST and two SHOULDs; four changes, each ruled in.
:(literal), not the global flag.git --literal-pathspecsworks by puttingGIT_LITERAL_PATHSPECS=1in git's environment, and every child ofgit commitinherits it, including the holder's commit hooks. On git 2.43 apre-commithook that picks staged files with a glob (git diff --cached --name-only -- '*.yaml') sawfamily.yamlon a plain commit and nothing under the flag, so a linter behind it checked nothing on everyadd,bumpandremovecommit._commit_onenow passes-- :(literal)<path> ...and no global option. Proved on 2.43.0 on the commit's own form::(literal)members/*is refused while a neighbour's gitlink is staged, a pathgit rmtook out of the index and the tree still commits, and the hook sees the variable unset.deinit,rmanddiffrun no commit hooks and keep the global option. (git rm, likeforce_stage'sgit add, writes the index, so apost-index-changehook still sees the variable after them; that predates this PR.)./..remediation can now work. "Run the same command again" cannot succeed after either old remedy, and "remove the row by hand" leaves the member's gitlink with no row, which the validator and the doctor both pass. It says: restorefamily.yamlfrom git (git checkout -- family.yamlif the edit is not committed), or put the row'sproject:andpath:back as the tool wrote them, then run the command again naming that member.cmd_addspelledmembers/<project>itself; it calls_member_path(project)now (no row: the same string, the same behaviour).addrefuses a.or..segment asmember-malformed.MEMBER_RE's class admits the dot (for.github), soadd --member <org>/..matched, and its mount pathmembers/..is the holder's root. On a fresh holder, with nomembers/,member-path-takenfound the root's own files "in the index" under it and printed the remediationgit rm -r --cached -- members/.., which stages the deletion of every file the holder has (15 deletions on a scratch copy)._checked_membernow refuses an owner or project segment of.or..asmember-malformed, add's existing argument refusal, before the member is read or anything is mounted, and says why. This replaces the earlier claim here and in a docstring thataddrefuses these asmember-path-taken: that held only oncemembers/existed.MEMBER_RElives inscripts/family.py:104, is read only there, and its character class is unchanged.One expectation moves:
tests/test_family_add_refusals.pypins the exactgit commitline an unwoundaddshows, nowgit commit -q -F - -- :(literal).gitmodules :(literal)members/IRQS :(literal)family.yaml.Fix round on the re-check of 2f04085 (8f8eb92)
:(literal)is pathspec magic, andGIT_LITERAL_PATHSPECS=1turns magic off. With it exported in the caller's environment (a safety default, orfamily.pyrun from a hook or alias of agit --literal-pathspecscommand, which exports it to its children), git read:(literal)family.yamlas a file of that name and found none, so everyadd,bumpandremovecommit failed witherror: pathspec ':(literal)family.yaml' did not match:addunwound,bumpandremoveexited 2 with the pin or the unmount left staged and no rollback. Main and 58aa4e3 exited 0 in that environment, so this was a regression against both._commit_onenow drops that one variable from its own commit's environment (env.pop("GIT_LITERAL_PATHSPECS", None)on the environment it already builds), and only there: the commit's paths are literal by their magic and need nothing from the environment, and the other pathspec variables leave magic on and are untouched.The
_commit_onedocstring now says the other calls "run no commit hooks" and names thepost-index-changehook thatrmandforce_stage'saddstill feed the variable to. The unusedglobal_optionstest helper is removed.Not in this PR: no check reports a gitlink under
members/that no row names (a validator follow-up, next to #300).Tests
tests/test_family_member_path.py(43 tests), intest_family.py's family andtest_family_add_refusals.py's offline environment (members from local bare remotes, aghshim that logs and exits 97, an isolated git configuration):removeandbumpwith the row hand-edited, uncommitted, tomembers/*,., the neighbour's path, the member's own path with a trailing slash, and the member's path in another case: exit 2, the refusal text,git ls-files -s -zandgit status --porcelain=v1 -zbyte-identical, HEAD unchanged, the neighbour's gitlink, row and checkout still there, noghcall.removeandbumpagainstproject: .withpath: members/.andproject: ..withpath: members/..: the same refusal and the same unchanged holder, with the new remediation text pinned.subprocess.runforbidden: made before any git call, not merely before any write.bumpwith a literalmembers/*checkout holding a.gitrefuses and leaves that clone's HEAD where it was (skipped on Windows, which refuses*in a file name, with the reason written at the skip; every other row needs no such directory).path:, still exit 0 in ONE commit of exactly the paths each verb is for, and validate.pre-commithook that writesGIT_LITERAL_PATHSPECSand the staged files a'*.yaml'glob finds. Through a realadd,bumpandremoveit sees the variable unset andfamily.yaml. The hook issh, which git runs on every OS (the same as the hook intest_a_commit_that_fails_unwinds_the_mount_and_the_pin).GIT_LITERAL_PATHSPECS=1exported to the run,add,bumpandremoveeach exit 0 in ONE commit of exactly the paths each is for, leave nothing staged behind, and the holder validates.add --memberof<org>/..,<org>/.,../<Project>and./<Project>is refusedmember-malformedon a fresh holder (nomembers/) and on one with members: exit 2, nothing on stdout, no clone, noghcall, the holder byte for byte as it was;.githubanda.bstill pass_checked_member.members/*, reaches the neighbour in its plain spelling (the premise) and does not in the tool's (global flag;:(literal)for the commit): it runs on each CI OS's own git.bumpand a realremoveis checked, the set exact: the global flag ondeinit,rm,diffand the stages, and:(literal)on each of the commit's paths with the global option absent from it.Run on Linux, git 2.43.0, on head 8f8eb92:
tests/test_family.py tests/test_family_add_refusals.py tests/test_family_add_excludes.py tests/test_family_member_path.py tests/test_cli_reference.py tests/test_repo_hygiene.py, 265 passed, none skipped.docs/cli.mddoes not move (no--helptext changes).Mutants, each on a scratch copy of the tree:
removewithmembers/*still stops atdeinit(exit 2, nothing written), butpath: .still dies on theFileNotFoundErrorwith the whole tree staged for deletion andbumpwith the literalmembers/*clone still exits 0 with the wrong commit: the flags are no substitute for the refusal.removewithmembers/*exits 0 with one commit whosegit show --statnamesmembers/IRRSandmembers/IRSS(18 deletions) and drops one row.git rm's flag dropped alone:deinitstops the run first, so that flag is not reachable end to end. Withdeinitstubbed out to reach it,git rmwithout its flag stages the deletion of both members; with it, exit 2 and the index is clean.deinit,rm,diff), refusal kept: the every-call test fails, and only it../..clause dropped: the four dot rows and the definition's test fail, and the wrong outcomes come back (remove --member .exits 0 with one commit deleting both members, 18 deletions;remove --member ..dies on theFileNotFoundError).literal=1, and its glob finds nothing), both every-call tests fail, and the pinned commit line intest_family_add_refusals.pyfails. With the fix all pass.env.popdropped: the three caller-environment rows fail witherror: pathspec ':(literal)family.yaml' did not match any file(s) known to giton the commit, exit 1; the other 40 of the file pass. With the pop, all 43 pass.adddot-segment refusal dropped: the eightmember-malformedrows fail, and the fresh holder'sadd --member InkRouter/..prints thegit rm -r --cached -- members/..remediation again.Closes #299
🤖 Generated with Claude Code
Summary by Sourcery
Protect family member operations from unsafe manifest paths and directory references while ensuring all affected git commands treat member paths literally.
Bug Fixes:
bumpandremovefrom acting on hand-edited member paths or project names that could target multiple members or the holder itself..and..member segments duringaddbefore any repository access or filesystem changes.Enhancements:
addand consumed bybumpandremove.Tests: