Repository navigation
validate-repository-naming.py, repo_shape.py, validate-pins.py: a bare --link-source is the first chain link's tree alone, and a source that names no path is refused (#441, #442) - #576
Conversation
…bare `--link-source` is the tree of the chain's first link and no other's, so one tree no longer answers for two links and calls a three-link chain that holds broken, and `--link-source openXdox=` or `--pin-source openXdox=` is refused instead of reading the current directory (#441, #442) #441. `link_pins_from_trees` offered its `default_source` (the naming gate's bare `--link-source`, the scaffold's `--pin-source`) to every link of a recorded chain. In `openXdox → openYdox → openInk` with one bare tree, `openYdox` was read from `openXdox`'s `project.yaml`, which declares `openYdox` and not `openInk`, so `codexInk` got a FINDING, exit 1, for a chain that holds, and the scaffold refused it as `chain-not-declared`. Now the bare value is offered to the first link alone: the pin the project holds, and the one tree a person who typed one path has to hand. A later link is read from its own `LINK=PATH`, `SHAPE_PIN_SOURCE_<LINK>` or a checkout beside the project, or is declared-unverified. The two-link case reads exactly as before. In the naming gate, `LinkSources` finds the first link of every chain a name will read before any is read, and offers the bare value only while there is one, so it is never counted as the answer for two links; a bare value no link took is a NOTE saying why. `--help`, the module docstring, `resolve_link_source`'s rule 4, the scaffold's `_declaration` docstring and README's lookup order say so; `docs/cli.md` is regenerated, and the handbook footer names the README change and its new blob (the page does not carry that sentence). #442. `openXdox=` with nothing after the `=` was `Path("")`, which is `.`. From a project root the naming gate read the project's own manifest as `openXdox`'s and called the chain broken; `validate-pins.py` asked the project root for the pinned commit and blamed `.`. Both tools now refuse a keyed value with no path, and an empty value, naming the key: `REFUSED link-source-no-path` and `REFUSED pin-source-no-path`, exit 2. A typed `.` still means the current directory. Tests: tests/test_link_source_chain_defaults.py (20), 16 of which fail on bfed143. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndbook.html takes main's footer, and its three `The three legs` markers move to that README section's new hash, so the handbook check #551 put on main passes with this branch's README edit (#441) #551 (5e59e3b) ended the footer's history list and replaced the one README blob with a hash per README section, recorded at the top of each page part that reads it. This branch had added a footer clause and the whole-file blob for its README edit, which conflicted. Resolved to main's footer, unchanged. The README edit changes one segment, `The three legs` (6ebed796 -> 39ec2c23, `readme_fingerprint` in tests/test_repo_hygiene.py). Three parts of the page record reading it: the masthead, `Reading private legs in CI`, and `The three legs, and naming`. None of them carries the sentence that changed (the lookup order for a chain link's tree, where a bare `--link-source` is now the first link's tree alone); the page names no `--link-source` at all. So the three markers move to the new hash and no page text changes. README.md stays at 1422 lines. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-5, 2026-10-14T23:34Z) @codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Reviewer's GuideThe PR changes chain source resolution so a bare path can verify only a chain’s first link, avoids ambiguous reuse across chains with different first links, and explicitly refuses empty link/pin source values instead of reading the current directory; documentation and regression tests are updated accordingly. Sequence diagram for first-link-only source resolutionsequenceDiagram
participant Validator as NamingValidator
participant Resolver as link_pins_from_trees
participant Source as resolve_link_source
participant Tree as FirstLinkTree
participant LaterTree as LaterLinkSources
Validator->>Resolver: link_pins_from_trees(chain, default_source)
loop Each chain link
Resolver->>Source: resolve_link_source(link, default_source)
alt First link
Source->>Tree: Read bare source as first link tree
Tree-->>Source: Declaration or unreadable
else Later link
Source->>LaterTree: Read keyed, environment, or adjacent source
LaterTree-->>Source: Declaration or unavailable
end
Source-->>Resolver: Source result
end
Resolver-->>Validator: Link declarations and warnings
Sequence diagram for refusing empty source pathssequenceDiagram
actor User
participant Tool as NamingValidatorOrValidatePins
participant Sources as LinkSourcesOrPinSources
participant Main as ExistingMainHandler
User->>Tool: --link-source LINK= or --pin-source LINK=
Tool->>Sources: Parse source values
Sources->>Sources: Detect empty path
Sources-->>Main: Refusal link-source-no-path or pin-source-no-path
Main-->>User: Exit 2 with remediation
Flow diagram for ambiguous bare source handlingflowchart TD
A[Read chains used by this run] --> B[Find first link of each chain]
B --> C{One distinct first link?}
C -->|Yes| D[Offer bare PATH to that first link only]
C -->|No| E[Do not offer bare PATH]
D --> F[Record unused-source note if a specific source wins]
E --> G[Ask for --link-source LINK=PATH]
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…ead, so a bare `--link-source` still verifies the chain beside it, and `--link-source`'s help says a bare PATH is read for neither link when the chains begin at two (#441) A chain of one link (`--referent-chain openInk`) has no holder and reads no declaration, but `_first_links_read` and `LinkSources.link_pins` counted its only link as a first link. A run that also read `openXdox -> openQuill` then had two first links, the bare `--link-source <openXdox tree>` went to neither, and `codexQuill`, which bfed143 verified through it, was declared-unverified with a NOTE that named `openInk` as a first link. Both places now count `chain[0]` only when the chain has more than one link. Two tests: that run, which is verified with no WARNING and no NOTE; and one project whose two legs begin at `openXdox` and `openYdox`, which gets the two WARNINGs and the two-first-links NOTE. The second guards the project half of `_first_links_read`: without it, the first classification pass counted the bare value used while the pass that prints said it was unverified, and the NOTE went missing. `--link-source`'s help now says a bare PATH is read for neither link when the chains read begin at two different links, and that a later link is read from its own PRODUCT=PATH, its SHAPE_PIN_SOURCE_<PRODUCT> or a checkout beside the project. docs/cli.md is regenerated. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…stdin with its first link keyed and a bare value unused is held at the one-link boundary of `link_pins`, so counting only chains of three links or more no longer passes the tests (#441) `LinkSources.link_pins` counts a chain's first link only when the chain has more than one link. Changing that `> 1` to `> 2` passed the whole new file and tests/test_naming_policy.py, yet it changes the output for a name read from stdin whose chain has TWO links: the first-link list is empty, and the NOTE for an unused bare value reads `a bare PATH is the tree of a chain's first link alone, , which had a more specific source`. New test `test_a_two_link_chain_read_from_stdin_counts_its_first_link`: stdin `codexInk`, `--referent-chain openXdox,openInk`, `openXdox=<tree>` keyed and a second tree bare. It asserts the whole stderr, the one NOTE saying each link whose own declaration is read had a more specific source. The existing three-link stdin test and the new one share a helper, `stdin_naming`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d as `pin-source-no-path` before anything exists, so an empty value is no longer read as the directory the tool runs in (#540) Lane 5 found this beside #442 (#570 issuecomment-6180853841), and lane 2 took it into this PR (issuecomment-6180905220). Both tools took `--pin-source` as `type=Path`, and `Path("")` is `Path(".")`. So with `--pin-source ''`: - run from inside a clone with a `--pin`, the dry run, the real scaffold and `plan` pinned that clone's digest, exit 0; - run from anywhere else, they read whatever directory they stood in; - with no `--pin`, `pin-source-unused` named `.`, which nobody typed. #576 (#442) refuses an empty value in the naming gate (`link-source-no-path`) and in the root's `validate-pins.py` (`pin-source-no-path`). The two tools that make a project now refuse it under the same code and in the same sentence, "`--pin-source ''` names no path. An empty path would be read as the directory this runs in." Each tool begins the remedy in its own words, as `pin-source-unused` does: give the clone's path, or `.` to mean this directory. `pin_source_path` (`scripts/shape_materialize.py`, beside `refuse_an_unread_pin_source`) asks this for both tools and hands back the `Path` they read. Both parsers therefore take the flag as text, since once parsed as a `Path` an empty value and a typed `.` are one. It is asked from the flags alone, before `pin-source-unused` and before any process starts. In the scaffold that is beside `pin-source-unused`, so `--dry-run` refuses it too. In `plan` it is the first thing `cmd_plan` asks. A typed `.` is read as it always was. Both `--pin-source` helps say so, and `docs/cli.md` is regenerated (`render-cli-reference.py --check` passes). Tests (`tests/test_pin_source_unreadable.py`): - `test_a_pin_source_that_names_no_path_is_pin_source_no_path` runs the dry run, the real scaffold and `plan`, with and without a `--pin`, each from inside a clone that answers: exit 2, the exact refusal, nothing on stdout and nothing made; - `test_a_typed_dot_is_still_the_directory_the_tool_runs_in` is the control. Main: 7d9bec9. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 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 1 day and 8 hours by commenting |
…er before it clones, clones each member on its tracking branch, and `family.py siblings` runs the holder's own copy (#543) (#578) Three lines of #543, in one change and one re-pin, because `scripts/siblings.py` is a pinned copy in every family holder. Head: `8d3d1cceb30dfc93c7ceec130131038dc4f89e40`. The branch's own commits: the change (43d32eb, on bfed143), a merge of main at 9f80939 (8b4fc0b), the review round (2156a01), round 3 (5ce5b68, a family folder reached through a link; 0112dcb, a test skipped on Windows), and round 4 (8d3d1cc, a Windows junction counts as a link, and rv543b's nits). **1. The family folder is asked first** (`templates/family-root/scripts/siblings.py`, `_run_placement`, `parent_folder_warning`). - Before: in a holder whose parent folder is not named after the family, `make siblings` cloned every member into that folder, then printed the warning and the `mv` lines, and exited 0. After the move, the re-run cloned every member a second time beside the moved holder. The first clones stayed behind. - Now: the check runs before any `git` command runs or any member is cloned. The run prints the same WARNING and `mv` lines, says `NOTHING WAS CLONED` and why, ends with `siblings: stopped before cloning: the holder is not in a folder named <Family>. Nothing was cloned, fetched or moved.`, and exits 1 (`make siblings` exits 2, make's own code). `--dry-run` stops the same way. After the move, one run clones each member once. - Why a stop and not a later warning: no flag or documented answer lets a person place siblings outside the family folder. AGENTS.md, README.md, the handbook and the holder's own README and AGENTS-shape.md all describe a warning plus a printed `mv`, and the warning itself told the person to move the holder and re-run. A stop gives the same outcome without the stray clones. Exit 1 is the file's "finding a human can act on" (exit 2 is kept for "the question could not be asked"). - **The printed move runs as typed on every disk** (review round, S3). The name test stays exact on Linux, macOS and Windows. So the remedy has to work on each: - On a disk that ignores case (macOS, Windows), a holder in `inkrouter/` for the family `InkRouter` used to get `mkdir -p <p>/InkRouter` and `mv <p>/inkrouter/InkRouter <p>/InkRouter/InkRouter`. There `<p>/InkRouter` IS `<p>/inkrouter`, so that moved the holder onto itself and failed. Now, when `<p>/InkRouter` exists and `os.path.samefile` says it is this folder, the FOLDER is renamed through a staging name: `mv <p>/inkrouter <p>/InkRouter.folder`, then `mv <p>/InkRouter.folder <p>/InkRouter`. - At a filesystem or drive root (`/InkRouter`, `C:\InkRouter`), `<root>/InkRouter` is the holder itself, and the old lines were `mv X X/X`. Now the holder moves through `<root>/InkRouter.holder`, the staged move the common case already used. The warning names the root (`'/'`) where it used to say `''`. - Linux is unchanged: with nothing at `<p>/InkRouter` the holder moves there through `InkRouter.holder`; with a separate `<p>/InkRouter` folder it moves into it. - A link named `InkRouter` beside the folder and leading to it passes the check: the family folder, reached through a link (see "Round 3"). - **The header says `parent folder` until the check passes** (C3). It used to print `family folder <parent>` right above a warning saying the parent is not the family folder. Through a link it names the folder the clones go into, and the link. **2. auF-13: the tracking branch** (`siblings.py`, `tracking_branch()`, used in `place()`). - Before: a plain `git clone`, so a sibling landed on its remote's default branch. - Now: the clone passes `--branch=<branch>`, where `<branch>` is the `tracking_branch:` of the member's own `project.yaml` in the pinned copy under `members/`. That copy is the holder's record of the member, and the same field the member's own bootstrap reads for its legs. - Not read: `.gitmodules` (both templates say it carries no `branch=`, and nothing writes one) and `family.yaml`'s `tracking_branch:` (the holder's own branch: `family.py add`, `bump` and `remove` refuse to commit on it). - No record: when `members/<Project>` is not checked out, or its `project.yaml` names no branch, the clone keeps today's behaviour (the remote's default branch) and its line says why. - **A value that is no branch name is not used** (review round, N1). A number or a list (`5`, `[main]`) now reads `declares tracking_branch 5, which is not a branch name`, where it used to say it declares none. A string holding a character that does not print (an ESC sequence, a newline) is not handed to git and is shown in its `repr`, for example `declares tracking_branch '\x1b[2Jcleared', which holds a character that does not print, so it is not used as a branch`. It used to reach stdout raw. The member's own bootstrap escapes the same field (#391). Each such clone keeps the remote's default branch. No import was added to the pinned file. - A declared branch the remote does not have is git's own refusal: a `CLONE FAILED` finding naming it, exit 1, and no directory left behind. **3. auF-14: the holder's pinned copy** (`scripts/family.py`, `cmd_siblings`). - Before: `family.py siblings` used `runpy` to run this checkout's `templates/family-root/scripts/siblings.py`, inside family.py's own process. A holder pinned at another shape ran different code than `make siblings`. With no holder copy at all it still ran, and its `siblings-script-missing` remedy said it "runs the holder's own copy". - Now: it runs `<holder>/scripts/siblings.py` with the same interpreter, in its own process, from the holder, as the Makefile's `siblings` target does, and returns that exit code unchanged. The command is `scripts/siblings.py --root .` (plus `--dry-run`). Every word of it is a constant, so no command-line value reaches its argv (the case SonarCloud's `pythonsecurity:S8705` flags). - **The child keeps family.py's escaped stdout** (review round, S1). family.py escapes what its stdout cannot encode (#214), but a child takes its stdout settings from its environment. So the child now gets `inherited_child_environment()`, the helper `update-shape.py` already uses for the validators it runs (#254): this stdout's own encoding and a handler that cannot raise. Under `PYTHONIOENCODING=ascii` the child used to die on the em dash of its first line, `UnicodeEncodeError`, exit 1. Now it prints `\u2014` and goes on. The helper already guards a stdout with no encoding, and it never passes on the handler only family.py registers, which a child cannot look up. - A holder without the file is refused: `siblings-script-missing`, exit 2, before anything is cloned, in the words the holder's Makefile refuses with since #503. **4. `init --work-dir` no longer promises clones** (`scripts/family.py`, `_print_next_steps`; review round, S2). `--work-dir <dir>` makes no family folder. Where `<dir>` is not named after the family, the NEXT STEPS used to say `make siblings` "then puts each member's WORKING clone beside the holder, in the family folder", and `family folder <dir>`. That `make siblings` now stops. The block now says `--work-dir made no family folder, so make siblings clones nothing: it prints the mv that puts the holder in a folder named <Family>, and stops until it is there`, and `family folder none: <dir> is not named <Family>`. The `--into` output is byte for byte what it was. **The half of #461 that #543 names.** The sibling clone is `--no-local`. A member url that is a path on disk is then read through git, not copied file by file, so a repack in the remote cannot break it halfway (#440). **Docs.** - AGENTS.md family item 5 and the holder's AGENTS-shape.md said the stop "exits 1"; `make siblings` itself exits 2, make's own code. Both now say exit 1 (`make`: 2), rewrapped within the same lines. AGENTS.md stays at 490 lines. - The holder's `README.md` says the run stops before cloning, and which tracking branch it clones on. - `templates/family-root/family.yaml`'s `tracking_branch:` comment said the family bootstrap places each member on that branch. It now says what it is: the holder's own branch, which `family.py` refuses to commit on. The family bootstrap leaves each member detached, and `make siblings` reads the member's own `project.yaml`. No test or pin compares those bytes. - README.md and docs/handbook.html are untouched (see "Left out"). | point | what was found | what this head does | |---|---|---| | S1 | the child process lost family.py's escaped stdout (#214) | `env=inherited_child_environment()`; test with `PYTHONIOENCODING=ascii` | | S2 | `init --work-dir`'s NEXT STEPS promised a `make siblings` that stops | the `folder is None` branch says there is no family folder; test on the fixture's own `init` stdout | | S3 | on a disk that ignores case, and at a root, the printed move cannot run | the exact name test is kept on every disk; the folder rename and the staged holder move; tests by simulation on all three systems | | N1 | a `tracking_branch:` holding control characters reached stdout raw; `5` and `[main]` said "declares no tracking_branch" | not used, shown in its `repr`; "is not a branch name"; three tests | | N2 | "before any credential is resolved" was unguarded | the claim is now "before any `git` command runs", pinned with `GIT_TRACE`; reading the environment had nothing a test could observe | | N3 | a symlinked family folder now stops, and its printed move cannot run | round 3: a link named after the family, beside the folder and leading to it, passes; see "Round 3" | | N4 | AGENTS.md:409 said "exits 1" for `make siblings` | exit 1 (`make`: 2), within the same lines; the same fix in the holder's AGENTS-shape.md | | N5 | the merge section was stale | rewritten below | | C2 | the `family.yaml` comment was false | fixed | | C3 | `family folder` printed above a stop | `parent folder` until the check passes | **S3, the decision: keep the strict name test on every OS.** Brett Heap's rule for the case variants of `members/` (#362-#364, "Refuse on every disk") is one answer on every disk. The same spirit applies here. Accepting `os.path.samefile(folder, wanted)` instead would pass a holder on macOS that Linux stops. So every printed remedy now has to work as typed. The evidence: - **Tests, on all three systems.** The template's `siblings.py` is loaded in-process with `load_without_bytecode`, so nothing is written under `templates/`. `Path.exists` and `os.path.samefile` are patched to answer as a disk that ignores case does. The case-only test then does the printed moves with `os.rename` and checks that the next check passes. A case-sensitive disk is simulated with nothing or a separate folder at `<p>/InkRouter` (the separate folder skipped on Windows, see "Round 3"), and a link there, which now passes. The roots are asked as `PurePosixPath("/InkRouter")`, `PureWindowsPath("C:\InkRouter")` and `PureWindowsPath("C:\inkrouter")`. - **Run as typed** (a throwaway probe, not committed). Each printed line went to `sh -c` as printed. A disk that ignores case was emulated with a symlink that spells the folder in the family's case, and that exists only while the folder does (a step that leaves it dangling removes it). Then a real `siblings.py` ran in the moved holder: | case | lines printed | run as typed | then `siblings.py` in the moved holder | |---|---|---|---| | a disk that ignores case, holder in `inkrouter/`: 43d32eb | `mkdir -p <t>/InkRouter`, then `mv <t>/inkrouter/InkRouter <t>/InkRouter/InkRouter` | the `mv` fails, rc 1: `cannot move ... to a subdirectory of itself` | the holder is not placed | | the same, this head | `mv <t>/inkrouter <t>/InkRouter.folder`, then `mv <t>/InkRouter.folder <t>/InkRouter` | both rc 0 | rc 0, no WARNING, IRRS and IRSS cloned beside the holder | | the holder at `/InkRouter`: 43d32eb | `mkdir -p /InkRouter`, then `mv /InkRouter /InkRouter/InkRouter` | the `mv` fails the same way | the holder is not placed | | the same, this head | `mv /InkRouter /InkRouter.holder`, `mkdir -p /InkRouter`, `mv /InkRouter.holder /InkRouter/InkRouter` | all rc 0 | rc 0, no WARNING, both cloned | | Linux, nothing at `<t>/InkRouter` | the three staged lines, the same on both heads | all rc 0 | rc 0, both cloned | | Linux, a separate `<t>/InkRouter` folder | `mkdir -p` and one `mv`, the same on both heads | both rc 0 | rc 0, both cloned | The filesystem root ran with `/` mapped to a scratch folder, because this machine has no root access and no user namespaces. `C:\InkRouter` and `C:\inkrouter` were asked of the function only. Both print `WARNING the holder's parent folder is 'C:\\', not 'InkRouter'.`, then `mv 'C:\InkRouter' 'C:\InkRouter.holder'` (or `'C:\inkrouter'`), `mkdir -p 'C:\InkRouter'` and `mv 'C:\InkRouter.holder' 'C:\InkRouter\InkRouter'`. Nothing here ran on a real macOS or Windows disk. CI's macOS and Windows jobs do run the case-only test's two renames on their own disks. New file: `tests/test_family_siblings_placement.py`, 29 tests: 10 from the first round, 13 from the review round, 2 from round 3, 4 from round 4. The runs below are the review round's, at 2156a01; round 3's are in its own section. All runs used a stub `gh` first on PATH, a bogus token, an empty `GH_CONFIG_DIR`, a blanked git identity, `/usr/bin/python3 -m pytest`: - `tests/test_family_siblings_placement.py` on the working tree, before the commit: `23 passed in 50.77s`. - Set A at 2156a01: every `tests/test_family*.py` (15 files), `test_park_resume_targets.py`, `test_windows_paths.py`, `test_doctor_legs_members_next.py`, `test_commit_trailers.py`, `test_free_plan_hint.py`, `test_refusal_default_remediation.py` and `test_stdout_encoding.py`: `1007 passed, 20 skipped in 983.34s (0:16:23)`. The skips are `test_family_init_values.py:219` (16) and `:291` (3), which the file documents, and `test_stdout_encoding.py:286` (Windows only). - Set B at 2156a01: `test_cli_reference.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, the three `test_template_*` files, the four `test_validate_family_*.py`, the ten `test_update_shape*.py` of the first round, `test_shape_doctor.py`, and now `test_update_shape_kept_helper.py` (main's check of the template's imports) and `test_validator_child_encoding.py` (the helper S1 uses): `1478 passed, 6 skipped in 1658.89s (0:27:38)`. The skips are NTFS junctions (3) and the doctor's PowerShell line (3), both Windows only. - The merge commit 8b4fc0b on its own, before the fix: the new file, `test_family.py`, `test_family_printed_quoting.py`, `test_family_makefile_siblings.py`, `test_park_resume_targets.py`, `test_windows_paths.py`, `test_clone_just_pushed.py` and `test_doctor_legs_members_next.py`: `375 passed in 686.29s (0:11:26)`. And `test_cli_reference.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_update_shape_kept_helper.py` and `test_template_text_placeholders.py`: `340 passed in 245.06s (0:04:05)`. - Nothing is written under `templates/`: after the mutation baseline run (the new file's 23 tests, the in-process ones included, plus four more) a clean tree had no `__pycache__` under `templates/`. The script builds two scaffolded members (Atlas on `main`; Borealis on `trunk`, with its remote's default branch set to `main`) and a holder made with `init --work-dir`, so its parent folder is `fam`, not `Orbit`. | | bfed143 | this branch | |---|---|---| | first `make siblings` in `fam/Orbit` | exit 0; Atlas and Borealis cloned into `fam/`; WARNING after | exit 1 (make: 2); nothing cloned; `NOTHING WAS CLONED` | | clones on disk after move + re-run | 4: `fam/Atlas`, `fam/Borealis`, `Orbit/Atlas`, `Orbit/Borealis` | 2: `Orbit/Atlas`, `Orbit/Borealis` | | Borealis sibling branch | `main` (remote default) | `trunk` (its tracking branch) | | `family.py siblings` marker from the holder's copy | 0 lines (ran the standard's) | 1 line | | `family.py siblings`, holder without `scripts/siblings.py` | exit 0, ran the standard's copy | exit 2, `REFUSED siblings-script-missing`, the Makefile's words | | `make siblings`, same holder | exit 2, the Makefile's refusal | same | The review round's reproductions are rv543's probes: `PYTHONIOENCODING=ascii` through `family.py siblings` (rc 1 with a traceback at 43d32eb; the new S1 test holds rc 0), the `--work-dir` NEXT STEPS, and the printed moves above. Each probe reverts one hunk in a throwaway worktree. Every one is caught. **First round** (at 43d32eb; the new file plus the four changed existing tests, 15 tests): | probe | what it puts back | result | caught by | |---|---|---|---| | M1 | no `--branch=` on the clone | 3 failed | tracking branch, branch the remote lacks, the issue's flow | | M2 | no `--no-local` | 1 failed | no shared object file | | M3 | the warning no longer stops the run | 6 failed | both new misplaced tests, and the four changed existing tests | | M3b | the folder check back after the placement, as on main | 6 failed | the same six | | M4 | `tracking_branch()` never finds a branch | 4 failed | tracking branch, branch the remote lacks, dry-run line, the issue's flow | | M5 | the clone line names no branch | 3 failed | tracking branch, not-checked-out words, dry-run line | | M6 | the warning no longer says NOTHING WAS CLONED | 2 failed | both new misplaced tests | | M7 | `scripts/family.py` as on bfed143 | 2 failed | holder's copy and its imports; the refusal | | M8 | `runpy` of the HOLDER's file inside family.py | 1 failed | holder's copy and its imports | | M9 | one word of the refusal differs from the Makefile's | 1 failed | the refusal | | M10 | no refusal for a holder without its copy | 1 failed | the refusal | **Review round** (at 2156a01; the new file plus the same four, 28 tests; the unmutated baseline is `28 passed`): | probe | what it puts back | result | caught by | |---|---|---|---| | S1 | no `env=` on the child process | 1 failed | the ASCII stdout test | | S2 | `--work-dir` prints the old promise | 1 failed | the `--work-dir` NEXT STEPS test | | S3a | a root is not taken for the holder itself | 3 failed | the three root cases | | S3b | no rename of the folder | 1 failed | the case-only test | | S3c | a link named after the family is renamed through | 1 failed (at 2156a01; equivalent since round 3) | case-sensitive disk, `a link` | | S3d | `same_directory` asks the disk before comparing the paths | 3 failed | the three root cases | | S3e | the warning names a root `''` | 3 failed | the three root cases | | N1a | a control character is used as a branch | 1 failed | `escape-sequence` | | N1b | `5` and `[main]` "declare no tracking_branch" | 2 failed | `a-number`, `a-list` | | N2 | the credential and `.gitmodules` read before the stop (rv543's mutation B, which nothing caught before) | 1 failed | the `GIT_TRACE` test | | C3 | the header always says `family folder` | 1 failed | the misplaced dry run | The doc and comment hunks have no test: AGENTS.md, the holder's AGENTS-shape.md, the `family.yaml` comment, the docstrings, and the `tests/conftest.py` comment. Round 3's mutants are in its own section. **What was wrong.** Take `<t>/InkRouter`, a link to `<t>/real-x`, which holds the holder. Both entry points resolve the holder to `<t>/real-x/InkRouter` and saw a parent named `real-x`. On bfed143 that was a warning, and the members were cloned into `real-x`. This PR turned the warning into a stop. The printed `mkdir -p <t>/InkRouter` passes, then `mv <t>/real-x/InkRouter <t>/InkRouter/InkRouter` fails with `cannot move ... to a subdirectory of itself`, rc 1 (rv543 had read it as working). So the PR made a dead end. **The rule** (`siblings.py`, new `family_link`). Let R be the resolved holder, P its parent and G P's parent. The check also passes when `G/<Family>` is a link (`os.path.islink`) and is the same directory as P (`os.path.samefile`). The holder is then reachable as `G/<Family>/<R.name>`. The members are cloned into P, the link's own target, so the family layout holds through the link. - Both entry points give the same answer, because both resolve R: `make siblings` through `find_repo_root`, and `family.py siblings` by running the child in the resolved holder. - The header then reads `family folder <P> (through the link G/<Family>)`. It names the folder the clones go into. - A link named after the family that leads to another folder is not accepted. Its printed move runs as typed: the holder moves into the link's target, and there the link passes. - A link elsewhere (`~/InkRouter` leading to `/data/real-x`) still stops. Its printed move runs as typed. - The case-only rename of S3 is unchanged and stays strict. On a disk that ignores case, a case-only alias is one directory entry and never a link, so it gets the rename. The two disks differ at one edge only: a link spelled in another case stops on Linux, with a staged move that runs, and passes on a disk that ignores case (round 4 corrected this sentence). **Run as typed** (a throwaway probe; each printed line went to `sh -c` as printed, then a real `siblings.py` ran where the holder ended): | layout | 2156a01 | this head | |---|---|---| | `<t>/InkRouter` links to `<t>/real-x`, which holds the holder | stops; `mkdir -p <t>/InkRouter` rc 0, then the `mv` fails, rc 1, `cannot move ... to a subdirectory of itself`; the next run stops again | passes, rc 0, no WARNING; header `family folder <t>/real-x (through the link <t>/InkRouter)`; IRRS and IRSS cloned into `<t>/real-x` | | `<t>/home/InkRouter` links to `<t>/data/real-x` (a link elsewhere) | stops; the three staged lines (`mv <t>/data/real-x/InkRouter <t>/data/InkRouter.holder`, `mkdir -p <t>/data/InkRouter`, `mv <t>/data/InkRouter.holder <t>/data/InkRouter/InkRouter`) all rc 0; the next run passes and clones both | the same | | `<t>/InkRouter` links to `<t>/other`; the holder is in `<t>/real-x` | (not run) | stops; `mkdir -p <t>/InkRouter` and `mv <t>/real-x/InkRouter <t>/InkRouter/InkRouter` both rc 0; the next run passes through the link, header `family folder <t>/other (through the link <t>/InkRouter)`, both cloned into `<t>/other` | **Tests** (`tests/test_family_siblings_placement.py`, two new; links made with `os.symlink`, each test skipping first where the system refuses one; none under `templates/`): - the link layout passes through `family.py siblings --dry-run` and through `make siblings`' own script, which clones each member into the link's target; - a link named after the family to another folder stops, and its move done as written leads to a run that passes; - the case-only test still prints the two-line rename; - the simulated `a link` case of the case-sensitive disk test now patches `os.path.islink` too, and expects no warning. **Windows** (rv543b). `[a folder]` of the simulated case-sensitive disk test is skipped on Windows, before any call. A WindowsPath compares without case, so `same_directory()` calls `<p>/InkRouter` and `<p>/inkrouter` one path there, and the case-only rename is printed. That answer is right for Windows, whose disk cannot hold the two folders, but it is not the disk the parameter simulates. Emulated on Linux with rv543b's mechanism (`pathlib._is_case_sensitive` returning False during each test's call): `1 failed, 24 passed in 31.32s` with the parameter collected (only `[a folder]`, since `os.name` is `posix` here), and `24 passed, 1 deselected in 31.49s` without it. **Mutants** (at 0112dcb, the new file, 25 tests; the baseline is `25 passed`, and no `__pycache__` appeared under `templates/`): | probe | what it changes | result | caught by | |---|---|---|---| | R3a | `family_link` never finds a link | 3 failed | the link layout; the link to another folder (its moved holder no longer passes); the simulated `a link` | | R3b | any same directory counts, link or not | 5 failed | the case-only rename (a case-only alias would pass); the three roots; the placed dry run's header | | R3c | any link named after the family counts, wherever it leads | 1 failed | the link to another folder | | R3d | the folder check does not ask `family_link` | 3 failed | the link layout; the link to another folder; the simulated `a link` | | R3e | the header does not name the link | 1 failed | the link layout | Round 2's S3c (the `is_symlink` test inside the case-only branch) is now an equivalent mutant: every link is answered by `family_link` first, so that test can no longer decide anything (`25 passed` with it removed). It is kept, as the S3 branch is kept as it was. **Docs.** `siblings.py`'s module docstring (FIRST, EXIT CODES) and `parent_folder_warning`'s, and the holder's README.md and AGENTS-shape.md, say a link of the family's name beside the folder, leading to it, counts. AGENTS.md's family item 5 was left as it was in this round, on the belief that the clause needed a ninth line; rv543b showed it fits, and round 4 adds it. **Runs at 5ce5b68** (0112dcb changes only the test file; see round 4 for where `25 passed` was run): - the new file, `test_family_makefile_siblings.py`, `test_family_printed_quoting.py`, the whole of `test_family.py` and `test_family_tracking_branch.py`: `248 passed in 196.84s (0:03:16)`; - `test_template_text_placeholders.py`, `test_update_shape_kept_helper.py`, `test_cli_reference.py`, `test_repo_hygiene.py`, `test_free_plan_hint.py` and `test_update_shape.py`: `397 passed in 213.21s (0:03:33)`. | point | what was found | what this head does | |---|---|---| | S-1 | `family_link` asked only `os.path.islink`, which is False for an NTFS directory junction, the link Windows makes without privilege. `Path.resolve()` follows a junction, so that layout stopped and its printed `mv` failed, where bfed143 warned and cloned | `os.path.isjunction` counts too (Python 3.12 and later, False off Windows; read with `getattr`, so an older Python still runs the file, and there the junction case stays as it was). Not an "any alias" rule, which would make a link spelled in another case a dead end on macOS. A simulated junction test runs on every system | | F2 | AGENTS.md item 5 lacked the link clause the holder's README and AGENTS-shape.md have; this body said it could not fit | it fits: "(nor linked as it)", rewrapped within the same eight lines by dropping one "yourself"; AGENTS.md stays at 490. README.md:883 and docs/handbook.html:1306 make the same claim and are left to a later README change (see "Left out") | | F3(a) | the comment and this body said the case-only rename and the link rule "never meet" | corrected: a link spelled in another case (`inkrouter` leading to `real-x`) stops on Linux, with a staged move that runs, and passes as the family's link on a disk that ignores case. No dead end either way | | F3(b) | 0112dcb's skip reason said a Windows disk cannot hold the two folders | it says the simulation assumes Windows' default path equality, which ignores case; an NTFS folder made case-sensitive can hold them | | F4 | three conditions no test guarded | one test each: `init --work-dir` into a folder named after the family keeps the promise (S2c); a blank `tracking_branch: " "` is not a branch name (N1s); a second path to the folder under another name, neither link nor junction, is not answered by the case-only folder rename (S3f) | | F5, F6 | the staging names are not checked; a dangling link named after the family breaks the staged move halfway | checklist candidates, below | Also corrected: round 3's `25 passed in 34.28s` ran on the working tree at 5ce5b68 with 0112dcb's change not yet committed. At 0112dcb itself, the round-3 mutant baseline gave `25 passed in 32.69s`. **Mutants** (at 8d3d1cc, the new file, 29 tests): | probe | what it changes | result | caught by | |---|---|---|---| | R4a | a junction no longer counts (`isjunction` dropped) | 1 failed, 28 passed | the simulated junction | | S2c | `init --work-dir` always says there is no family folder | 1 failed, 28 passed | `--work-dir` named after the family | | N1s | a blank `tracking_branch` is used as a branch | 1 failed, 28 passed | `[blank]` | | S3f | the case-only folder rename for any second path to the folder | 1 failed, 28 passed | the rename only for a difference of case | The unmutated baseline is `29 passed`, and no `__pycache__` appeared under `templates/` after it. **Runs at 8d3d1cc:** - the new file on the working tree, before the commit: `29 passed in 21.84s`; - the new file, `test_family_makefile_siblings.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, and `test_family.py -k "siblings or init"`: `195 passed, 71 deselected in 66.86s (0:01:06)`. **Main merged at 8b4fc0b** (`git merge --no-ff origin/main`, main at 9f80939: #551, #552, #554, #553, #561, #562, #579, #567). One hunk conflicted, in `tests/test_family_printed_quoting.py`: #554 added `env=NO_BYTECODE` to the `run_script(FAMILY, "siblings", ...)` call, and this branch changed the next line from `returncode == 0` to `== 1`. The resolution keeps both lines whole: ``` result = run_script(FAMILY, "siblings", "--family-root", str(holder), "--dry-run", env=NO_BYTECODE) assert result.returncode == 1, result.stdout + result.stderr # #543 ``` `tests/conftest.py`'s comment above `NO_BYTECODE` said `family.py siblings` runs the template's copy through `runpy`, which this branch makes false. It now says what the constant guards and why it stays on those two calls (lane 3 agreed to the wording on #554). `NO_BYTECODE` stays at both call sites; dropping it is a later cleanup. The merged tree differs from main only in this branch's files and that comment. **merge-tree at 8d3d1cc:** origin/main f5fc705 is clean and not merged (one more commit since dd3f23a, `update-shape.py`'s refusal of a hard-linked file); AGENTS.md is 490 lines in the merged tree. Lane 5's `fix/family-bump-to-checks` (8024f9c), `fix/family-members-case-variants` (e5aac93), `fix/family-remove-gitmodules-missing` (ebe6b51) and `fix/link-source-chain-defaults` (a707d4b): clean. #573 and #575 were not re-checked at this head: round 4 changes `siblings.py`, AGENTS.md and the new test file, none of which they touch. **merge-tree at 0112dcb** (`git merge-tree --write-tree`, after a fresh fetch): - origin/main dd3f23a: clean, and not merged; this PR is behind it by three commits. AGENTS.md is 490 lines in the merged tree and keeps this branch's line 409. - #573 `fix/537-dry-run-asks-remotes` (69c58aa) and #575 `fix/539-validators-lockstep-remedies` (0680799): clean. - Lane 5's `fix/family-bump-to-checks` (8024f9c), `fix/family-members-case-variants` (8a95345), `fix/family-remove-gitmodules-missing` (ebe6b51) and `fix/link-source-chain-defaults` (a707d4b): clean. **At 2156a01, for the record:** - origin/main 64fc9a8: clean. Main moved after the merge, by a35f1be (`update-shape.py`) and 64fc9a8 (the assembly root's `bootstrap.py`). Neither touches a family file. A scratch merge of this head with 64fc9a8 (no ref, since removed) ran the new file, `test_family_makefile_siblings.py`, `test_family_printed_quoting.py`, and main's new `test_update_shape_messages.py`, `test_update_shape_header_comments.py`, `test_update_shape_branch_any_directory.py` and `test_bootstrap_after_bump.py`: `269 passed in 156.48s`. Then `test_update_shape.py`, `_landing`, `_next_quoting`, `_pr_base`, `_kept_helper`, `_add`, `test_cli_reference.py` and `test_repo_hygiene.py`: `411 passed in 322.52s (0:05:22)`. - origin/main dd3f23a (`openRepoShape --doctor` wording; `shape-doctor.py`, `render-cli-reference.py`, AGENTS.md, README.md, the handbook, `docs/cli.md`): clean, not merged, not test-run. - #573 `fix/537-dry-run-asks-remotes` (69c58aa) and #575 `fix/539-validators-lockstep-remedies` (93b68f6): clean. - Lane 5's `fix/family-bump-to-checks` (8024f9c), `fix/family-members-case-variants` (40eda84), `fix/family-remove-gitmodules-missing` (b1120c3) and `fix/link-source-chain-defaults` (a707d4b): clean. - **README.md and docs/handbook.html.** README:882-884 and the handbook at :1305 say `make siblings` "WARNS and prints the exact `mv`". That stays true: the output still opens with `WARNING` and prints the `mv`. README:871-872 ("the same file through the other entry point") becomes true with this change. Neither says the run now stops; that is incomplete, not false, so this PR does not touch README.md. Adding "and clones nothing until it is moved" could be a later README change. That change should also add the link clause round 3 gave the holder's README and AGENTS-shape.md and round 4 gave AGENTS.md: README.md:883 and the handbook at :1306 say the run warns whenever the parent folder is not named after the family, which a family folder reached through a link no longer does. They are left alone here because a README change needs the hash step and README PRs land one at a time. - **`scripts/shape_materialize.py:519`**: its comment says siblings "warns about the parent folder without moving anything", which is still true. It is left alone because PRs that touch that file meet SonarCloud's S8705 finding at `run()`. - **`templates/family-root/scripts/validate-family.py`**: not touched (lane 2's #539). - **The `--make` dispatch header** still prints `family folder`. The dispatch runs no folder check (it is the placement's business), so it was left as it is. - **`make siblings` crashes on a stdout that cannot encode its em dash or a path.** Pre-existing on bfed143. The holder's `siblings.py` never escapes its own stdout, so under `PYTHONIOENCODING=ascii`, or a Windows code page without a character in the holder path or a member name, `make siblings` dies with a `UnicodeEncodeError` on its first line, before any clone. `family.py siblings` no longer does (S1), because it hands the child its own escaped settings. The family `bootstrap.py` already calls `repo_shape.escape_unencodable_stdout()`; a holder that kept an older `repo_shape.py` may not have it, which main's kept-helper check now looks at. - **`shape-doctor.py:5640`: the `members` row's note says "`make siblings` places them"**, a command that now stops, clones nothing, in a holder whose parent folder is not named after the family. - **A family name a disk cannot hold exactly never passes the folder test on that disk.** Windows drops a trailing `.` or space from a folder name and reserves names like `CON` and `NUL`. `--family` goes through `SAFE_ARG_RE` (`scripts/repo_shape.py:106`), which allows a trailing `.`. Such a holder stops on every run there. - **A `tracking_branch:` that names only a tag gives a detached clone.** `git clone --branch=<name>` takes a tag too, so a member that declares `v1` where its remote has only a tag `v1` is cloned detached at the tag, rc 0, and the line calls it "its tracking branch". - **`tests/test_cli_reference.py` writes `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc`** (found by rv441b on #576 and here). `scripts/render-cli-reference.py` runs the template's `siblings.py --help` with `PYTHONPATH=scripts`, and that file imports `bootstrap` from beside it. Shown in clean trees: no `__pycache__` before, the file after `tests/test_cli_reference.py` alone (`22 passed`), on 04c9d77, on two merged trees, and on this branch's merge. `.gitignore` hides it. A `.pyc` under `templates/` breaks `template_values()` and its twin in `test_scaffold_leg_path_collisions.py` in a later run in the same tree, which read every template file as UTF-8. #554's `load_without_bytecode` does not cover it; `PYTHONDONTWRITEBYTECODE=1` in the renderer's child environment would. - **A staging name that already exists sends the moves astray** (F5). Neither `<Family>.folder` (round 2) nor the older `<Family>.holder` is checked before it is printed. With `<t>/InkRouter.folder` already a folder, on an emulated disk that ignores case, both printed lines exit 0 and leave the holder at `InkRouter/inkrouter/InkRouter` (rv543b's probe). The next run stops again with a rename that works, so it is recoverable. The move text predates this PR; the stop is what makes it the only way forward. - **A dangling link named after the family breaks the staged move halfway** (F6). `not wanted.exists()` follows the link, so a dangling `<t>/InkRouter` reads as nothing there: `mv <t>/real-x/InkRouter <t>/InkRouter.holder` exits 0, then `mkdir -p <t>/InkRouter` fails, `File exists`, and the holder is left at `<t>/InkRouter.holder` (rv543b's probe). `os.path.lexists` would send it to the two-line move, whose `mkdir -p` fails before anything moves. The move text predates this PR; the stop is what makes it the only way forward. - **`tests/test_validator_child_encoding.py`'s launcher guard does not see `family.py`.** It looks for `subprocess.run([sys.executable, ...])`, and `family.py` runs `[sys.executable or PYTHON, ...]`, so the guard would not notice if `env=` were dropped again. The new S1 test catches that by behaviour (probe S1), but the static guard could list `family.py` too. Review: rv543 found 3 should and 5 nits on 43d32eb; the fix round merged main 9f80939 by hand (rv543's resolution) and fixed them; re-review rv543b found that the stop's printed move had no way forward through a family folder reached by a link (rounds 3 and 4 accept a symlink and a Windows junction), and its nits were fixed or listed as checklist candidates on #459. CI (Brett Heap's ruling 27): H0 8d3d1cc passed tests, tests-macos, tests-windows and SonarCloud; the head 3e3c833 is a clean update-branch merge of main fb786c1, with tests and SonarCloud green. Landed on Brett Heap's word of 2026-10-15T13:32:47Z, "All five, once green". Closes #543 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-pin-source` is read by each `--pin` and by the FIRST `--referent-chain` link, so the sentence agrees with #576's rule that a bare path is the first link's tree alone (#451) rv540b N3. The refusal said `--pin-source` names the clone "each --pin's digest is read from, and each --referent-chain link's declaration". Lane 5's #576 (#441) makes the scaffold hand a bare `--pin-source` to a chain's first link and no other, because a later link read from the same tree answered with the wrong product's declaration. The wording follows its rule ("the FIRST link alone", docs/cli.md of #576), in the refusal's `reads` clause and in `refuse_an_unread_pin_source`'s docstring, where it names #441. The same claim is reworded in `tests/test_pin_source_unreadable.py`: the section comment, the `UNUSED` scaffold text the test compares the refusal with, and the docstring of `test_a_referent_chain_reads_the_pin_source`, whose chain `openXglass,openGlass` is read through its first link. The scaffold's own comment at the call (`no --referent-chain link a declaration`) stays: it says only that a run with no chain has no reader. The adopt wording is unchanged: `plan` takes no chain. Main: 2bf8fd4. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…es not have as the index has it (missing, unstaged, untracked, or behind assume-unchanged or a sparse checkout), so neither commits a change to it nobody staged (#407) (#571) `family.py remove` commits `.gitmodules` as `git rm` leaves it in the work tree, and `git rm` edits only a `.gitmodules` the work tree has. With the work-tree copy deleted and the deletion not committed, `git rm` edited nothing. `_commit_one` then staged the deletion, and the commit took `.gitmodules` out of HEAD, with every other member's section in it. `remove` exited 0, and the holder it ran in passed its own gate. Once the commit was pushed, a recursive clone left every remaining member empty. The issue's reproduction, run on bfed143 (I added one `git switch -c`, because #487 landed after the issue was filed): ``` registration keys before: Atlas 2, Nova 2 dry run: exit 0 gitmodules none: git reads no section of .gitmodules in the work tree as giving members/Nova, so none is removed remove: exit 0 committed 766cfa532913: .gitmodules, members/Nova, family.yaml .gitmodules | 6 ------ HEAD holds .gitmodules: no registration keys after: Atlas 2, Nova 0 family ok: Fam (fam), 1 member(s) recursive clone: exit 0 the clone's members/Atlas holds 0 entries REFUSED member-uninitialized: members/Atlas is not an initialized submodule checkout, ... fatal: No url found for submodule path 'members/Atlas' in .gitmodules ``` One check, `_checked_gitmodules_as_staged`, runs before any git command that writes. The dry run makes it too, in the same words. It reads the index's records for `.gitmodules` (stage, blob and git's `-v` flag letter) in one `git ls-files -s -v -z`, which writes nothing. - **`gitmodules-missing`** (`remove`): the work tree has no `.gitmodules`, while the index (at any stage) or HEAD has one. The remediation names the restore git takes in that state: - `git checkout -- .gitmodules` (the index's copy); - `git checkout HEAD -- .gitmodules`, where the deletion is staged; - for an unmerged file, by the stages the index holds: `git checkout --merge -- .gitmodules` where it has both sides' copies; `git checkout --ours` where only this side's is left (the other side deleted it); `git checkout --theirs` where only the other side's is; `git checkout-index -f --stage=1` where both sides deleted it. Each is followed by `git add .gitmodules`. - **`gitmodules-unstaged`** (`remove` and `add`): the work tree's copy is not the index's. That means one of: - git lists it as modified (`git ls-files -m`). Where `git add` would still stage the index's own blob from it (`git hash-object --path=.gitmodules` says so, and it is a regular file), the refusal adds that the difference may be its line endings alone; - it is unmerged; - its deletion is staged while the file is still on disk; - the index marks it assume-unchanged, which `ls-files -m` does not look past, and its content is not the index's blob; - it is untracked: neither the index nor HEAD holds one. This includes the first member's `add` in a new holder. The remediation offers both ways out: stage the work tree's copy, or put back the index's (or HEAD's) copy. Behind assume-unchanged, staging first takes the mark off (`git update-index --no-assume-unchanged -- .gitmodules`), because `git add` stages nothing while it is on. An untracked file is staged with `git add -f .gitmodules`, since an operator's global excludes may match it (and, if wanted, committed on its own first), or moved out of the holder. - **`gitmodules-sparse`**: the index marks `.gitmodules` skip-worktree, as a sparse checkout that leaves it out does. git neither compares nor stages that file, and `git checkout -- .gitmodules` matches nothing. The remediation is `git sparse-checkout disable`. - `add` refuses it whatever the file's state, since `git submodule add` cannot stage it. - `remove` refuses it where the file is missing or is not the index's copy, and commits a clean copy on disk rightly, as it did on bfed143. Every refusal ends with "No git command that writes was run and nothing was written.", and every remediation ends with "run the same command again". Typed as printed, each one leads to the right commit (the tests do exactly that). `remove` asks this first, before #360's `member-path-registered-twice`. `add` asks it after its mount-path refusals (#242, #300), which already name what they find in the same file. Two of those, `member-name-taken` and `gitmodules-unreadable`, have the person mend the file by hand and run `add` again. They now say "then `git add .gitmodules`", as #300's and #360's remediations already did. Without that step, the run again was this PR's own `gitmodules-unstaged`. The holder's `validate-family.py` keeps its words: it reads the work tree's copy, so re-running it needs no stage. | Point | What changed | |---|---| | S1 | `member-name-taken` and `GITMODULES_UNREADABLE` say "then `git add .gitmodules`". New test: each one's state, mended by hand, the printed stage typed, and `add` then mounts the member. The text tests are updated (`test_family_add_excludes.py`'s `member-name-taken` test, and `test_refusal_default_remediation.py`, which now holds `add`'s remedy equal to the gate's but for the one step). `test_family_add_refusals.py::test_a_gitmodules_git_cannot_read_is_a_refusal_not_a_pass` pins the validator's text, which is unchanged, so it is untouched, and so is the `:518` test, which asserts only the REFUSED line. | | N1 | The line-endings clause, only where `hash-object` gives the index's blob from a regular file. Tested with a CRLF copy (clause) and a link to the very same bytes (no clause: `hash-object` reads through the link, but git would stage the link). | | N2, sparse | `gitmodules-sparse`, with `git sparse-checkout disable`. Tested by a real `git sparse-checkout set --no-cone '/*' '!/.gitmodules'`, for both verbs, typed as printed. | | N2, modify/delete | The per-stage restores above. Tested for stages 1+2 (`--ours`), 1+3 (`--theirs`) and 1 alone (`checkout-index`), each typed as printed. | | N3 | `test_a_holder_whose_head_has_no_gitmodules_is_not_asked` asserts only that no `gitmodules-` refusal is made. Its docstring says the real run fails there, a known case from before #407 that goes to #459. | | N4 | The base split below is corrected. | | N5 | `unmerged()` and `typed_as_printed()` run git through `raw(root, env, ...)`, under the module's isolated environment. `raw()` gained a `stdin` argument for `update-index --index-info`. | | N6 | `held()` and `assert_held()` are gone. Refusals are read with `test_family_dry_run.state()` and `assert_as_it_was()`; `state()` now reads a missing `.gitmodules` as None (three lines). | | Codex, `:4476` | The untracked `.gitmodules` is `gitmodules-unstaged`, for both verbs, dry run alike. Tested: the first `add` in a holder with no member, and `remove` in the holder #407 leaves behind with the old bytes put back untracked. Staged as printed, each commits. | | Codex, `:4519` | assume-unchanged: compared by content (`hash-object` against the stage-0 blob) and refused. skip-worktree: `gitmodules-sparse`, as for N2. Tested for both verbs: the assume-unchanged edit kept through the printed `update-index` and `add`, and the skip-worktree edit through `git sparse-checkout disable`, then the restore the next refusal prints. | | Nit | What changed | |---|---| | 1 | `remove` again commits a clean `.gitmodules` the index marks skip-worktree, as bfed143 and b1120c3 did: `gitmodules-sparse` is asked for `add` whatever the file's state, and for `remove` only where the file is missing or is not the index's copy. `add`, an edited file and a missing one stay refused. The function's and the module's docstrings say which verb refuses what. New test: `remove` with the mark on and the file clean commits exactly `.gitmodules`, `family.yaml` and the gitlink. | | 2 | Two cases the round-2 tests left open. X1: `add` over a file marked both skip-worktree and assume-unchanged (two `update-index` calls, `ls-files -v` tag `s`) is `gitmodules-sparse`. X2: a clean assume-unchanged file passes, and both verbs commit the right tree. Each set-up asserts the `ls-files -v` letter it expects. | | 3 | The untracked branch prints `git add -f .gitmodules`. The untracked `add` test also runs under an operator's `core.excludesFile` holding `*` (set as #242's tests set it), asserts that the file is ignored there, types the remedy, and `add` commits. | | 4 | Decision 5's wording. Round 2's commit subject said "no printed command leads into another refusal", which is inexact: for an edit hidden by skip-worktree, `git sparse-checkout disable` leads, by design, into `gitmodules-unstaged`, and `test_an_edit_skip_worktree_hides_is_refused_then_named_as_modified` pins that chain. This round's commit subject says only what it does. | 1. **Refuse, not rebuild (the issue's first option).** The holder is left byte for byte as it was, and the person picks the copy they mean. Building this member's section out of the index's blob would commit a `.gitmodules` the work tree does not have. The holder would then show `.gitmodules` as deleted, and the person's next `git commit -a` would delete it after all. For a modified copy it would also have to guess which copy the person means. 2. **A staged change still goes in.** The remediations of #360 and #300 tell the person to edit `.gitmodules`, `git add` it, and run the same command again. So the rule is "the work tree's copy must be the index's", not "must be HEAD's". 3. **"Modified" is git's own answer.** It comes from `git ls-files -m`, the comparison `git status` uses and the one `git rm` refuses an unstaged change with. On bfed143, an unstaged edit, a CRLF-only copy and a symlink were each refused by `git rm` only after `git submodule deinit -f` had emptied the member (then rolled back), and each dry run exited 0. The CRLF case is still refused (as `git rm` refuses it), and now says why it may look like no change: its `hash-object` is the index's blob (probe: both `26f54704`). 4. **Assume-unchanged is compared by content; skip-worktree is refused outright.** Probes against round 1's check (`states-wip.log`): - assume-unchanged, clean: both verbs commit correctly, so it is not refused. Edited: `ls-files -m` is empty and both verbs committed the edit. `git add .gitmodules` with the mark on exits 0 and stages nothing (the hash stays `a2d4d491` against the index's `26f54704`), so the keep-remedy takes the mark off first. `git checkout -- .gitmodules` restores it with the mark on. - skip-worktree, file on disk: `remove` committed a hidden edit; `add` failed at its mount even for a clean file (`git add` refuses a path outside the sparse-checkout definition, then rollback). `git add` and `git checkout --` both fail while the mark is on. So `add` refuses any skip-worktree `.gitmodules`, on disk or not. `remove` refuses one that is missing or is not the index's copy, and commits a clean one on disk rightly, as it did on bfed143 and b1120c3 (round 3; round 2 refused that one too). 5. **`git sparse-checkout disable` is the one command that works as typed both in a sparse checkout and where only the mark is left.** `git sparse-checkout add /.gitmodules` is narrower in a real non-cone sparse checkout, but fails for a mark set by hand (`fatal: no sparse-checkout to add to`). With sparse checkout on, `git checkout --ignore-skip-worktree-bits -- .gitmodules` and `git update-index --no-skip-worktree` + `git checkout --` both put the file back, but `remove` then failed at its commit's `git add` ("outside of your sparse-checkout definition"). `disable` worked with sparse checkout on and with only the mark left, and keeps a copy already on disk as it is. The refusal names the state in its own words. It is a dozen lines, not a sparse-definition reader. 6. **An untracked file is staged, not committed by the printed command, and staged with `-f`.** `add`'s branch check (#487) comes after this refusal, so a printed `git commit` could land on the tracking branch. Staged, the file goes into the verb's commit; the remediation says to commit it on its own first if that is not wanted. An operator's global excludes may match the untracked file (#242's `*`), and a plain `git add` then refuses it, so this branch prints `git add -f .gitmodules`. The tracked branches keep the plain `git add`, which stages a tracked file under the same excludes. 7. **The dry run (N9).** In the issue's state, `remove --dry-run` prints the same refusal, exit 2. Past the refusal, the work tree's file is the index's copy, or no copy exists anywhere. So `deinit` reads the same name the dry run reads. 8. **`bump` is not affected.** Its commit is `[path, MANIFEST]` and never stages `.gitmodules`. Probe: `bump` with the work-tree `.gitmodules` deleted committed `family.yaml` and `members/Atlas` only. `tests/test_family_remove_gitmodules.py`, 35 tests: - `remove` over each state the work tree lacks (6): deleted (the issue), deletion staged, and unmerged with the file deleted at stages 1+2+3, 1+2, 1+3 and 1. Both runs refuse alike, exit 2, nothing on stdout, and the holder unchanged: `test_family_dry_run.state()`, which is HEAD, `ls-files -s -z`, `status --porcelain=v1 -z`, `family.yaml`, `.gitmodules`, `.git/config`, the member's HEAD, every work-tree byte and the holder's objects. The remedy is typed as printed; `remove` then exits 0, HEAD's `.gitmodules` gives only the other member's path, and the gate passes; - the issue's clone: after the restore and `remove`, a push and a fresh `clone --recurse-submodules` populate the other member, and the clone's gate exits 0; - each work-tree copy that is not the index's, for both verbs (12): edited, CRLF only, a link to the same bytes (skipped on Windows), assume-unchanged and edited, unmerged, deletion staged with the file kept. One printed remedy is typed, and the verb then commits; - sparse (4): a real sparse checkout for both verbs, and `add` over a clean file marked skip-worktree by hand and over one marked both ways (`ls-files -v` tag `s`); skip-worktree with an edit (2), both verbs; - a clean file behind a mark, committed with exactly `.gitmodules`, `family.yaml` and the member's gitlink (3): skip-worktree (`remove`), assume-unchanged (`remove` and `add`); - untracked (3): `add` in a new holder, with no excludes and under an operator's `*`; `remove` in a holder #407 already damaged; - `add` over a missing file is still git's own refusal, with its dry run's note; - a holder whose HEAD lost `.gitmodules`: no `gitmodules-` refusal (N3); - S1 (2): `member-name-taken` and `gitmodules-unreadable`, mended by hand, the stage typed as printed, `add` then mounts. Round 2's 29 tests on round 1's head b1120c3: `15 failed, 14 passed in 80.15s`. The 15 are exactly round 2's cases: CRLF's clause (2), sparse (2), skip-worktree (2), untracked (2), S1 (2), the `--ours`, `--theirs` and `checkout-index` restores (3), and assume-unchanged (2). On bfed143 with them: `27 failed, 2 passed in 59.67s`; the 2 that pass pin behaviour the fix keeps (`add` leaves a missing file to git, and N3's holder is not asked). Round 1's base proof, corrected (the review's N4): on bfed143 with round 1's 14 tests, `12 failed, 2 passed`. Of the 12, 8 failed on "HEAD changed" (the verb committed; the clone test is among them), 3 on ".git/config changed" (`lacks[deletion staged]`, `[edited-remove]` and `[crlf-remove]`: the run failed after `deinit` and the rollback re-wrote the registration) and 1 on "status changed" (`[unmerged-remove]`). In `lacks[deletion staged]` it was not `git rm` that failed after `deinit` but `_commit_one`'s `git --literal-pathspecs add -f -- .gitmodules family.yaml` (`pathspec '.gitmodules' did not match any files`). On this head (ebe6b51), Linux, `/usr/bin/python3 -m pytest`, the files round 3 touches and the importers of the changed helpers: `tests/test_family_remove_gitmodules.py tests/test_refusal_default_remediation.py tests/test_family_add_excludes.py tests/test_family_dry_run.py tests/test_family_member_path.py`: `182 passed in 224.37s (0:03:44)`. The new file alone: `35 passed in 46.50s`. Round 2's targeted runs, on cb159b1 (round 3 changes `family.py`'s skip-worktree condition and one remedy, and the new test file): - `tests/test_family_remove_gitmodules.py tests/test_family_bump_remove_rollback.py tests/test_family_dry_run.py tests/test_family.py tests/test_cli_reference.py tests/test_repo_hygiene.py tests/test_family_member_path.py tests/test_family_tracking_branch.py tests/test_family_printed_quoting.py tests/test_family_member_name.py tests/test_family_bump_to_a_tag.py tests/test_family_init_landing.py tests/test_family_init_values.py tests/test_template_text_placeholders.py`: `706 passed, 19 skipped in 1013.94s (0:16:53)` (the skips are `test_family_init_values.py`'s template-quoting cases, as on main); - `tests/test_family_add_refusals.py tests/test_validate_family_duplicates.py tests/test_validate_family_member_path_glob.py tests/test_validate_family_unnamed_mounts.py tests/test_validate_family_without_git.py tests/test_family_add_excludes.py tests/test_family_add_member_id.py tests/test_refusal_default_remediation.py tests/test_agents_md_codes.py tests/test_shape_doctor.py`: `632 passed, 3 skipped in 1482.84s (0:24:42)` (the skips are the Windows-only PowerShell line). Windows: every file the tests write is written with `write_bytes`, git's stdin gets bytes, and nothing compares against a file a helper wrote with `write_text`. The holder's attributes keep `.gitmodules` LF in the work tree (`i/lf w/lf attr/text=auto eol=lf`). The symlink case skips on Windows, where git checks a link out as a plain file. macOS: no case variants are involved. The issue's script, with local bare remotes, a `gh` that exits 97, no global or system git configuration, and the registrations read from `.git/config` by `grep`: - on round 2's head (cb159b1): `dry run: exit 2` and `remove: exit 2`, both `REFUSED gitmodules-missing: ...` with the same stderr, and a full snapshot of the holder (every file, `.git` included) identical after each. The registration sections stay 1 and 1. After a push of the unchanged holder, the recursive clone exits 0, `members/Atlas` holds 15 entries, and the clone's gate prints `family ok`. - round 1 typed the remedy as printed (`git checkout -- .gitmodules`): `remove: exit 0`, a commit with `.gitmodules | 3 ---` (Nova's section only), the holder's gate exits 0, and after a push the recursive clone populates Atlas and its gate exits 0. The lacks and clone tests repeat that on this head. - round 2's states, on cb159b1: for `remove` and `add`, a real sparse checkout, a skip-worktree edit, an assume-unchanged edit (kept, and dropped), CRLF and a link; for `remove`, an assume-unchanged deletion, the unmerged file deleted at stages 1+2, 1+3, 1 and 2+3, and the untracked file in a damaged holder; for `add`, the untracked file in a new holder. Each is refused alike by both runs (identical stderr), each printed command exits 0 as typed, and the run again commits what the remedy chose. Round 3 changes two of these paths (a clean skip-worktree file for `remove`, and the untracked remedy's `-f`), and its tests on ebe6b51 hold both. Nothing here needs real GitHub or a cloud tool. Round 2's code hunks, each reverted alone in a throwaway worktree at cb159b1, with the new file run against it: | hunk reverted | result | new tests that fail | |---|---|---| | M1 S1, `GITMODULES_UNREADABLE`'s stage | 1 failed, 28 passed | S1 `[gitmodules-unreadable]` | | M2 S1, `member-name-taken`'s stage | 1 failed, 28 passed | S1 `[member-name-taken]` | | M3 the untracked refusal (Codex, `:4476`) | 2 failed, 27 passed | both untracked tests | | M4 `gitmodules-sparse` | 4 failed, 25 passed | sparse `[remove]`, `[add]`; skip-worktree `[remove]`, `[add]` | | M5 the per-stage restores (always `--merge`) | 3 failed, 26 passed | `lacks[our side only, deleted]`, `[their side only, deleted]`, `[neither side, deleted]` | | M6 the line-endings clause (N1) | 2 failed, 27 passed | `[crlf-remove]`, `[crlf-add]` | | M7 `_stages_as`'s regular-file guard | 2 failed, 27 passed | `[a link to the same bytes-remove]`, `[...-add]` | | M8 the assume-unchanged branch (Codex, `:4519`) | 2 failed, 27 passed | `[assume-unchanged, edited-remove]`, `[...-add]` | | M9 the flags read as `H` (`_gitmodules_in_index`) | 6 failed, 23 passed | the 2 assume-unchanged, 2 sparse and 2 skip-worktree cases | | M10 the return where nothing holds a `.gitmodules` | 29 errors | every test, at the family fixture's first `add` into a holder with no `.gitmodules` anywhere, which is then refused as untracked | Round 1's hunks are still held by the same tests (round 1's probes M1 to M8). The other hunks are docstrings and comments. Round 3's hunks, each reverted alone at ebe6b51, with the new file run against it: | hunk reverted | result | new tests that fail | |---|---|---| | R1 the narrowing (always refuse skip-worktree) | 1 failed, 34 passed | `[skip-worktree-remove]` of the clean-behind-a-mark test | | R2 its `verb == "add"` arm | 2 failed, 33 passed | sparse `[marked by hand, clean-add]`, `[marked both ways, clean-add]` | | R3 X1, `tag == "S"` without `.upper()` | 1 failed, 34 passed | sparse `[marked both ways, clean-add]` | | R4 X2, every assume-unchanged copy refused | 3 failed, 32 passed | `[assume-unchanged-remove]`, `[assume-unchanged-add]`, sparse `[marked both ways, clean-add]` | | R5 plain `git add` in the untracked remedy | 3 failed, 32 passed | the three untracked tests (the pinned text) | | R5b plain `git add` in the remedy AND the test's expected text | 1 failed, 2 passed (`-k untracked`) | `[*]`: typing `git add .gitmodules` fails, "The following paths are ignored by one of your .gitignore files ... Use -f" | `git merge-tree --write-tree` of this head (ebe6b51) with each of these is clean: - origin/main dd3f23a. Since bfed143, main took #551, #552, #554, #553, #561, #562, #579, #567, #558, #556 and #580; none touches `scripts/family.py` or a test file this branch changes. This branch does not merge main; - `fix/family-bump-to-checks` 8024f9c (#568), `fix/family-members-case-variants` 8a95345 (#574), `fix/link-source-chain-defaults` a707d4b (#576), `fix/family-siblings-tracking-branch` 2156a01 (#578); - lane 2's `fix/537-dry-run-asks-remotes` 69c58aa (#573), and #575's head 0680799 (lane 2, which changes `validate-family.py`; its unreadable-file remedy still ends "then run this again.", which `test_refusal_default_remediation.py` expects). - **(a) `family.yaml` hand edits are committed by `add`, `bump` and `remove`.** Each commits `family.yaml` as the work tree has it, so an unstaged hand edit outside `members:` (a comment line) goes into the commit: the review reproduced `+# a hand note nobody staged` in a commit of each verb. It is #407's rule for a different file, and a staged-only rule there would chain into every remedy that says "edit `family.yaml`, then re-run", as S1 did here. It needs its own issue. - **(b) A HEAD that already lost `.gitmodules` fails after the first write.** With no `.gitmodules` anywhere, this check asks nothing. `remove --dry-run` exits 0 and says no section is removed; the real `remove` runs `deinit` and `git rm`, then dies at `git --literal-pathspecs add -f -- .gitmodules family.yaml` (`pathspec '.gitmodules' did not match any files`) and puts back only part of the unmount (`members/Nova` is left empty). Same on bfed143. Once the old file is put back in the work tree, this PR now refuses it as untracked, and staged as printed `remove` commits it back into HEAD; but nothing tells the person to do that. - **(c) The gate passes a row with no `.gitmodules` section (#459, the issue's decision 3).** `validate-family.py` should find a `members:` row whose path no section of HEAD's `.gitmodules` gives. A holder already carrying a #407 commit passes its own gate, and only a fresh recursive clone shows it. - **(d) A sparse checkout whose rules leave `.gitmodules` out, with the file put back on disk by hand.** git then clears the skip-worktree mark, so this check passes, and `remove`'s commit fails at `git add` ("outside of your sparse-checkout definition") and is rolled back. Nothing wrong is committed; it is a late failure, and reading the sparse rules is not this PR's. - **(e) A verb run during a merge, cherry-pick or rebase fails after its first writes.** Any `family.py` verb run while one is in progress fails at its commit, `git commit -- <paths>` ("fatal: cannot do a partial commit during a merge."), and is rolled back. It is the same on bfed143. It touches this PR because its `--ours` and `--theirs` restores name modify/delete conflicts, whose commonest source is `git merge`: typed as printed the restore works, then `remove` fails late. The tests build those stages with `update-index --index-info` and no `MERGE_HEAD`, the state a conflicted `git stash pop` leaves. The candidate: refuse an in-progress merge, cherry-pick or rebase before the first write, beside the existing up-front refusals. Review: rv407 found 1 should and 6 nits on b1120c3; fix round 2 (cb159b1) fixed them and answered Codex's `:4476` and `:4519` threads; re-review rv407b found 4 nits, fixed in round 3 (ebe6b51). CI (Brett Heap's ruling 27): H0 ebe6b51 passed tests, tests-macos, tests-windows and SonarCloud; the head 52c7c88 is a clean update-branch merge of main 2989b4c, with tests and SonarCloud green. Landed on Brett Heap's word of 2026-10-15T13:32:47Z, "All five, once green". Closes #407 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
…, any path under another spelling of `members/`, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) (#574) Brett Heap ruled on 2026-10-14, "Refuse on every disk": refuse before reading the member, name the entries and a committed `git mv` as the remedy, use one rule for the index, HEAD and the disk, and give the same answer on Linux, macOS and Windows, as #318's any-case rule for the mount path does. The cost he accepted: a Linux holder with its own `Members/` folder renames it first. What went wrong before, all reproduced on bfed143: - **#362, the index.** With `core.ignorecase` set, an index entry `Members/README` made git stage every new gitlink as `Members/<P>`. The commit, whose pathspec is `members/<P>`, matched nothing, and the remedy the refusal printed failed the same way every time. With `core.ignorecase` unset, the add committed and left `Members/` beside the member, ready to break the next clone on macOS or Windows. - **#363, HEAD.** HEAD held `Members/README.md`, and an uncommitted `git rm -r Members` had taken it out of the index. `add` then committed the gitlink as `Members/<P>` with exit 0, while `.gitmodules` and `family.yaml` said `members/<P>`. A fresh clone left the member empty and failed `member-gitlink-absent`. - **#364, the disk.** `_checked_mount_path` checked the disk with an exact `os.path.lexists`. An untracked `Members/<P>` was refused on macOS and Windows, and on Linux the member was mounted beside it. What `add` asks now, in `_checked_mount_path`, before it clones, fetches or mounts anything, and before it prints anything about the member: 1. **Which directory is `members/` on this disk (`_members_dir_on_disk`).** The holder's root is listed for every spelling of `members`, with ASCII case folded. `members` is `members/` where it is listed. Where it is not, a directory under another spelling is `members/` itself only when the disk says so: `os.path.isdir(root/'members')` is true and `os.path.samefile(root/'members', root/<spelling>)` names the same directory. That happens on macOS and Windows, never on Linux, and never for a link. 2. **The mount path in that directory, by listing.** An exact `<P>` there gets the same `already exists` refusal as before, word for word. Under another spelling that is `members/` itself, the exception is a `<P>` the index holds under another spelling; the next two questions name that. 3. **#318's any-case index check of the mount path.** Its text and `git rm -r --cached` remedy are unchanged where every variant it holds has nothing on disk. A variant that is on disk goes to the next question instead, which renames it. 4. **`_members_spellings`.** It looks in three places, and gives each path the remedy that acts on it: - every index entry under a spelling of `members/` other than `members/` itself, and every case variant of the mount path; - every file and gitlink HEAD holds there that the index no longer holds (`git ls-tree` of HEAD's root tree, then of each matching entry's object, with no pathspec; an unborn HEAD holds nothing); - what the disk holds there and no index entry covers. 5. **`lexists` of the mount path**, still asked, last, for a directory no listing could read. The refusal (`_spellings_refusal`): - It names each path in its own spelling and says where it was found: `in the index`, `in HEAD and no longer in the index`, or `on disk and untracked`. Each place names at most five paths, then a count. - **Every remedy acts on one entry, never on another spelling's directory.** On macOS and Windows `Members/` and `members/` are one directory, holding the members' checkouts, so a rename of `Members` would move them all. The entry is the one directly under the other spelling (`Members/README`, `Members/docs`, `Members/<P>`), or the spelling itself where it is a file or a link. Where that entry's name folds to one `members/` itself tracks (`Members/irrs` beside `members/IRRS`), it is the full entry instead. - The remedies run in this order: 1. what HEAD holds and the index no longer does: commit the pending change first (`git commit`); 2. what the index holds: - on disk in its own spelling: `git mv -- <entry> <new name>`; - kept off disk by a sparse checkout: `git mv --sparse -- <entry> <new name>`; - nothing on disk: `git rm -r --cached -- <entry>...`, which touches no file; - then a commit, where there is something to commit; 3. what is on disk and untracked: `mv -- <entry> <new name>`; 4. a `members/` renamed by hand on Linux: `mv -- Members members`. - Every path in a command is quoted for a POSIX shell (`shell_quoted(..., 'posix')`, #532). - The holder stays byte-identical. The tests compare the snapshot of `tests/test_family_add_refusals.py` and the whole index, byte for byte. Example: #364's state on Linux, an untracked `Members/Atlas` in a holder that has `members/Nova`: ``` REFUSED member-path-taken: the family root holds a case variant of members/Atlas, the same path on a case-insensitive disk, though no `members:` row names it — on disk and untracked: `Members/Atlas` (a directory) Remediation: every member is mounted under members/, and a holder keeps that directory in one spelling on every disk. Move what is on disk and untracked out of the way (`mv -- Members/Atlas <new name>`); then re-run. ``` On macOS and Windows the same `mkdir -p Members/Atlas` lands inside `members/`, which is then the mount path itself. The refusal there is the one it always was, with the same code: ``` REFUSED member-path-taken: members/Atlas already exists in the family root but no `members:` row names it Remediation: this is a half-finished add or a hand-made directory. Remove it (`git rm -r` if it is tracked), then re-run. ``` Example: #362 on a real disk, a committed `Members/README` beside `members/Nova`. In a fresh clone this text is the same on all three systems. A macOS or Windows holder that PULLED the commit keeps the directory spelled `members`, and gets `git rm -r --cached` instead (a checklist candidate below): ``` REFUSED family-members-dir-variant: the family root holds a path at or under another spelling of members/, which a case-insensitive disk takes for members/ itself — in the index: `Members/README` (a file) Remediation: every member is mounted under members/, and a holder keeps that directory in one spelling on every disk. Rename what the index holds with `git mv`, entry by entry (`git mv -- Members/README <new name>`), and commit the rename; then re-run. ``` Head `8a95345b` is one new commit on `40eda849`. The review graded one blocker, three should and five nits; Codex added a P2. - **B1 (blocker): the printed `git mv -- Members` / `mv -- Members` moved every member's checkout on macOS and Windows.** Every remedy now acts on one entry under the other spelling, never on that directory (`_spelled_target`). `_members_dir_on_disk` asks the disk which directory is `members/` (`isdir` + `samefile`). Where it is spelled `Members`, it is treated as `members/` itself: an exact `<P>` in it is `already exists`, a case variant `<p>` is targeted as `Members/<p>`, and an index entry that IS that directory is never renamed. Scenarios `macsingle-mac` and `r318mac-mac` type the new remedy and show `Members/Nova/.git` still in place; `test_the_printed_git_mv_leaves_the_members_where_they_are` does the same in the suite. - **S1, with Codex's P2 at `scripts/family.py:2099`: rule (b) hid a real Linux variant.** Rule (b) is gone; only the alias that `isdir` + `samefile` confirm is `members/`. On Linux, Codex's state (`members/` renamed `Members`, nothing staged) is refused, and its remedy moves the directory back (`mv -- Members members`). The sign is a member's checkout in it (`Members/<name>/.git`, with the gitlink `members/<name>` in the index) and no `members/` on disk. A human's own `Members/` holding a directory named like a member (`ruleb`) is named entry by entry. `tests/test_family_members_case_variants.py:359-371`, which pinned Codex's case as "not named", is replaced. The layout table asks the same states of an emulated folding disk and an emulated Linux disk on every OS, and `test_a_members_directory_renamed_by_hand_is_moved_back` asserts the answer of the disk the test runs on. - **S2: `git mv` failed for an index entry with nothing on disk (`bad source`), and for a sparse one (`not under version control`).** Such an entry is now taken out of the index (`git rm -r --cached -- <entry>`, touching no file). The commit after it is printed only where HEAD holds the entry, or will once the pending change is committed. An entry the sparse checkout keeps off disk gets `git mv --sparse -- <entry> <new name>`, which keeps its content. Scenarios `idxonly` and `sparsemv` type each. - **S3: #318's tracked `Members/<P>/...` took three refusals.** #318's index check now keeps its text only where every variant of the mount path it holds has nothing on disk; its pinned test is unchanged. A variant that is on disk goes to the new refusal: one committed `git mv -- Members/<P> <new name>`, one round (scenario `r318`; `test_a_tracked_path_on_disk_takes_one_committed_git_mv[Members/IRQS/README]`). - **N1: two remedies collided (`mv: cannot stat 'Members'`).** A disk path the index holds, holds anything above, or (for a move out of the way) holds anything under, is not named on disk. Per-entry targets cannot collide: `git mv -- Members/README` and `mv -- Members/Atlas` now run in order (scenario `overlap`). - **N2: U2, U3 and U4 survived.** `test_a_spelling_that_is_a_link_is_named_and_not_entered` kills U2 (a link entered, probe P19) and U3 (no `a symlink`, P20). `test_a_name_head_holds_that_is_not_utf8_is_shown_replaced` kills U4 (P14). - **N3: the "#364's state on any OS" example and the docstring at `:278-280`.** The example above now says Linux, and shows the macOS/Windows text beside it. The test that docstring belonged to is replaced by two: one for the states every disk answers alike, and one that asserts per disk (`test_an_untracked_members_directory_is_moved_entry_by_entry_on_linux`). - **N4: "hold as one directory with it" for a FILE named `Members`.** The sentence is now "a path at or under another spelling of members/, which a case-insensitive disk takes for members/ itself", true of a file too. - **N5: `folding_lexists` walked from `/`.** It is replaced by `disk_as`, which resolves names below the holder root only and passes every other path through unchanged. Head `e5aac93f` adds one commit on `8a95345b`. It changes only `tests/test_family_members_case_variants.py` (+102); no product code changes. The re-review (rvcase2) said APPROVE WITH NITS: every point above is fixed, every scenario ends clean as typed, and five nits remain. - **NIT-1: ten conditions no test caught.** rvcase2's probes Q1-Q7 and Q9-Q11 each left all 57 tests passing. Four new rows in the layout table, each asked of both disks, and one new unit test now guard them: - a link `Members` to a directory outside the holder, `members/` gone (Q1, the alias may be a link). A folding disk resolves `members` through the link, and it must still be named as a link. This is now caught on every OS by the emulated folding disk, not by macOS CI alone. The row skips on Windows, before the link is made. - three committed entries under `Members/` (Q2-Q5): a gitlink `X` with its directory on disk (`git mv`); a file `README` where the disk holds a directory (`git rm -r --cached`); and `docs/a`, kept off disk by `skip-worktree` (`git mv --sparse` of the entry itself). HEAD holds the removed entry, so the text says "and commit both". - a file at `Members/IRQS`, in the index and on disk (Q11): #318's index-only text must not take it. - a gitlink `Members` over a human's `Members/notes.txt` (Q10): on Linux, the file under that entry is the index's to name. - the move back of a renamed `members/` (Q6, Q7, Q9). This needs two spellings side by side, which only a disk that does not fold case can hold, and no test makes them. So `_disk_spellings` is asked of a listing handed in, with nothing read from a disk: the move back happens once only, never where `members/` is on disk, and never for a `.git` that no gitlink names. - **rvcase2's twelve probes (`rvcase2/mut2.py`), re-run against these tests** with `-x`, the new file plus #318's three tests. Every probe now fails a test: | probe | result | the first test that fails | |---|---|---| | Q1 alias may be a link | `1 failed, 46 passed` | layout `a link Members, no members/-folding` | | Q2 on-disk kind ignored | `1 failed, 47 passed` | layout `and every remedy the index takes-linux` | | Q3 gitlink not asked for a directory | `1 failed, 47 passed` | the same row | | Q4 sparse entry keeps its group target | `1 failed, 47 passed` | the same row | | Q5 "commit both" wording | `1 failed, 47 passed` | the same row | | Q6 move back more than once | `1 failed, 53 passed` | `test_only_a_spelling_that_is_members_renamed_is_moved_back[once, though two spellings hold checkouts]` | | Q7 move back where `members/` is on disk | `1 failed, 54 passed` | `...[never where members/ is on disk]` | | Q8 move back without a `.git` | `1 failed, 39 passed` | layout `a human's own Members/, no members/-linux` | | Q9 move back without the gitlink | `1 failed, 55 passed` | `...[never for a .git no gitlink names]` | | Q10 a path under an index entry named | `1 failed, 51 passed` | layout `a gitlink Members over a human's Members/-linux` | | Q11 every mount variant taken as a directory | `1 failed, 49 passed` | layout `and a file Members/IRQS in the index and on disk-linux` | | Q12 no case for a bare `Members` | `1 failed, 4 passed` | `test_an_index_entry_with_nothing_on_disk_is_taken_out_of_the_index[Members-Members-true]` | - **Tests at `e5aac93f`:** the new file (60 tests) and `tests/test_family_add_refusals.py` (65): `125 passed in 105.50s`. - **NIT-5, this body:** P4's row now reads "7 of the 9 folding rows"; the #362 example says its text holds in a fresh clone; and the merge checks are refreshed. - **NIT-2, NIT-3 and NIT-4** are checklist candidates below. - **The new code is `family-members-dir-variant`**, used where every path found is under a spelling of `members/` alone. Codes about the holder as a whole start with `family-` (`family-index-locked`, `family-on-tracking-branch`, `family-members-unrecognised`). `members-dir` is `family.yaml`'s own name for that directory (`members_dir`). `variant` is the word #318's refusal and the validator use. `member-path-taken` stays wherever any path is a case variant of the mount path `members/<P>`, as #318 and #364 have it. - **The alias is asked, never guessed.** `isdir` and `samefile` are what the disk says. On Linux `members` is simply not there when only `Members` is (Codex's state), and with two directories `samefile` is false. A Linux `members` symlink is listed as `members`, so it is never the alias. A Linux `casefold` directory answers as macOS does, which is right. - **Per entry, not per file.** The remedy names the entry directly under the other spelling, so `Members/docs/` with 300 files is one `git mv`. A `git mv` of a directory is one `rename(2)`, so its untracked contents go with it, and a mount variant `Members/<P>` is cleared in one step on every disk. The full entry is used only where the name folds to something `members/` itself tracks. - **"On disk" means in its own spelling (`_on_disk_as_named`).** It reads listings component by component, exactly. On macOS `lexists("Members")` is true because `members/` exists, and `git mv -- Members X` there would move `members/`. With exact listings an index file `Members` beside `members/` is taken out of the index instead, on every disk. - **An empty directory under another spelling is not named.** `git mv` and `rm` of its last entry leave one behind on Linux; it holds nothing a clone or a disk could meet. A directory nobody may list is named and moved whole, since nobody can tell it is empty. - **A `members/` renamed by hand is moved back (Linux).** The sign is a member's checkout in it: `<spelling>/<name>/.git`, with the gitlink `members/<name>` in the index, and no `members/` on disk. A human's own `Members/` holding a directory named like a member, with no checkout in it, is named entry by entry instead. - **The commit after `git rm --cached` is printed only where there is one to make:** where HEAD holds the entry, or will once the pending change is committed. A staged-only entry, #362's own planted state, leaves nothing to commit, and a printed `git commit` would fail with `nothing to commit`. - **`git mv --sparse` needs git 2.34 or later**, the release that made `git mv` and `git rm` refuse paths outside the sparse checkout. Its test skips on an older git. - **#318's unit test** (`test_the_index_is_asked_at_the_mount_path_in_any_case_and_literally`) pinned "a variant of `members/` alone: not refused, by design". Its docstring cites Brett Heap's ruling of 2026-10-14 ("Refuse on every disk"). It asserts that `_members_spellings` names each entry with its target and remedy. - **#318's end-to-end test of the unwind's closing sentence** runs in process with `_members_spellings` switched off, so the unwind's guard behind the refusal stays tested. - **`add --dry-run` refuses alike**, because the check runs before the dry run branches off. All runs used `run9.sh`: a `gh` stub first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `/usr/bin/python3 -m pytest`, and `PYTHONDONTWRITEBYTECODE=1`. - `tests/test_family_members_case_variants.py` held 49 tests at `8a95345b` (60 at `e5aac93f`; see Round 3). Run against `40eda849`'s `family.py`, with #318's unit test beside them: `40 failed, 10 passed in 60.95s`. The 10 that pass are behaviour round 1 already had right: the controls (2); HEAD's mount variant (2); `members/irqs` untracked (1 end-to-end, 2 in the layout table); the unlistable `members/` backstop; and the folding-disk answers for Codex's state and a human's own `Members/`, which round 1's rule (b) hid on every disk. - At head `8a95345b`, the new file, `test_family_add_refusals.py`, `test_repo_hygiene.py` and `test_cli_reference.py`: `189 passed in 180.78s`. - On the merge of `8a95345b` with origin/main `64fc9a86` (main has rewritten `test_family*.py`'s clone sites since this branch's base), the new file, `test_family_add_refusals.py`, `test_family_member_path.py`, `test_family.py`, `test_repo_hygiene.py` and `test_cli_reference.py`: `338 passed in 353.46s`. - Round 1's two batches, on the bytes of `8a95345b` before a two-line docstring correction: - batch A plus `test_family_add_refusals.py`, 14 files (test_family_members_case_variants, test_family_add_refusals, test_family_member_path, test_family_add_excludes, test_family_member_name, test_family_add_member_id, test_family_dry_run, test_family_tracking_branch, test_family_printed_quoting, test_family_bump_remove_rollback, test_family_bump_to_a_tag, test_family_init_landing, test_refusal_default_remediation, test_template_text_placeholders): `561 passed in 862.50s`; - batch B, 9 files (test_family, test_validate_family_member_path_glob, test_validate_family_unnamed_mounts, test_validate_family_duplicates, test_validate_family_without_git, test_update_shape_pr_base, test_cli_reference, test_repo_hygiene, test_shape_doctor): `671 passed, 3 skipped in 1228.01s`. The 3 skips are the Windows-host PowerShell lines in test_shape_doctor. What the tests cover, and how each stays portable across all three systems: - **Every printed remedy is typed as printed** (`type_remedy`), and the same `add` then pins the member under `members/` alone, with a clean `git status`. - **Index and HEAD states** are planted with `update-index --cacheinfo`, under `core.ignorecase` true and false. A non-UTF-8 name goes in through `--index-info` with bytes on stdin, and is committed by `write-tree` and `commit-tree`, which never look for it on disk. - **Disk states** use a holder with no member yet, or the holder's own `members/` renamed in place. No test makes two directories whose names differ only in case. - **Where the disk decides the answer**, the test either asks the disk it runs on (`disk_folds`, one probe directory) and asserts that disk's answer, or runs in process under `disk_as`, which emulates a folding disk and a Linux disk below the holder root. Both answers are then asserted on every OS. - **Skips** come before any call that could fail: links on Windows; an unlistable directory on Windows, as root, or on a folding disk; `git mv --sparse` on git older than 2.34. - **Windows text mode:** files are written with `write_bytes`, and git's stdin is fed bytes. The review's states (`$Q/v110/rvcase/scen.sh`), rebuilt on a fresh kit (local bare remotes, a `gh` that exits 97, called 0 times). After each refusal, every printed command was typed as printed. Only `<new name>` was filled in: `renamed-N` for a `git mv` (committed, so it stays in the holder) and a path outside the holder for a `mv`. "Linux" is this host. "macOS/Windows" is the same layout judged in process with `isdir`, `samefile` and `lexists` folding case below the holder root. | scenario | `40eda849` | `8a95345b` | |---|---|---| | `macsingle` (Linux) | `git mv -- Members renamed-1` moves Nova: ` D members/Nova`, `member-uninitialized` | `git mv -- Members/README`, commit, `mv -- Members members`: add exit 0, status clean, `family ok` | | `macsingle` (macOS/Windows) | `git mv -- Members ...` (moves Nova there) | only `git mv -- Members/README <new name>`: Nova's checkout stays in `Members/` with its `.git`; the check then passes | | `macleft` (Linux) | `mv -- Members ...` moves Nova: ` D members/Nova` | `mv -- Members/Atlas ...`, then `mv -- Members members`: exit 0, clean, `family ok` | | `macleft` (macOS/Windows) | `mv -- Members ...` | `members/Atlas already exists`, as `linuxleft`; removing it passes the check | | `linuxleft` | `already exists` | unchanged; removed as it says: exit 0, clean, `family ok` | | `codex` | add exit 0, holder split (` D members/Nova`, `?? Members/`) | refused; `mv -- Members members`: exit 0, clean, `family ok` | | `ruleb` | add exit 0, `?? Members/` left | refused; `mv -- Members/Nova ...` and `mv -- Members/README.md ...`: exit 0. ` D members/Nova` stays, because the scenario deleted Nova's checkout; `git submodule update --init` (the validator's own remedy) gives clean and `family ok` | | `ruleb2` | `mv -- Members ...` | `mv -- Members/README.md ...`: exit 0; Nova as in `ruleb` | | `idxonly` | `git mv` fails `bad source` | `git rm -r --cached -- Members/README` (nothing to commit): exit 0, clean, `family ok` | | `sparsemv` | `git mv` fails `not under version control` | `git mv --sparse -- Members/README ...`, commit: exit 0, clean, `family ok` | | `overlap` | second command fails `mv: cannot stat 'Members'` | `git mv -- Members/README`, commit, `mv -- Members/Atlas`: exit 0, clean, `family ok` | | `r318` | 3 rounds; leaves `?? Members/Atlas/` | 1 round, `git mv -- Members/Atlas`, commit: exit 0, clean, `family ok`, HEAD holds `renamed-1/README` | | `r318mac` (Linux) | ends moving Nova | `git mv -- Members/Atlas`, commit, `mv -- Members members`: exit 0, clean, `family ok` | | `r318mac` (macOS/Windows) | `git rm --cached`, then `mv -- Members` | `git mv -- Members/Atlas`: Nova stays; the check passes | | `linux362` | `git mv -- Members`: exit 0 | `git mv -- Members/README`, commit: exit 0, clean, `family ok` | | `twodirs` | `mv -- Members ...` | `mv -- Members/Nova ...`: exit 0, clean, `family ok` | | `mac-ok` (the control) | exit 0 | exit 0, clean, `family ok` | | #363 (`i363`) | `git commit`, then exit 0 | `git commit`, the text now naming `Members/README.md` rather than `Members`: exit 0, clean, `family ok` | | #364 (`i364`) | `mv -- Members ...` | `mv -- Members/Atlas ...`: exit 0, clean, `family ok` | Every refused first `add` printed nothing on stdout. Not reproduced: a real macOS or Windows disk. The folding disk is emulated (`core.ignorecase`, and `isdir`, `samefile` and `lexists` resolved case-insensitively below the holder), and CI's macOS and Windows jobs run the tests on real disks. Each probe changed one hunk of this round in a throwaway worktree, then ran the new file (49 tests) and #318's three tests in `test_family_add_refusals.py` (8 cases), 57 in all. At least one test failed every time: | probe | what it undoes | result | a test that fails | |---|---|---|---| | P1 | per-entry targets (back to the spelling's directory) | 19 failed | `test_the_printed_git_mv_leaves_the_members_where_they_are` | | P2 | the full entry under a name `members/` tracks | 2 failed | `test_an_index_entry_with_nothing_on_disk_is_taken_out_of_the_index[Members/irrs/a-...]` | | P3 | an unlistable directory read as empty | 1 failed | `test_another_spelling_nobody_may_list_is_named_and_moved_whole` | | P4 | the alias (`isdir` + `samefile`) | 8 failed | 7 of the 9 folding rows then in the layout table, and `test_the_printed_git_mv_leaves_the_members_where_they_are` | | P5 | "on disk" by exact listings (back to `lexists`) | 1 failed | layout `a file Members in the index, beside members/-folding` | | P6 | `git mv --sparse` for a skip-worktree entry | 1 failed | `test_an_entry_a_sparse_checkout_keeps_off_disk_takes_git_mv_sparse` | | P7 | another spelling's directory named entry by entry | 13 failed | `test_a_tracked_path_on_disk_takes_one_committed_git_mv[Members/README]` | | P8 | an empty directory not named | 6 failed | `test_a_tracked_file_deleted_from_disk_is_taken_out_and_committed` | | P9 | the move back of a renamed `members/` | 6 failed | `test_a_members_directory_renamed_by_hand_is_moved_back` | | P10 | the alias's own entries judged as `members/`'s | 6 failed | layout `members/ renamed Members (Codex's P2)-folding` | | P11 | never renaming the alias itself | 1 failed | layout `and a gitlink named Members in the index-folding` | | P12 | the move back kept when the index holds things under it | 2 failed | layout `and Members/README in the index (#362, B1)-linux` | | P13 | a disk path dropped when the index holds things under it | 3 failed | `test_a_tracked_path_on_disk_takes_one_committed_git_mv[Members/docs/notes]` | | P14 | U4: a HEAD-only name shown with U+FFFD | 1 failed | `test_a_name_head_holds_that_is_not_utf8_is_shown_replaced` | | P15 | no commit printed after removing a staged-only entry | 12 failed | `test_an_index_entry_with_nothing_on_disk_is_taken_out_of_the_index[...]` | | P16 | the commit after a removal once the pending change is committed | 1 failed | `test_every_place_is_named_and_the_pending_change_is_committed_first` | | P17 | `already exists` in the alias only where the index holds no variant | 1 failed | layout `and Members/IRQS/README in the index (#318)-folding` | | P18 | #318's text only for variants with nothing on disk (S3) | 3 failed | `test_a_tracked_path_on_disk_takes_one_committed_git_mv[Members/IRQS/README]` | | P19 | U2: a link not entered | 1 failed | `test_a_spelling_that_is_a_link_is_named_and_not_entered` | | P20 | U3: `a symlink` named | 1 failed | `test_a_spelling_that_is_a_link_is_named_and_not_entered` | | P21 | N4's wording | 29 failed | every refusal naming `family-members-dir-variant` | The other hunks are docstrings, which change no behaviour. `git merge-tree --write-tree 8a95345 <ref>`, after a fetch at round 3. Every result is clean. Round 3's commit changes only `tests/test_family_members_case_variants.py`, which no ref below touches. - origin/main `f5fc7056166d` (round 2 checked 64fc9a8 and 7d9bec9, both clean): clean. Since 64fc9a8, main has not changed `tests/test_family.py`, `tests/conftest.py`, `tests/test_family_add_refusals.py` or any script these tests load, so round 2's run on the merge with 64fc9a8 still holds for them. #561 has landed, so it is no longer checked as a PR head. - #568 `fix/family-bump-to-checks` `8024f9c914fd`: clean. - #571 `fix/family-remove-gitmodules-missing` `ebe6b512db02`: clean. - #576 `fix/link-source-chain-defaults` `a707d4ba3f54`: clean. - #578 `fix/family-siblings-tracking-branch` `0112dcb63391`: clean. - Lane 2's `fix/537-dry-run-asks-remotes` `69c58aae2a04` and `fix/539-validators-lockstep-remedies` `0680799c7b21`: clean. The tests on the merge with origin/main are in the Tests section. - **#363 option 2, a check of the commit after the fact** (`git ls-tree HEAD` must hold the gitlink at exactly `members/<P>`, else undo). The ruling refuses before the mount, so nothing here needs it. It would still catch a cause nobody has foreseen. - **#363 item 3, the validator option:** `validate-family.py` would say that a gitlink in HEAD at a case variant of a row's path is why the gitlink is absent. That is a pinned template file, and lane 2's #539 is editing its remedies. - **Docs.** `git grep` of AGENTS.md, README.md, `docs/handbook.html` and `templates/family-root/` (README, AGENTS.md, AGENTS-shape.md) found no sentence about `add`'s refusals or about `members/` versus `Members/`, so none became false. AGENTS.md is at its 490-line cap. `templates/family-root/AGENTS-shape.md` is digest-pinned into every holder. - **`docs/release-notes/v1.0.0.md`** lists #362, #363 and #364 as v1.0.0's known limits. That is still true of v1.0.0, so the file is untouched. - **Contrived states, not handled:** a gitlink named `Members` in a Linux holder whose `members/` was also renamed `Members`; Windows' trailing-dot and 8.3 aliases of `members`; NFD names. The case-folding is ASCII, as git's `icase` is, and project names are ASCII. - **`family.py add`: check the commit after the fact (#363 option 2).** After `_commit_one`, read `git ls-tree HEAD` for a gitlink at exactly `members/<P>`. If it is anywhere else, undo this command's commit and the mount, and refuse with the spelling git chose. This catches any cause of a misplaced gitlink, not only the case variants refused here. - **`validate-family.py`: say a HEAD gitlink is at a case variant of the row's path (#363 item 3).** A holder already pushed in #363's state is told `member-gitlink-absent` in a fresh clone, with nothing about the `Members/<P>` that HEAD holds. Reading HEAD's gitlinks folded for case would name the cause. - **`validate-family.py`: the `member-gitlink-unnamed` remedy for a hand mount under `Members/`.** The remedy says `git rm` the gitlink, then `family.py add` it. Under a spelling of `members/` other than `members/`, `add` now refuses until that `git rm` is committed, and says so. The validator's sentence could say "commit the removal" before the `add`. Lane 2's #539 is editing these remedies. - **Docs: name the one-spelling rule for `members/`.** AGENTS.md's family section, the README's family section and the holder's `AGENTS-shape.md` could say that a holder keeps `members/` in one spelling, and that `add` refuses `family-members-dir-variant` until a human renames their `Members/`. AGENTS.md is at its line cap. - **`family.py add`: a file named `members`, or a sparse checkout that leaves `members/` out, is refused late** (rvcase's `mfile` and `sparse`). Both pass the checks, the member is read and mounted, and `git submodule add` then fails; the unwind restores the holder. Both could be refused up front, the second naming `git sparse-checkout add members`. - **`family.py add`: a member's own gitlink renamed with `git mv members Members` (NIT-2).** Staged or committed, the gitlink sits at `Members/<M>`, and the refusal names it with the generic `git mv -- Members/<M> <new name>`. Typed with any name, the add then passes and the validator says `member-gitlink-absent`. The right name (`members/<M>`) fails as typed until `mkdir members` (`$Q/v110/rvcase2/scen-gitmv.log`, `stagedmv`, `committedmv`). The holder's own validator already refuses that state before the add. A refusal that named a gitlink `<spelling>/<M>` whose `members/<M>` is a `members:` row could print `mkdir members` and the move back. - **`family.py add`: a macOS or Windows holder that PULLED a commit adding `Members/README` (NIT-3).** There the directory stays spelled `members` and the file lands at `members/README`. Since exact listings do not find `Members/README`, the remedy is `git rm -r --cached`, not the ruling's committed `git mv`, and the file is left untracked at `members/README` (`$Q/v110/rvcase2/scen-head.log`, `macpulled-mac`, emulated). It is benign: committing that file afterwards still gives `family ok`. Folding the lookup for targets below the spelling, keeping the exact rule only for a bare `Members`, would print the `git mv` there too. - **`git mv --sparse` needs git 2.34 or later (NIT-4).** The estate states no minimum git (`README.md:215`, `README.md:365`, `docs/handbook.html:617`), and `scripts/shape_materialize.py` keeps a fallback for git older than 2.32. On such a git the sparse remedy fails with an unknown option; nothing is damaged (`$Q/v110/rvcase2-report.md`, NIT-4). The text could say "git 2.34 or later", or fall back to `git rm -r --cached` there. Review: rvcase found 1 blocker, 3 should and 5 nits on 40eda84, and Codex a P2; fix round 8a95345 fixed them all; re-review rvcase2 found 5 nits, fixed in round 3 (e5aac93, tests only), and Codex's `:2099` thread is answered. CI (Brett Heap's ruling 27): H0 e5aac93 passed tests, tests-macos, tests-windows and SonarCloud; the head 4c21ce2 is a clean update-branch merge of main 40b476d, with tests and SonarCloud green, and the local gate passed on its predicted squash (the family, validate-family and hygiene sets: 1050 passed, 19 skipped, over 66267f0). Landed on Brett Heap's word of 2026-10-15T13:32:47Z, "All five, once green". Closes #362 Closes #363 Closes #364 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n-source-unread Merges origin/main 66267f0 so the PR's CI merge ref and the fix that follows run on current main. The fourteen landings since 2bf8fd4: #597, #578, #593, #589 (#449), #586, #573 (#537), #575 (#539), #566 (#528), #571, #588, #569 (#538), #582 (#523), #616 and #576 (#441, #442). Those that touch this PR's files: - adopt-project.py: #589 (`check` recomputes each pin's digest in the clone `plan` read it from: LOCAL_CLONE_RE, _recorded_clone, _why_not_rechecked, _recomputed_pin_finding, _pin_digest_findings), #582 (what `plan`, `execute` and AGENTS.md say around a run) and #566 (a source's own `single-repository.yaml`). - scaffold-project.py and docs/cli.md: #573, #569 and #576 (the `_declaration` docstring: `--pin-source` verifies the first chain link alone, which this PR's N3 wording already says). - tests/test_adopt_pins.py: #589's thirteen cases. - tests/test_scaffold_leg_path_collisions.py: #573. This commit holds the textual resolution only. One hunk conflicted, the `from shape_materialize import (...)` list at adopt-project.py:261. It is resolved as the union of both sides' changes to the list: main's added `pin_digest_from_source` (#589), and this PR's added `DIGEST_FROM_CLONE`, `DIGEST_FROM_GH`, `pin_source_path` and `refuse_an_unread_pin_source` and its removal of `one_printable_line`, whose last caller this PR deleted and to which main added none (keeping it is pyflakes' "imported but unused"). pyflakes adopt-project.py is clean on the merge. Every other file merged without a marker, and `scripts/render-cli-reference.py --check` is ok. The merge alone does not keep #589 working: this PR's `plan` records a bare `local-clone`, which names no clone for #589's `check` to read, and `check` refuses `local-clone (<path>)` as `plan-pin-malformed`. The next commit reconciles the two. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ange is tested against the validator's `link-source-no-path` refusal (#576) it now meets (#608) Main moved eight commits past bcfa364. One of them, #576 (66267f0), changed the naming validator: a bare `--link-source` is the first chain link's tree alone, and a source that names no path is refused. Step (4) passes the passthrough's `--pin-source` as a bare `--link-source`, so the two now interact. No file this branch changed was touched on main; the merge is clean. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… repeat, step (4) passes `.` for an empty `--pin-source` and no source for a chain of one link, and step (3)'s probe refuses only for the folder it could not make (#608) rv608's review of #623 at 8b0f14d, items 1 to 5, and the merge of main 55faad2 that item 2 exists for. - Step (5) worded a refused plan from a second, captured run it never read the exit code of. When the first run refused and the second printed the plan and exited 0 (a passing gh or network failure, or a remote made in between), "the plan was refused (see above)" pointed at a plan that was not there. A second run that exits 0 now gets its own words: the refusal did not happen again, run setup again. - On the merge with #576, `-- --pin-source ''` sent the naming check `--link-source=`, which it refuses as `link-source-no-path`: a flag the person never typed, and exit 1 where the plan refuses the value with exit 2. An empty value goes as `.`, the folder the scaffold's `Path('')` reads, so step (4) and the plan describe the same source. - A chain of one link, which the plan accepts, got a `--link-source` too, and step (4) printed a `NOTE ... not used` the plan never prints. A source goes only with a chain of two links at least. - The write probe's `rmdir` shared a `try` with its `mkdtemp`, so a failing `rmdir` called a folder the person can write in one they cannot, and left the probe behind. Only the making refuses now; the taking away is best-effort, and names the folder it left. - Tests: two S9073 composite asserts split; the second run's cwd is asserted; rows for the surviving mutants (a chain with a blank link last and first, `a\b=c`, `C:x=y`, a line that starts `project` without its column of spaces); the new behaviours through both entry points by shared conftest helpers with one-call wrappers. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e: local-clone` and never the `--pin-source` path, so no committed pin file holds a local directory, and a `--pin-source` nothing reads is `pin-source-unused` (#540) (#570) #540: `declared_pin_values` (scripts/shape_materialize.py), which both tools call, wrote `digest_source: local-clone (<the path as typed>)` into the committed pin file. An absolute `--pin-source` therefore committed the directory a clone sat in on the machine that ran the tool. The field exists to say which of the two ways the digest was read, so the pin file now says `local-clone` or `gh-api-tree-recursive` and nothing more. A refusal for a source that cannot answer still names the path. Adopt's plan still records the path, `local-clone (<path>)`, because `check` recomputes each pin's digest in that clone (#449, #589). The plan is a local file nothing commits: `execute` builds the root and both legs from fresh clones at the plan's commit, and the one plan value it commits is the pin file's `digest_source:`, which it writes as `local-clone` for both clone spellings. `plan`'s `pin` line and `check`'s `write` line say the same. `check` holds the field to a closed set: `gh-api-tree-recursive`, `local-clone`, and `local-clone (<path>)` as `LOCAL_CLONE_RE` reads it, on one printable line. Any other value, a bare path or free text among them, is `plan-pin-malformed`, and `execute` refuses it before any leg exists, so nothing but the two words can reach the committed file. The one-printable- line condition is main's rule for the field; without it a hand-edited `local-clone (/x\x00y)` reached #449's `Path.stat()` as a NUL and raised `ValueError`. #450, the same field: the path was written bare. One holding a line break wrote a line of its own into the pin file, exit 0. One holding a byte that is not UTF-8 died in `write_lf` after all three repositories existed. The scaffold records no path, so it makes a project from such a clone like any other. Adopt's `plan` must record the path for #449, and `check` holds the field to one printable line, so its `_refuse_an_unrecordable_pin_source` (#273's review, N2) stays: such a path is `adopt-unwritable-value`, in a sentence that now says the path goes into the plan for `check`. `cmd_plan` asks it straight after `pin-source-unused`, before the work directory, git in the source or `gh repo clone`, as it needs the flags alone. A malformed `--pin` given with such a path therefore gets this refusal first. #449's code, findings and NOTEs are unchanged. One of its test rows, `no-clone-named`, wrote `digest_source: "read by hand"`, which the closed set now refuses; it writes bare `local-clone`, which names no clone, and asserts the same NOTE. `read by hand` is a malformed row. #451: with no `--pin`, and in the scaffold no `--referent-chain` (whose first link reads the bare `--pin-source`, and no later link, as #576 makes it), nothing read `--pin-source`. Even a path that did not exist was accepted, with exit 0 and no word about the flag, so a forgotten `--pin` made a project that declares no descent. `refuse_an_unread_pin_source`, beside `declared_pin_values`, refuses it for both tools as `pin-source-unused`: - exit 2; - from the flags alone, before any process starts and before anything exists, the scaffold's `--dry-run` alike; adopt's `plan` asks it first, after `pin-source-no-path`, and `adopt-unwritable-value` follows, before its work directory, git in the source, or `gh repo clone`; - two remedies: add the `--pin`, or drop the flag. The refusal says the flag names the clone each `--pin`'s digest is read from and the first `--referent-chain` link's declaration, the one link a bare path is the tree of (#441). An empty `--pin-source ''` was `Path("")`, the directory the tool ran in, so it pinned whatever clone the command stood in. `pin_source_path`, beside it, now refuses it for both tools as `pin-source-no-path`, in the words #442 gives the root's `validate-pins.py`. Both tools take the flag as text for this, and a typed `.` still means this directory. Both tools' `--help` for `--pin-source` says what the pin file and, for adopt, the plan record, and adopt's names the `adopt-unwritable-value` refusal; `docs/cli.md` is regenerated. The neutral pin template says no path is recorded in it, and its header names `adopt-project.py execute` as a renderer too (L2-16 on #459). Closes #540 Closes #451 Closes #450 Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… run on current main The branch was cut at 2989b4c (#573). origin/main is now 396c0ff: seventeen commits later, among them #574 (family.py `add`'s refusals, 841 lines of scripts/family.py), #570 and #576 (scaffold-project.py's `--pin-source`), #569 and #538 (scaffold-project.py's `--no-next-steps`), and several that moved docs/cli.md. `git merge-tree` said clean, and the merge made no conflict: scaffold-project.py, scripts/family.py, scripts/shape_materialize.py and docs/cli.md each merged by themselves. `scripts/render-cli-reference.py --check` passes on the result, so docs/cli.md was not re-rendered, and pyflakes gives on the three .py files exactly the warnings main's own copies give. No other change; the next commit on this branch is the fix round (L2-60, L2-67). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ot, the scaffold's chain WARNING offers only its own sources and spells `--pin-source`, the root's not-used note spells it too, and THE LOCKSTEP RULE's `bump` runs (#610) (#634) Issue #610 gathers the pinned shape copies' text into ONE shape bump (the checklist triage's D6, approved by Brett Heap at 2026-10-15T16:49:38Z), so every project re-pins once for v1.1.0. Branch made from origin/main 396c0ff (#570's squash; #575 and #576 are in). The bump. A pinned copy has no digest to edit in this repository: each root's contracts/shape-pin.yaml holds the sha256 of each copy as it was written, and `update-shape.py check` calls every copy whose upstream bytes moved `upstream-changed`, which `apply` re-copies and re-pins. So every pinned copy this issue changes moves in this one commit: templates/assembly-root/AGENTS-shape.md, scripts/repo_shape.py, templates/assembly-root/scripts/validate-pins.py and scripts/validate-repository-naming.py (assembly roots), and scripts/repo_shape.py and templates/family-root/scripts/validate-family.py (family holders). L2-33. AGENTS-shape.md said "the root Makefile exports `$(CURDIR)/spec/contracts` once". A scaffolded root's Makefile exports nothing (`make contracts-dir`: no rule), and since #572 an adoption exports the spec leg where its plan mounts it. The sentence is path-free: an ADOPTED root's shape Makefile exports `CONTRACTS_DIR` as the `contracts/` of the spec leg at the path `project.yaml` mounts it; where the source kept a Makefile of its own, which stays at the root, that block waits in shape/Makefile until the two are merged (`make contracts-dir` has no rule at that root); and a SCAFFOLDED root's exports none, its legs starting from the scaffold's seed, with no `contracts/`. The file stays placeholder-free. 576/2. The scaffold's `declared-unverified` WARNING offered "a sibling checkout, SHAPE_PIN_SOURCE_<LINK>, or --link-source <link>=<path>", the naming validator's sources. The scaffold has no `--link-source` and reads no sibling checkout. `LinkPins` now carries a caller's `remedies`, which `_chain_link_findings` uses for a tree that is not reachable, and the scaffold's `_link_remedies` names what it reads: the variable (unless another link shares it, #275) and, for the first link alone (#441), a `--pin-source` clone. The naming validator and `validate-manifest.py` pass none and keep their words. #444 x #610. #638 keys every reader of a chain by `pin_product_key` (`lower()`, the pin file's own fold), so `_link_remedies` keys a link's remedy by it too, and `_chain_link_findings` finds it by it. Keyed by `casefold()`, the chain `openß, openss, openInk`, two pin files and two links, wrote ONE entry, and `openß`, the first link, was told the scaffold reads no source for it, the later link's words, where the scaffold reads its `--pin-source`. Test: test_two_pin_files_one_casefold_are_each_offered_their_own. The naming gate's `LinkSources` docstring cited `validate-pins.py` by line numbers that no longer hold the lines; it names `PinSources.__init__` and `resolve_neutral_pin_source` now (R444's N2). L2-30. `link_declarations` named the manifest it reached raw, and the scaffold prints that reason in the WARNING, so a `--pin-source` holding an ESC put a raw `\x1b[31m` on stderr. Every reason now names it with `repo_shape.shown_path`, the spelling `pin-source-unreadable` gives a `--pin-source` (`shape_materialize.shown_source` is now that function): `repr` where it is not printable, the same bytes where it is. L2-31. The root's `validate-pins.py` named an unused `--pin-source`, the later value that replaced it, and a key read as a PATH as typed. Each is spelled the same way, by a local `_shown`: a root whose repo_shape.py is older has no `shown_path` to import. So are its two other raw prints, the source a `neutral-pin-source-unreadable` finding names and the value a line about a keyed value that is also a directory names, in the sentence and in the `./` command. The naming gate's `link-source-unreadable` WARNING named the tree it asked raw, beside a reason that names the manifest with `shown_path`. It spells the tree with its own local `_shown`, for the same reason. The `git-failed` detail that `validate-pins.py` prints at the end of a `neutral-pin-source-unreadable` finding, for a `--pin-source` that exists and is not a repository or lacks the commit, was built in `repo_shape.git_out` with the folder git ran in as it was and git's stderr as it came. A folder named with an ESC, a CR or a BEL reached stderr raw, and a line break in the name began a line of its own (`WARNING forged-line: ...`). git echoes the paths it reads (`invalid gitfile format: <folder>/.git`, `not a git repository: <gitdir>`, a dubious ownership line) and passes a line break through; 2.43, measured, replaces only an ESC, a CR and a BEL with `?` and passes a C1 control (U+0080 to U+009F) raw, and another git may replace nothing. `git_out` now names the folder with `shown_path` and prints what git said with `spelled`, as ONE line: a control character, a line break and a C1 control are the escape Python spells them, a line break git itself printed between two lines of its message is `\n`, and printable text, UTF-8 included, is unchanged. That is `git_out`'s detail for every caller, not `validate-pins.py`'s alone. `git_out`'s arguments, which a caller writes, are named as they were. `repo_shape.py` was already in this bump, so no pinned file is added. L2-39. validate-family.py's THE LOCKSTEP RULE named `scripts/family.py bump --member <Project> --to <commit>`, with no `--family-root`, which `bump` requires, and no branch, though a real `bump` refuses the holder's tracking branch (#487). It now names the holder by its path and the `git -C <holder> switch -c family/bump-<member>` that makes a branch, as #575's `_switch_line` does for `add` and `remove`: each finding for its member (the row's `id:` in the branch), and the closing count with `<Project>`. It ends with #637's `BRANCH_LEFT` sentence, as every remedy that names `_switch_line` does (#607). Measured: an assembly root scaffolded at 396c0ff (and an adopted one) takes this as one `update-shape.py` re-pin with `upstream-changed` for scripts/validate-pins.py, AGENTS-shape.md, scripts/repo_shape.py and scripts/validate-repository-naming.py, and a family holder takes it with `upstream-changed` for scripts/validate-family.py and scripts/repo_shape.py. `apply` re-copies them and the roots' own gates stay green. No row is a conflict. Tests: tests/test_pinned_copy_text.py (new), and in tests/test_validate_family_branch_step.py (the rule's branch word is the row's `id:`, through the gate), tests/test_adopt_contracts_dir.py (an adoption of a source with its own Makefile) and tests/test_naming_policy.py, whose test_scaffold_keeps_its_old_warning_for_a_pin_source_with_no_manifest pinned the old remedies and is now test_scaffold_offers_its_own_sources_for_a_pin_source_with_no_manifest. The `git_out` rows in tests/test_pinned_copy_text.py run `validate-pins.py` over a `--pin-source` that is no repository and over one whose `.git` is no gitfile, each named with an ESC, a forged line, a CR and a BEL, and run `git_out` against a stub `git` that prints those bytes, so the result does not depend on the git on the machine. Closes #610 Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>



Lane: openreposhape-5 (openRepoShape-5)
Claim: #441 (comment)
Claim: #442 (comment)
README change. This PR edits one README paragraph. #551 is on main and this branch has merged it, so the handbook step follows #551's rule: the three page parts that read the README section "The three legs" record its new hash. No page text changes.
What changed and why
#441: one bare tree answered for two links
link_pins_from_treespassed itsdefault_sourceto every link of a recorded chain. That value is the naming gate's bare--link-source PATH, and the scaffold's--pin-source. In the chainopenXdox → openYdox → openInk, with one bare tree that isopenXdox's,openYdoxwas read from the sameproject.yaml. That file declaresopenYdox, notopenInk, so the gate said "openYdox declares openYdox, not openInk". ForcodexInk, which holds no direct pin on its referent, that was a FINDING, exit 1, for a chain that holds. The scaffold refused the same chain aschain-not-declared.Now:
--pin-sourcein scaffold-project.py and adopt-project.py: accepted without--pin, and committed intodigest_sourceas typed #540 and anyone else relying on it): only the FIRST link of each chain,links[0], the pin the project holds. That is true for a chain of any length, one link included: a chain of one link has only its first link, and that link reads the bare tree as before. A later link is never offered the bare value. It is read from its ownLINK=PATH, itsSHAPE_PIN_SOURCE_<LINK>or a checkout beside the project; otherwise it is declared-unverified (a warning, exit 0). For the first link the order is unchanged: keyed, variable, checkout beside the project, then the bare value.link_pins_from_treesandresolve_link_sourcekeep their signatures and return shapes. The only code change inrepo_shape.pyisNone if index else default_sourcein the loop.openXdox → openDoxwith a bare path) reads exactly as before and prints the same lines. The one difference is inside the returned mapping, and only for a chain of two or more links: the chain's END link no longer gets an entry read from the bare tree. Nothing reads that entry; only holders' declarations are read. In a chain of one link the only link is both the first and the end, and it still reads the bare tree, as before.LinkSourcesfinds the first link of every chain a name will READ before any chain is read._first_links_readuses the samereads_link_declarationstest the lookup uses, and counts a chain only if it has two or more links: a chain of one link has no holder, reads no declaration, and so has no first link to count. The bare value is offered only while there is at most one first link: with a lone one-link chain there are none, and the bare tree is still returned for that chain's link to read, as before (nothing reads what it finds, so the output is unchanged). If a run reads two chains that begin at different links (a name on the command line plus--project, or one project with two legs), the bare value is read for neither. Before, one tree answered both, andusedcounted it once.#442: an empty path was the current directory
--link-source openXdox=and--pin-source openXdox=becamePath(""), which is.. From a project root, the naming gate read the project's own manifest asopenXdox's and called the chain broken.validate-pins.pyasked the project root for the pinned commit, and its finding blamed..Now both tools refuse, exit 2, naming the key:
--link-source '',--pin-source '') is refused the same way..still means the current directory.openZdox=) is still the bare PATHopenZdox=, as scripts/validate-repository-naming.py:_link_sources_from_argssplits every--link-sourcevalue at its first=and nothing reports a value no chain link used, so a link tree under a directory nameda=b, or behind a mistyped key, is silently skipped (declared-unverified, exit 0), andrepo_shape.link_manifest_pathlets a source this user cannot enter (keyed,SHAPE_PIN_SOURCE_<LINK>or a sibling checkout) end the naming gate in aPermissionErrortraceback before Python 3.14 #338 and templates/assembly-root/scripts/validate-pins.py: a bare--pin-source <path>whose path holds an=is read as<product>=<path>(raw.partition("=")), so a stand-in clone under a directory nameda=bis never consulted and the pin's digest silently goes togh apiand askip(pins ok, exit 0), or, when the text before the=is a declared pin's name, to aFileNotFoundErrortraceback #295 decided. It is not empty, so it is not refused.--link-source openXdox= --link-source openXdox=<tree>was a NOTE ("the later--link-source ...replaced it"), exit 0. It is nowREFUSED link-source-no-path, exit 2.validate-pins.pychanges the same way:--pin-source openXdox= --pin-source openXdox=<clone>waspins ok, exit 0, and is nowREFUSED pin-source-no-path, exit 2. A typo is a typo, and the refusal names it when it is typed; a script that passed an empty value first and the real one after must drop the empty one.main, right after the values are read and before stdin is read or any name is classified, like its ownREFUSED naming-no-target.validate-pins.pyraises aRefusalfromPinSources. It reachesmain's existing handler with its own remedy, not the lockstep one. It is raised late:PinSourcesis built inside_check_neutral_product_pins, which_run_all_checkscalls after_check_legand_check_shape_pin. So an earlier refusal wins: a damagedcontracts/spec-pin.yamlplus--pin-source openXdox=printsREFUSED pin-tag-only, and the empty path is named on the run after that is fixed. The hunk stays insidePinSourceson purpose, to stay clear of the lines Pinned validators: THE LOCKSTEP RULE only where it applies, and remedies that name thegit switch -cstep (one shape bump) #539 edits in_run_all_checks. The refusal also has to wait for the declared pins, because they decide whetheropenXdox=is keyed or a bare PATH. Nothing is printed before the refusal, and the exit is 2 as in every other refusal of that tool.Docs
--helpfor--link-source, the naming validator's module docstring (exit code 2 included),resolve_link_source's rule 4,link_pins_from_trees's docstring and the scaffold's_declarationdocstring say what a bare value means in a longer chain.--link-sourcehelp also says that a bare PATH is read for neither link when the chains read begin at two different links, and that a later link is read from its ownPRODUCT=PATH, itsSHAPE_PIN_SOURCE_<PRODUCT>or a checkout beside the project (it said "needs its ownPRODUCT=PATH").docs/cli.mdis regenerated.--link-source <path>, for the first link's tree only: the ordervalidate-pins.pyuses below for the same checkout." The paragraph keeps its 12 lines, and README stays at 1422 lines.--link-source. The three parts that read "The three legs" (the masthead, "Reading private legs in CI", "The three legs, and naming") move to that section's new hash, 6ebed796 → 39ec2c23. No other README section changed.validate-pins.py's--helpand EXIT CODES docstring do not namepin-source-no-path. Nothing in them is false: the exit-2 list gives examples. I left them, because the module docstring sits near lines Pinned validators: THE LOCKSTEP RULE only where it applies, and remedies that name thegit switch -cstep (one shape bump) #539 edits.Fix round (commit 31a6f21, one commit on the head 04c9d77)
The review of 04c9d77 (APPROVE WITH NITS) found five nits. Each, and what this commit does:
--referent-chain openInkbeside a project whose leg recordsopenXdox → openQuill, with a bareopenXdoxtree, gave two first links, so the bare tree went to neither.codexQuill, verified through it on bfed143, was declared-unverified, with a NOTE namingopenInkas a first link. Both places now countchain[0]only when the chain has more than one link:_first_links_read, and thefirstsupdate inLinkSources.link_pins. The run is verified again, with no WARNING and no NOTE, as on bfed143. New testtest_a_chain_of_one_link_is_no_first_link_read._first_links_read. Revertingif data is not None:toif False:(mutation M14) left all 20 tests passing, yet it changes the output for one project whose two legs begin at different links: the first classification pass counted the bare value used, and the NOTE went missing. New testtest_a_project_whose_legs_begin_at_two_links_reads_a_bare_tree_for_neither:openXdox → openInkandopenYdox → openQuillin one project, a bareopenXdoxtree, two WARNINGs and the two-first-links NOTE. M14 now fails it (see the mutation probes).pin-source-no-pathis raised late invalidate-pins.py. No code change. The behaviour and the reason are written above, under scripts/validate-repository-naming.py_link_sources_from_args(:406) and templates/assembly-root/scripts/validate-pins.pyPinSources(:409): a key with an empty path (--link-source openXdox=,--pin-source openXdox=) isPath(""), which is the current directory, so run from a project root the naming gate reads the project's OWNproject.yamlas the link's declaration and reports a chain that holds as broken, a FINDING (exit 1) for a name with no direct pin #442.--link-sourcehelp. It now says a bare PATH is read for neither link when the chains read begin at two different links, and "needs its ownPRODUCT=PATH" now names the variable and the checkout beside the project too.docs/cli.mdis regenerated, andrender-cli-reference.py --checkis ok.git switch -cstep (one shape bump) #539 is on origin and merges clean (see the merge checks); an empty value is refused even when a later one replaces it; the END-link sentence holds for chains of two or more links; and the checklist candidate below.The commit changes
scripts/validate-repository-naming.py,docs/cli.mdandtests/test_link_source_chain_defaults.pyonly.Fix round 3 (commit a707d4b, one commit on 31a6f21)
The re-review of 31a6f21 (APPROVE WITH NITS) found that all five nits are fixed, and two new ones. This commit changes
tests/test_link_source_chain_defaults.pyonly.link_pins. Changingif len(target.chain) > 1:there to> 2passed the whole new file andtests/test_naming_policy.py(264 passed), yet it changes the output for a name read from stdin whose chain has two links: the first-link list is empty, and the NOTE for an unused bare value reads "a bare PATH is the tree of a chain's first link alone, , which had a more specific source". New testtest_a_two_link_chain_read_from_stdin_counts_its_first_link: stdincodexInk,--referent-chain openXdox,openInk,openXdox=<tree>keyed and a second tree bare, asserting the whole stderr, the one NOTE "each link whose own declaration is read had a more specific source (...)". The> 2mutant fails it (see the mutation probes). The existing three-link stdin test and the new one share a helper,stdin_naming.Decisions and evidence
default_sourcetolink_pins_from_trees, and every scaffold test with a three-link chain setsSHAPE_PIN_SOURCE_<LINK>for each link. A refusal "when more than one link would take it" would depend on what is on the disk (checkouts beside the project, variables). scripts/validate-repository-naming.py:_link_sources_from_argssplits every--link-sourcevalue at its first=and nothing reports a value no chain link used, so a link tree under a directory nameda=b, or behind a mistyped key, is silently skipped (declared-unverified, exit 0), andrepo_shape.link_manifest_pathlets a source this user cannot enter (keyed,SHAPE_PIN_SOURCE_<LINK>or a sibling checkout) end the naming gate in aPermissionErrortraceback before Python 3.14 #338 ruled that the reading is decided by the chains, never by the disk._link_sources_from_args(:406) and templates/assembly-root/scripts/validate-pins.pyPinSources(:409): a key with an empty path (--link-source openXdox=,--pin-source openXdox=) isPath(""), which is the current directory, so run from a project root the naming gate reads the project's OWNproject.yamlas the link's declaration and reports a chain that holds as broken, a FINDING (exit 1) for a name with no direct pin #442, not note it. A note would still have to stop the value reading.. A refusal names the typo when it is typed, and both tools already exit 2 for a question they cannot ask.--pin-sourcethrough the same function, so its three-link refusal goes away (see the reproduction). Its--pin-sourceis the pinned product's checkout, which is the first link's tree.Tests
New file
tests/test_link_source_chain_defaults.py: 23 tests (22 until round 3). On bfed143, 17 fail and 5 pass (the controls: the one-link rows and the rows that already worked). On 04c9d77, the one-link test fails and the other 21 pass.Targeted runs (each
run9.sh:ghstub, bogus token, emptyGH_CONFIG_DIR, git-filter-repo 2.47.0), on the head 31a6f21 unless a row says otherwise. Round 3 (head a707d4b) changes the new test file only, and its own run is the last row:774 passed in 117.48s205 passed in 91.75s22 passed in 26.08s187 passed in 87.54s415 passed in 53.10s287 passed in 95.29sRound 1 (head 73c4c255, before the merge of #551 and this commit;
docs/handbook.html,README.mdand the validators are unchanged by this commit apart fromvalidate-repository-naming.py):27 failed, 1796 passed, 3 skipped in 1433.51s; every failure the sameUnicodeDecodeErrorfrom a bytecode file my ownpy_compileprobe had left intemplates/assembly-root/scripts/__pycache__. With it removed, the two files that held all 27 (test_adopt_root_symlinks.py,test_scaffold_leg_path_collisions.py) gave253 passed in 140.50sThe three skips are
test_shape_doctor.py:9119, which runs only on a Windows host. None of those files passes--link-source, and the one-link change and the help text cannot change a run that gives no bare value.The 27 failures.
template_values()and its twin intest_scaffold_leg_path_collisions.pyread every file undertemplates/assembly-rootas UTF-8. Avalidate-pins.cpython-312.pychad appeared there the second I ranpython3 -m py_compileover the changed files. Its first byte is0xcb, the 3.12 bytecode magic, which is the byte every failure names. CI never runspy_compile.Reproductions (bfed143, then this head; local trees and remotes,
ghstub first on PATH)--link-source <openXdox tree>, chainopenXdox → openYdox → openInk,codexInkFINDING ... is broken (openYdox declares openYdox, not openInk), exit 1domain-descendant/assembly, onedeclared-unverifiedWARNING for openYdox naming--link-source openYdox=<path>, exit 0--link-source--dry-run, same chain,--pin-source <openXdox clone>REFUSED chain-not-declared, exit 2--referent-chain openInkbeside a legopenXdox → openQuill, bare openXdox treeopenXdox → openInkandopenYdox → openQuill, bare openXdox treeFINDING codexQuill: the declared chain openYdox → openQuill is broken (openYdox declares openInk, not openQuill), exit 1: the bare tree answered both first links--link-source openXdox=from the project rootFINDING ... (openXdox declares openXdox, not openInk), exit 1REFUSED link-source-no-path, exit 2., exit 0REFUSED link-source-no-path, exit 2--link-source ''from the project rootREFUSED link-source-no-path, exit 2--link-source openXdox= --link-source openXdox=<tree>REFUSED link-source-no-path, exit 2 (the behaviour change above)--link-source openXdox=.from the project root.is read--pin-source openXdox=(scaffoldedcodexDox)FINDING neutral-pin-source-unreadable: ... . (--pin-source openXdox=...), exit 1REFUSED pin-source-no-path, exit 2--pin-source ''REFUSED pin-source-no-path, exit 2--pin-source openXdox= --pin-source openXdox=<clone>pins ok, exit 0REFUSED pin-source-no-path, exit 2--pin-source openXdox=<clone>pins ok, exit 0No row needs real GitHub. Round 1 ran every row except the two new ones and the two "a later value replaces it" rows. The one-link and two-legs rows are the new tests: on bfed143 the one-link test passes and the two-legs test fails; on 04c9d77 the one-link test fails and the two-legs test passes. The two "a later value replaces it" rows are the review's reproductions of bfed143 against 04c9d77; this commit does not change them.
Mutation probes
Each hunk was reverted alone in a throwaway worktree, and the new file run against it.
Round 1 (20 tests), every hunk fails at least one test:
repo_shape.py: offerdefault_sourceto every link again[two]and[three], the scaffoldLinkSources._bare_path: drop the two-first-links gate_not_asked_why: drop the two-first-links sentence_not_asked_why: always the old two-link sentencemain: drop thelink-source-no-pathrefusal--link-sourceno-path rowPinSources: drop thepin-source-no-pathrefusal--pin-sourceno-path rowlink_pins: dropfirsts.setdefault(stdin names)_first_links_read: return nothingno_path: keyed values onlyno_path: keyed values onlyRound 2 (22 tests, head 31a6f21):
_first_links_read:if data is not None:→if False:(the project's legs; the review's M14, which survived 04c9d77)test_a_project_whose_legs_begin_at_two_links_reads_a_bare_tree_for_neither; stderr has the two WARNINGs and no NOTELinkSources.link_pins: count a one-link chain's first link againtest_a_chain_of_one_link_is_no_first_link_read_first_links_read: count a one-link chain againtest_a_chain_of_one_link_is_no_first_link_readRound 3 (23 tests, head a707d4b):
LinkSources.link_pins:if len(target.chain) > 1:→> 2(the re-review's boundary mutant, which survived 31a6f21)test_a_two_link_chain_read_from_stdin_counts_its_first_link(1 failed, 22 passed in 10.59s); stderr has "first link alone, , which had" where the test expects "each link whose own declaration is read had"The existing
test_a_link_source_no_link_used_is_a_note_and_changes_no_exitintests/test_naming_policy.pyholds the two-link NOTE sentence byte for byte, and it passes.Merge checks (
git merge-tree --write-tree, aftergit fetch)tests/readme-cap.d/550.txtof f6eaab7): clean. The re-review found it clean at 31a6f21 (tree 71ea0d30), andtest_repo_hygiene.py,test_readme_sections.pyandtest_cli_reference.pygave151 passed in 58.81son that merged tree, withrender-cli-reference.py --checkok. The README cap there is 1427 = 1422 + 5 from main'stests/readme-cap.d/550.txt, and this PR adds 0 README lines; its three handbook markers for "The three legs" are 39ec2c23 on the merged tree. a707d4b changes the test file only: merge-tree of it with 9f80939 is clean (tree dfc650f9). Earlier mains were clean too: 7d9bec9 (after shape-doctor.py: themachinerow of a machine that is not ready names the preflight checks that failed, in the[!!]lines the preflight printed, so its reason no longer ends onthis machine is not ready yet (see above).with nothing above it (#549) #552, shape-doctor.py: a git repository is told of both ways in, the adoption and the scaffold, and the adoption line ends--org <your-org>where itsoriginnames no organisationplancan read, so pasting it is no longer refusedadopt-no-org(#545) #553, tests: every test that clones a just-pushed local remote by its plain path now clones it withclone_just_pushed, so the macOSfailed to copy filerace of #440 cannot reach it;load_without_bytecodekeeps the doctor's and the Windows-path tests' loads of the family root's scripts from writing.pycinto the templates; the not-a-list doctor cases hold INVALID, exit 1; andGOOD_KEYSholds th #554, bump-leg.py:--dry-rungives the real run's answer, so--toan annotated tag's own id is peeled to the commit it names in both runs, and a pin layout the rewrite refuses is refused by the dry run too (#534, #438) #561 and update-shape.py:applyrefuses to keep an--accept-localcopy of a helper that lacks a name a copy it writes imports from it, before anything is written, so a keptscripts/repo_shape.pyno longer leavesmake bootstrapdying on an ImportError after the validators passed (#536) #562;415 passedon the merged tree) and bbcfd34 (tree 10acc9c0). The twodocs/cli.mdregenerations (this PR's--link-sourcehelp, bump-leg.py:--dry-rungives the real run's answer, so--toan annotated tag's own id is peeled to the commit it names in both runs, and a pin layout the rewrite refuses is refused by the dry run too (#534, #438) #561'sbump-leg.py) touch different tools' sections.fix/539-validators-lockstep-remedies(validate-pins.py and validate-family.py: THE LOCKSTEP RULE closes the count only where a leg or member is out of step, a lone digest or workflow-reference mismatch names itsbump-leg.pyline, and holder remedies branch first (#539) #575, 93b68f6), now on origin: clean. The reviewer ran the lockstep-lines test, the new file,test_validate_pins_without_git.pyandtest_validate_pins_unreadable_workflow.pyon the merge with 04c9d77:103 passed in 48.45s. This commit does not touchvalidate-pins.pyor any of those tests. Thevalidate-pins.pyhunk stays insidePinSources, away from Pinned validators: THE LOCKSTEP RULE only where it applies, and remedies that name thegit switch -cstep (one shape bump) #539's regions (:99-544and:1109-1143).fix/540-pin-source-unread(scaffold-project.py and adopt-project.py: a pin recordsdigest_source: local-cloneand never the--pin-sourcepath, so no committed pin file holds a local directory, and a--pin-sourcenothing reads ispin-source-unused(#540) #570, 79dfd33): clean. On a merge with 04c9d77, the new file,test_pin_source_unreadable.py,test_scaffold_referent_chain_links.py,test_scaffold_leg_path_collisions.py,test_cli_reference.pyandtest_adopt_pins.pygave371 passed.--pin-sourcein scaffold-project.py and adopt-project.py: accepted without--pin, and committed intodigest_sourceas typed #540 counts any--referent-chainas a reader of--pin-source; that stays true, because every chain's first link reads it. One sentence of--pin-sourcein scaffold-project.py and adopt-project.py: accepted without--pin, and committed intodigest_sourceas typed #540 needs a word once both are in:pin-source-unusedsays the clone is read for "each --referent-chain link's declaration" (scripts/shape_materialize.py:2706-2707and the docstring at:2681-2683, andtests/test_pin_source_unreadable.py:445, theUNUSEDtext at:455-457and:498). After this PR it is the FIRST link's alone. Whichever lands second should change "each" to "the first".not moving an existing branchline names where the checkout is, and bump-leg.py and AGENTS-shape.md say "or says why not" (#533) #556 to adopt-project.py: whatplan,executeand AGENTS.md say around a run is what the run does: the plan's last follow-up is a review of the pull requestexecuteopens itself, a failed verification names the pull request and the topic it left in place, AGENTS.md sayscheckpasses an answer with noresolution:, NEXT under--local-remote-dirnames the split's branch, and the ruleset hint no longer calls a seed branch and a pull request the scaffold's other exit (#523) #582 that git still serves (23 heads; bump-leg.py:--dry-rungives the real run's answer, so--toan annotated tag's own id is peeled to the commit it names in both runs, and a pin layout the rewrite refuses is refused by the dry run too (#534, #438) #561 is on main): all clean, except install.sh and openRepoShape --install: one line,curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash, installs the newest release pinned to it, with git-filter-repo, and ends with the preflight, and the installed command stays on its release (#564) #577 (install.sh, d0cdbf4). install.sh and openRepoShape --install: one line,curl -fsSL https://github.com/opensoft/openRepoShape/releases/latest/download/install.sh | bash, installs the newest release pinned to it, with git-filter-repo, and ends with the preflight, and the installed command stays on its release (#564) #577 conflicts with main itself, indocs/handbook.htmlandtests/test_repo_hygiene.py; against this head the same two files conflict. This PR is not the cause. Both editREADME.mdand the handbook's hash markers, so they land one at a time. Only validate-pins.py and validate-family.py: THE LOCKSTEP RULE closes the count only where a leg or member is out of step, a lone digest or workflow-reference mismatch names itsbump-leg.pyline, and holder remedies branch first (#539) #575 touches a file this PR changes (validate-pins.py, clean).bump --toasksadd's duplicate-id question, peels an annotated tag's own id asadd --atdoes, and says to push first a commit the member does not hold after its fetch (#541) #568fix/family-bump-to-checks8024f9c, family.py:removeandaddrefuse a.gitmodulesthe work tree does not have as the index has it (missing, unstaged, untracked, or behind assume-unchanged or a sparse checkout), so neither commits a change to it nobody staged (#407) #571fix/family-remove-gitmodules-missingb1120c3, family.py:addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574fix/family-members-case-variants40eda84 and siblings.py and family.py:make siblingsasks about the family folder before it clones, clones each member on its tracking branch, andfamily.py siblingsruns the holder's own copy (#543) #578fix/family-siblings-tracking-branch43d32eb: clean.Left out
adopt-project.py's own--pin-source ''(see the checklist candidates).validate-pins.py's--helpand module docstring: nothing in them became false, and the module docstring sits near lines Pinned validators: THE LOCKSTEP RULE only where it applies, and remedies that name thegit switch -cstep (one shape bump) #539 edits.validate-pins.pyrefuse the empty path before its leg and shape-pin checks: it would put the hunk in_run_all_checks, where Pinned validators: THE LOCKSTEP RULE only where it applies, and remedies that name thegit switch -cstep (one shape bump) #539 edits.resolve_link_source(:2401-2402) and scripts/validate-repository-naming.py_link_sources_from_args(:408): a bare--link-source <path>answers for EVERY chain link that has no more specific source, so in a chain of three or more links one tree is read as two links' declarations and a correct chain is reported broken, a FINDING (exit 1) for a name that holds no direct pin on its referent #441 and scripts/validate-repository-naming.py_link_sources_from_args(:406) and templates/assembly-root/scripts/validate-pins.pyPinSources(:409): a key with an empty path (--link-source openXdox=,--pin-source openXdox=) isPath(""), which is the current directory, so run from a project root the naming gate reads the project's OWNproject.yamlas the link's declaration and reports a chain that holds as broken, a FINDING (exit 1) for a name with no direct pin #442 as known limits of 1.0.0, which they are.Checklist candidates
scaffold-project.pyandadopt-project.py:--pin-source ''isPath(""), so the current directory is read as the pinned product's clone. Both take--pin-sourceastype=Path, andshape_materialize.declared_pin_valuescomputes the digest from it. The scaffold also hands it tolink_pins_from_treesas the first link's tree. Run from the product's own clone,scaffold-project.py --pin openXdox@<sha> --pin-source '' --referent-chain openXdox,openDox --dry-runplans withlocal-clone (.), exit 0; run from anywhere else it reads whatever directory it stands in. This is scripts/validate-repository-naming.py_link_sources_from_args(:406) and templates/assembly-root/scripts/validate-pins.pyPinSources(:409): a key with an empty path (--link-source openXdox=,--pin-source openXdox=) isPath(""), which is the current directory, so run from a project root the naming gate reads the project's OWNproject.yamlas the link's declaration and reports a chain that holds as broken, a FINDING (exit 1) for a name with no direct pin #442's shape in the two tools that create a project, and neither was in scripts/validate-repository-naming.py_link_sources_from_args(:406) and templates/assembly-root/scripts/validate-pins.pyPinSources(:409): a key with an empty path (--link-source openXdox=,--pin-source openXdox=) isPath(""), which is the current directory, so run from a project root the naming gate reads the project's OWNproject.yamlas the link's declaration and reports a chain that holds as broken, a FINDING (exit 1) for a name with no direct pin #442. (The review confirmed it for the scaffold on the head;adopt-project.pywas not re-run.)declared-unverifiedwarning names remedies the scaffold does not have. It says "(a sibling checkout, SHAPE_PIN_SOURCE_, or --link-source = would let it be read)".scaffold-project.pyhas no--link-source(none in its--help) and reads no sibling checkout:link_pins_from_trees(chain, None, ...)atscaffold-project.py:799passes no root. The text is_chain_link_findings' (scripts/repo_shape.py:1419). It was already printed on bfed143 for a two-link scaffold with no manifest. Since this PR, every chain of three or more links scaffolded with--pin-sourcealone prints it for the second link, where onlySHAPE_PIN_SOURCE_<LINK>helps. The wording should name only the remedies the caller has.tests/test_cli_reference.pyalone writestemplates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc(proved on 04c9d77 and on two merged trees:22 passed, then the file is there). A.pycundertemplates/breakstemplate_values()and its twin intest_scaffold_leg_path_collisions.pyin any later adoption run in the same tree: round 1's 27 failures were the same kind of break, from a.pycundertemplates/assembly-root. tests: every test that clones a just-pushed local remote by its plain path now clones it withclone_just_pushed, so the macOSfailed to copy filerace of #440 cannot reach it;load_without_bytecodekeeps the doctor's and the Windows-path tests' loads of the family root's scripts from writing.pycinto the templates; the not-a-list doctor cases hold INVALID, exit 1; andGOOD_KEYSholds th #554'sload_without_bytecodedoes not cover it. It is outside scripts/repo_shape.pyresolve_link_source(:2401-2402) and scripts/validate-repository-naming.py_link_sources_from_args(:408): a bare--link-source <path>answers for EVERY chain link that has no more specific source, so in a chain of three or more links one tree is read as two links' declarations and a correct chain is reported broken, a FINDING (exit 1) for a name that holds no direct pin on its referent #441 and scripts/validate-repository-naming.py_link_sources_from_args(:406) and templates/assembly-root/scripts/validate-pins.pyPinSources(:409): a key with an empty path (--link-source openXdox=,--pin-source openXdox=) isPath(""), which is the current directory, so run from a project root the naming gate reads the project's OWNproject.yamlas the link's declaration and reports a chain that holds as broken, a FINDING (exit 1) for a name with no direct pin #442, and is named here only.Closes #441
Closes #442
🤖 Generated with Claude Code
Summary by Sourcery
Correct chain source resolution so bare paths validate only the first link and reject source values that do not name a path.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests: