Skip to content

family.py and scaffold-project.py: --dry-run asks the real run's own question about existing remotes, so family-remote-exists and scaffold-remote-exists come at the plan, and a missing gh is gh-not-found, not a traceback (#537) - #573

Merged
brettheap merged 6 commits into
mainfrom
fix/537-dry-run-asks-remotes
Oct 15, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-2 (openRepoShape-2)
Claim: #537 (comment)

What was wrong

Both dry runs printed the plan and exited 0 before the one question their real runs ask of the remotes:

  • family.py init --dry-run: against a holder repository that already existed, the dry run exited 0. The real run then refused family-remote-exists, after _prepare_landing_dir had already made <into>/<Family>/.
  • scaffold-project.py --dry-run: the dry run never asked whether the three repositories existed. setup-project.py's plan, step (5), is that dry run, so scaffold-remote-exists came only from step (6), after the person typed yes.

What changed

  • init --dry-run asks _checked_reuse, the question the real init asks before it creates the holder. It asks after the plan and refuses family-remote-exists in the real run's words. With --reuse-empty-repo and an empty holder, it prints reuse <target> (zero commits).

  • scaffold-project.py --dry-run asks _reusable_remotes, the real run's question about all three repositories, the scaffold-project.py, family.py init: after a ruleset refuses the first push, neither exit RULESET_HINT offers can finish: the pull request has no base in the repository gh repo create left empty, and the scaffold's re-run is refused scaffold-remote-exists for its own two empty legs, so a ruleset-protected organisation cannot seed a new project; --reuse-empty-repo is to reuse empty legs, and the hint is to offer only that re-run #462 seed rule included. It refuses scaffold-remote-exists in the real run's words, and prints a reuse ... line for each remote the real run would reuse. So setup-project.py now refuses an existing remote at the plan, before it asks for the yes.

  • There is no second check. Each dry run calls the real run's own function, so the two cannot disagree. The dry run only reads:

    • gh repo view and a commit listing, or the bare repositories with --local-remote-dir;
    • for a leg that is not empty, the one commit it holds, fetched into a temporary directory that is then deleted.

    It creates no remote, folder or work tree, and pushes nothing.

  • No gh on PATH is now gh-not-found, in dry and real runs alike. A dry run used to need no gh. Now the scaffold's two reads of GitHub, remote_is_empty and remote_visibility, and the family's _remote_is_empty refuse gh-not-found, in the words pin_digest_from_gh uses, where they used to raise a FileNotFoundError traceback.

    • remote_visibility is the one a first version missed. With --reuse-empty-repo, _leg_visibility reads the root's visibility before either _reusable_remotes call asks whether the repositories exist, so the scaffold with that flag still gave a traceback in both runs until the visibility read refused too.
    • The scaffold's two reads share one refusal, _gh_not_found, so their words cannot drift apart.
  • --help for both --dry-run flags says what they ask, and docs/cli.md is regenerated (render-cli-reference.py --check passes).

  • The real-run order in family.py init is unchanged: only the dry-run path was touched.

Tests

New: tests/test_dry_run_asks_remotes.py, 28 tests.

Also new: conftest.assert_the_plan_refuses_a_remote_that_exists, with one-call wrappers in test_setup_project_py.py and test_setup_sh.py. It checks that step (5) refuses before the question, and that nothing is created.

Five existing tests dry-ran the scaffold against GitHub with no stub, so the conftest gh guard now caught their repo view calls:

  • Three take --local-remote-dir, since none of their assertions depend on GitHub: test_a_neutral_product_root_is_planned_by_a_dry_run, test_the_dry_run_plans_a_pinned_neutral_product_root and the 13 cases of test_a_path_that_overlaps_nothing_the_scaffold_writes_plans.
  • Two need the real-org plan line, so they ask the new conftest.scaffold_dry_run_on_github, which runs the scaffold in process with a stand-in for that one question: test_internal_visibility_is_accepted and test_the_longest_name_a_disk_keeps_is_still_accepted.
  • test_family.py::test_init_dry_run_plans_the_gh_topic_command_for_a_real_org does the same, inline.

Red on main (7d9bec9), with these tests run against main's code: 31 failed, 17 passed.

  • Red: 26 of the 28 new tests, both setup wrappers, and the three tests that now assert the GitHub question was asked. Main's dry run exits 0 where the real run refuses, asks no gh, and has no gh-not-found; all 8 gh-not-found tests fail there.
  • Green: the 2 controls, plus the 15 tests that only gained --local-remote-dir.
  • Red on the first version of this PR (0c81423's code): 2 of the 8 gh-not-found tests, the scaffold with --reuse-empty-repo, dry and real, with FileNotFoundError: [Errno 2] No such file or directory: 'gh'.

Branch runs:

Run Result
Targeted set on the fix head: every tests/test_family*.py, test_scaffold*.py, test_setup*.py, test_cli_reference.py, test_repo_hygiene.py, and the new file, plus test_reuse_visibility.py 1494 passed, 19 skipped (615 + 879)
Same set on the merge with main 7d9bec9, before the fix 1488 passed, 19 skipped
17 other files that dry-run either tool or touch gh, at the first version (test_adopt_pins, test_bump_leg*, test_doctor_gitlink_repairs, test_leg_path_yaml_scalars, test_naming_policy, test_park_resume_targets, test_pin_source_*, test_refusal_default_remediation, test_shape_advisory, test_stdout_encoding, test_tracking_branch_ref, test_reuse_visibility, test_ruleset_hint_tracking_branch, test_agents_md_codes, test_gh_guard) 1252 passed, 10 skipped
Mutants of the first version: no question, the flag forced on, folder or remote dir made first, gh-not-found removed, refusal swallowed (each twice, once per tool) 10 of 10 killed
Mutants of the remote_visibility fix: the old traceback back, the wrong code, the missing gh swallowed to None, the wrong question named, the remedy's gh sentence lost, remote_is_empty's refusal swallowed 6 of 6 killed

README follow-up (after #519)

README's "If it fails part-way" box says "A second run is refused while they exist". Suggested wording:

A second run is refused while they exist, at the plan in step (5), before the yes: the plan is the scaffold's --dry-run, which asks whether each of the three already exists (#537).

Closes #537

🤖 Generated with Claude Code

Summary by Sourcery

Make dry-run planning match real-run remote validation and handle missing GitHub CLI prerequisites consistently.

Bug Fixes:

  • Make family and scaffold dry runs detect and refuse existing remotes using the same checks as real runs, before any confirmation or filesystem changes.
  • Report a missing gh CLI as a gh-not-found refusal instead of exposing a traceback in both dry and real runs.

Enhancements:

  • Show reuse decisions for eligible empty or seed repositories during dry-run planning while preserving a read-only execution path.

Documentation:

  • Update CLI reference and option help to document that dry runs check existing remotes and create or push nothing.

Tests:

  • Add comprehensive coverage for remote checks, reuse behavior, missing gh, GitHub and local remote paths, setup integration, and no-side-effect guarantees.

…es already exist, with the real run's own question, so `family-remote-exists` and `scaffold-remote-exists` come at the plan, before `init` makes the family folder and before `setup-project.py` asks for the yes (#537)

Both dry runs printed the plan and returned 0 before the one question
their real runs ask of the remotes. So a dry run against a remote that
already existed said nothing, and the real run refused it later:
`family.py init` refused `family-remote-exists` after
`_prepare_landing_dir` had made `<into>/<Family>/`, and the scaffold
refused `scaffold-remote-exists` only in `setup-project.py`'s step (6),
after the person had typed yes.

Each dry run now asks the real run's own question after the plan:
`_checked_reuse` in `family.py`, `_reusable_remotes` in the scaffold.
There is no second check that could disagree with the real one, so the
dry run refuses what the real run refuses, in the same words, and says
`reuse ... (zero commits)` (or the seed, #462) where the real run would
reuse a remote. It only reads: `gh repo view` and a commit listing, or
the bare repositories with `--local-remote-dir`, and for a leg that is
not empty the one commit it holds, fetched into a temporary directory
that is then deleted. It creates no remote, folder or work tree, and
pushes nothing. `setup-project.py`'s plan is the scaffold's dry run, so
it now refuses an existing remote before it asks for the yes.

A dry run used to need no `gh`. With no `gh` on PATH,
`remote_is_empty` and `_remote_is_empty` now refuse `gh-not-found`, in
the words `pin_digest_from_gh` uses, where they raised a
`FileNotFoundError` traceback. The real runs get the same refusal.

`--help` says what each `--dry-run` asks, and `docs/cli.md` is
regenerated to match. Five existing tests dry-ran the scaffold against
GitHub with no stub, and the gh guard now catches their `repo view`
calls. Three take `--local-remote-dir`, since their assertions do not
depend on GitHub. Two need the GitHub plan line, so they ask
`conftest.scaffold_dry_run_on_github`, which runs the scaffold in
process with a stand-in for that one question; `family.py`'s twin test
does the same inline. The new tests are in
`tests/test_dry_run_asks_remotes.py`, plus a setup step (5) helper in
conftest with one-call wrappers in both setup test files.

Main at bfed143.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 14, 2026

Copy link
Copy Markdown

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 23 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 14, 2026

Copy link
Copy Markdown

Reviewer's Guide

Dry runs for family initialization and project scaffolding now invoke the real remote-existence/reuse checks after printing the plan, producing the same refusals or reuse output without mutating anything; missing gh is reported as gh-not-found, with CLI documentation and extensive offline/GitHub-path tests updated accordingly.

Sequence diagram for dry-run remote checks

sequenceDiagram
    participant User
    participant CLI
    participant RemoteCheck
    participant GitHubOrBareRepo

    User->>CLI: run --dry-run
    CLI->>CLI: print plan
    alt family init
        CLI->>RemoteCheck: _checked_reuse
    else project scaffold
        CLI->>RemoteCheck: _reusable_remotes
    end
    RemoteCheck->>GitHubOrBareRepo: gh repo view and commit listing
    GitHubOrBareRepo-->>RemoteCheck: remote status and optional seed commit
    alt remote exists and is not reusable
        RemoteCheck-->>CLI: Refusal family-remote-exists or scaffold-remote-exists
        CLI-->>User: refusal, create nothing
    else remote is empty and reuse enabled
        RemoteCheck-->>CLI: reuse output
        CLI-->>User: plan and reuse lines, create nothing
    else no remote exists
        RemoteCheck-->>CLI: continue
        CLI-->>User: plan, create nothing
    end
Loading

Sequence diagram for missing gh handling

sequenceDiagram
    participant User
    participant CLI
    participant RemoteCheck
    participant gh

    User->>CLI: run family or scaffold dry-run
    CLI->>RemoteCheck: _remote_is_empty or remote_is_empty
    RemoteCheck->>gh: gh repo view
    alt gh is not on PATH
        gh-->>RemoteCheck: FileNotFoundError
        RemoteCheck-->>CLI: Refusal gh-not-found
        CLI-->>User: remediation to install or authenticate gh
    else gh is available
        gh-->>RemoteCheck: repository status
        RemoteCheck-->>CLI: remote result
    end
Loading

Flow diagram for non-mutating dry-run behavior

flowchart TD
    A[Print execution plan] --> B{Remote check passes?}
    B -->|No| C[Refuse with matching real-run code]
    B -->|Yes| D{Reusable remote?}
    D -->|Yes| E[Print reuse line]
    D -->|No remote| F[Keep planned creation steps]
    C --> G[Exit without folder, remote, repository, work tree, or push]
    E --> G
    F --> G
Loading

File-Level Changes

Change Details Files
Make dry-run paths perform the real run's remote-reuse checks before declaring the plan complete.
  • Call _checked_reuse for family holder repositories after printing the plan.
  • Call _reusable_remotes for all scaffold repositories, including seed-reuse handling.
  • Preserve read-only behavior: no remotes, folders, work trees, or pushes are created.
  • Report reuse decisions and use the real run's refusal messages and exit behavior.
scripts/family.py
scaffold-project.py
Normalize missing GitHub CLI handling into the existing refusal mechanism.
  • Convert FileNotFoundError from gh probes into gh-not-found refusals with remediation text.
  • Apply the behavior consistently to family and scaffold remote checks.
scripts/family.py
scaffold-project.py
Document the expanded dry-run behavior and validate it across local and GitHub-backed paths.
  • Update both CLI help strings and regenerate the CLI reference.
  • Add coverage for existing, empty, seeded, absent, and GitHub-backed remotes, including no-gh behavior and byte-identical real/dry-run refusals.
  • Update setup-project/setup.sh assertions so step (5) refuses before confirmation.
  • Adjust existing tests to use local remotes or an in-process GitHub-question stand-in.
docs/cli.md
tests/test_dry_run_asks_remotes.py
tests/conftest.py
tests/test_family.py
tests/test_scaffold_e2e.py
tests/test_scaffold_leg_path_collisions.py
tests/test_scaffold_manifest_values.py
tests/test_scaffold_pin_and_reuse.py
tests/test_setup_project_py.py
tests/test_setup_sh.py

Assessment against linked issues

Issue Objective Addressed Explanation
#537 Make family.py init --dry-run check whether the holder remote exists and refuse it consistently with a real run, without creating the family folder. ✅
#537 Make scaffold-project.py --dry-run check whether its three remotes exist and refuse them consistently with a real run, so setup stops at the plan before asking for confirmation. ✅
#537 Handle a missing gh CLI with a named gh-not-found refusal rather than a traceback when the dry runs perform these remote checks. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-15T10:56Z)

This lane reviewed its own PR #573 at 0c81423, read-only and in its own detached worktrees. Nothing was authored.

MERGEABLE WITH NITS. Land #565 first or together with this PR.

Findings:

  1. P3, fix-in-PR. With --reuse-empty-repo and no gh, both scaffold runs still end in a traceback, raised by remote_visibility (scaffold-project.py:940-941, :196-199). That contradicts the title's "dry and real runs alike". Catch it there too, or narrow the claim.
  2. P3, follow-up. Without scaffold-project.py: a leg gh sees and git cannot read is refused scaffold-remote-exists with git's terminal prompt off, and the reuse re-run's visibility lines go by each leg as it stands (#542) #565, the dry run inherits scaffold-project.py re-run: a leg that gh sees and git cannot read gets a generic failure, and the reuse run's "two new legs" and private-leg text are wrong #542's N1: a generic refusal and git's prompt.
  3. P3, follow-up. The dry runs never ask *-target-exists, and never ask whether --work-dir or --local-remote-dir can be made.
  4. P3, follow-up. A real init makes the folder before it asks about the remote (family.py:1753-1754).
  5. P3, follow-up. Any gh repo view failure reads as "absent" in both runs.
  6. nit, follow-up. setup-project.py:1886 says "plan could not be produced". README's part-way box is now unblocked by tests/test_repo_hygiene.py and docs/handbook.html: each handbook section records the hash of every README section it reads, and README.md's line cap is 1422 plus one file per raise in tests/readme-cap.d/, so two README pull requests no longer conflict on the footer's one blob or the cap's one line, and a stale page names exactly which sections to re-read (#519) #551.

Tests:

Merges: clean with main, #565, #570, #572, #576, #577, #561 and lane 5's #568, #571, #574 and #578. #569 conflicts on additive helpers, and a union resolves it. #554 conflicts on imports. docs/cli.md passes --check on every merge.

The full report is in opensoft/brett-wip at handoffs/xFactory/attachments/lane-openRepoShape-2/review573-0c81423/final-report.md.

Brett Heap and others added 2 commits October 15, 2026 11:00
…lone_just_pushed`) so the PR's CI merge ref and this fix round run on current main

The brief named 2247dbf; origin/main in the shared store is b4c1f50, which is
2247dbf plus #553 (shape-doctor.py only), so that is the tip merged.

Conflicts, both on import lines only (rv537): tests/test_scaffold_e2e.py and
tests/test_scaffold_manifest_values.py. #554 moved those files' clones to
`cloned_just_pushed` and dropped `FILE_PROTOCOL`; this PR adds
`scaffold_dry_run_on_github` to the same import. Both are kept, and
`FILE_PROTOCOL` stays out because neither file uses it after #554. No test
this PR adds clones a just-pushed local remote by its plain path.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eady merged at b4c1f50) so the PR's CI merge ref and this fix round run on the main the coordinator fetched at 11:02Z

origin/main in the shared store moved from b4c1f50 to 7d9bec9 while this
round was running. The merge is clean, with no conflict: #561 changes
scripts/bump-leg.py, docs/cli.md and the bump-leg tests, none of which this
PR touches. `scripts/render-cli-reference.py --check` passes on the merged
docs/cli.md, so it is not re-rendered.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… `gh` is not on PATH, so `--reuse-empty-repo` with no `gh` is refused by name in the dry run and the real run alike, not a `FileNotFoundError` traceback (#537)

The PR says a missing `gh` is `gh-not-found` "in dry and real runs alike", but
the scaffold with `--reuse-empty-repo` never reached the refusal that says
so. `_leg_visibility` reads the root's visibility, through
`remote_visibility`, BEFORE either `_reusable_remotes` call asks
`remote_is_empty`, and `remote_visibility` let the `FileNotFoundError`
escape: exit 1 and a traceback, in both runs (rv537 finding 1; the same input
gave a traceback on main too).

`remote_visibility` now refuses `gh-not-found` as `remote_is_empty` does. The
refusal's words are one helper, `_gh_not_found(question, exc)`, so the two
reads cannot drift apart; `remote_is_empty`'s own text is unchanged byte for
byte. `_reused_leg_visibility` calls `remote_visibility` too and gets the same
refusal.

`test_a_dry_run_with_no_gh_is_refused_by_name` now runs each tool with and
without `--reuse-empty-repo` (4 cases), and the new
`test_a_real_run_with_no_gh_is_refused_in_the_dry_runs_words` runs the real
run of each, 4 more. Each asserts exit 2, the refusal's opening sentence and
the one `gh` argv asked, which is `repo view --json visibility` for the
scaffold with the flag and `--json name` for every other run. Two of the 8
fail on the old code, the scaffold's `--reuse-empty-repo` cases, with
`FileNotFoundError: [Errno 2] No such file or directory: 'gh'`.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-15T12:18Z)

This lane re-verified its own PR #573 at 69c58aa, read-only and in its own detached worktrees. Nothing was authored.

MERGEABLE. Land #565 first or together with this PR, and only on a green three-OS run.

  1. Fixed. With --reuse-empty-repo and no gh, both scaffold runs now refuse gh-not-found (scaffold-project.py:211-217). Probes of 22 no-gh paths through both tools gave exit 2 and no traceback. remote_is_empty's words are unchanged byte for byte.
  2. P3, follow-up, also on main. A real run makes its work directory or family folder before that refusal (scaffold-project.py:1539, family.py:1753).
  3. The two main merges change only import lines.

Tests:

Merges: clean with main, #565 and #570. #569 conflicts in three test files, and a union resolves them. test_scaffold_e2e.py merges cleanly and keeps FILE_PROTOCOL.

The full report is in opensoft/brett-wip at handoffs/xFactory/attachments/lane-openRepoShape-2/review573-0c81423/reverify-69c58aa.md.

brettheap added a commit that referenced this pull request Oct 15, 2026
…nnotated tag's own id as `add --at` does, and says to push first a commit the member does not hold after its fetch (#541) (#568)

`family.py bump --to` skipped two checks `add` makes, and failed in git's own words on a commit nobody had pushed. This PR fixes the three lines of #541 in `scripts/family.py`. Both runs, the dry run and the real one, now give the same answer for each line.

The head is 8024f9c. It has two commits on bfed143: eeadc65 is the fix, and 8024f9c answers review rv541 (see "Second commit" below).

- [x] **`bump --to` never asked `add`'s duplicate-id question.** If the member's `project.yaml` at `--to` recorded another row's `id`, `bump` committed it with exit 0. The holder's own `validate-family.py` then refused that commit as `member-duplicate-id`. Now `bump` refuses with `member-duplicate-id` before anything changes, and the holder stays byte-identical.
- [x] **`bump --to` an unpushed commit failed as git's `reference is not a tree`.** Now both runs refuse with `member-to-unknown`. The refusal names the commit, says no branch of the member's remote reaches it, and says to push it first. Once the commit is pushed, the same command pins it.
- [x] **Riding: `add --at <tag id>` peels it, `bump --to` refused the same id, and the two worded a tag of a tree differently.** Brett Heap ruled on 2026-10-14 that `bump --to` peels an annotated tag's own id, as `add --at` does.
  - `bump` now pins the commit the tag names, in both runs, and prints one line saying so.
  - A tag of a tree is now named in `add`'s words, "names a tree", through one shared function.
  - Both verbs' `--help` say that an annotated tag's own id is peeled.
  - One input still differs between the two verbs: a tag whose commit is on no branch (see "Left out").

- **`_checked_bumped_id`** (new) asks `add`'s question (#330) of the row `bump` writes. The member's own row is left out. Every other row is asked the way the validator asks it, so `bump` refuses what the gate would refuse and nothing it would pass. It is asked in three places:
  - by the dry run, before it prints the row;
  - by the real run, before its checkout (`_checked_bump_target`), so the refusal comes before the first write;
  - by the real run again, after its checkout (`_bump_and_pin`), of the id that checkout read. This catches a `project.yaml` that is a symbolic link, which only a checkout reads through. That refusal is put back by `PinRollback` (#365), like any refusal after the first write.
- **`_row_recording`** (new) finds the row that records an id. `add`'s `_checked_member_id` now uses it too. `add`'s words do not change.
- **`_checked_to_names_a_commit`** now returns the commit to pin:
  - a commit: itself;
  - an annotated tag, or a tag of a tag: the commit it names (`^{}`);
  - a tree, a blob or a tag of either: refused with `member-to-not-a-commit`, in `add --at`'s words (`_names_no_commit`, shared with `_probe_commit`);
  - an id the member does not hold after the fetch: refused with `member-to-unknown`. If the fetch failed, the refusal says so and does not claim the remote lacks the commit.
- **`cmd_bump`** goes on with the returned commit in both runs. When a tag was peeled, both runs print this as their first line: `--to <tag12> is an annotated tag in <repo>; the commit it names, <commit12>, is what would be pinned`. The words "would be" are deliberate: the dry run prints the line, and so does a run refused by a question asked after the peel, and neither pins anything.
- **`--help`.**
  - `add --at` now reads "pin this 40-hex commit instead of the member's current tip (an annotated tag's own 40-hex id is peeled to the commit it names)".
  - `bump --to` ends "(an annotated tag's own 40-hex id is peeled to the commit it names, as `add --at` peels one)".
  - `docs/cli.md` is regenerated by `scripts/render-cli-reference.py`.
- **Docstrings.**
  - The sentence that said `bump` refuses a tag "as `bump-leg.py` refuses the same `--to`" is gone (lane 4's note on #541), and so is "left to the checkout" for an id the member does not hold. Both files now compare only with `family.py add --at`, so they stay true whichever of #561 and this PR lands first.
  - `_checked_to_names_a_commit` now names the tag whose commit is on no branch.

- **S1.** The peel line said "is what is pinned" in the dry run and above a refusal. It now says "is what would be pinned". A new test puts a duplicate id behind a tag: both runs print the line, refuse `member-duplicate-id` in the same two lines, exit 2, and pin nothing.
- **N1.** A new test covers the branch hint with a tag's id on the tracking branch. The dry run's hint and the real run's refusal both name `family/bump-irrs-<the typed id's first twelve>`. The reviewer's mutant, in which the dry run takes its hint from the peeled commit, fails that test.
- **N2.** The tag whose commit is on no branch is named in the docstring, in "Left out" and as a checklist candidate. Its behaviour is unchanged.
- **N3.** The `--help` lines above, with `docs/cli.md` regenerated.
- **N4.** The merge-check paragraph below is redone at 8024f9c.
- **N5.** The ripple numbers in "Left out" and the checklist candidate are now the reviewer's measured ones.

1. **The wording for a tag of a tree.** `bump` now uses `add --at`'s words: "names a tree" and "names a blob", with `add`'s remediation sentence. Both verbs build the refusal from one function, `_names_no_commit`, and each keeps its own code (`member-at-unknown` and `member-to-not-a-commit`). I followed the brief's "exactly as `add --at` does".
   - #561 keeps "is an annotated tag of a tree" for `bump-leg.py`, under its own code and in its own frame.
   - Changing `add` to that wording would change `add`'s tested output, so it is not done here.
   - `add`'s tests pass unchanged (`test_family_add_refusals.py`, inside the 670 below).
2. **A new code, `member-to-unknown`.** It mirrors `add`'s `member-at-unknown` for the same input. The fetch's outcome selects one of two wordings, so a member that could not be fetched is never called unpushed.
3. **The peel is announced.** `add --at` peels a tag without saying so. `bump` prints one line in both runs, because the `old -> new` line the person reads then shows an id they did not type. The line says "would be pinned", the same words #561 uses for `bump-leg.py`.
4. **The suggested branch keeps the typed id.** The real run refuses the tracking branch before the fetch, so before any peel. The dry run's hint names the same branch, and a test now holds that. #561 does the same with `asked`.
5. **A linked `project.yaml` is still left to the real run by the dry run**, as `add --dry-run` leaves one (#326). The real run refuses the duplicate id after its checkout and puts the holder back. A test holds that.

New: `tests/test_family_bump_to_checks.py`, with 6 tests:
- the duplicate id read from the member's objects, refused alike by both runs;
- the duplicate id read through a linked `project.yaml` (POSIX only), refused and put back;
- a duplicate id behind a tag: both runs print the peel line, refuse, and pin nothing;
- the branch hint on the tracking branch with a tag's id, the same in both runs;
- an unpushed commit in a working clone: refused, then pinned once pushed;
- a commit missing after a failed fetch, not called unpushed.

Changed:
- `tests/test_family_bump_to_a_tag.py` now holds the ruling: the peel in both runs, the peel after the fetch, and the tree, blob and tag-of-one wording. Its `both_runs` helper also takes the stdout a run prints before its refusal. Its clone lines are untouched, so it merges cleanly with main, where #554 has landed.
- `tests/test_family_dry_run.py`: its test of a commit the member lacks now expects `member-to-unknown` from both runs.

Results at 8024f9c, through the lane's runner (`gh` stubbed, a bogus token, an empty `GH_CONFIG_DIR`):
- `test_family_bump_to_checks.py`, `test_family_bump_to_a_tag.py`, `test_family_dry_run.py`, `test_family_bump_remove_rollback.py`, `test_family_add_member_id.py`, `test_cli_reference.py`: **139 passed in 149.62s**
- `test_family.py`, `test_family_tracking_branch.py`, `test_family_add_refusals.py`, `test_family_add_excludes.py`, `test_family_member_path.py`, `test_family_printed_quoting.py`, `test_cli_reference.py`, `test_repo_hygiene.py`, `test_refusal_default_remediation.py`, `test_commit_trailers.py`, `test_agents_md_codes.py`, `test_stdout_encoding.py`: **670 passed, 1 skipped in 300.71s**. The skip is `test_stdout_encoding.py:286`, which runs on Windows only.
- `scripts/render-cli-reference.py --check`: `[ok] docs/cli.md is what the generator produces`.

The new and changed tests at bfed143, with the test files copied in:
- The 12 from the first commit: **12 failed, 60 passed**. Each fails for its line's reason:
  - on a duplicate id, the real bump moved the member;
  - on an unpushed commit, git's own words;
  - the tag refused;
  - the old tree wording.
- The 2 added in 8024f9c: **2 failed**. bfed143 refuses the tag, so no peel line is printed and the dry run exits 2.

The script scaffolds Atlas, Borg and Cello into bare remotes, makes a holder with Atlas and Borg, then runs each line. At bfed143, then at eeadc65; 8024f9c changes only the peel line's last words:

- **Line 1.** Atlas's `project.yaml` is changed to record `id: "borg"`, pushed, and Atlas is bumped to it.
  - bfed143: the dry run exits 0 with `id: borg`, and the real run commits (exit 0). `validate-family.py` then says `FINDING member-duplicate-id: Borg and Atlas both record id 'borg'`, exit 1.
  - eeadc65: both runs say `REFUSED member-duplicate-id: testorg/Atlas at <sha12> is already a member of Sonata under another name: ...`, exit 2. The holder's state hash is the same before and after, and the validator says `family ok`.
- **Line 2.** A commit is made in the working clone `work/Atlas` and not pushed.
  - bfed143: the dry run says `REFUSED resolving ... fatal: Needed a single revision`. The real run says `REFUSED checking members/Atlas out ... fatal: reference is not a tree`. Both exit 2.
  - eeadc65: both runs say `REFUSED member-to-unknown: --to <sha> is not a commit any branch of testorg/Atlas reaches: ...` and `Remediation: push it first, ...`. After `git push`, the same command exits 0 and `family ok`.
  - With the member's origin gone, eeadc65 says the fetch failed and does not claim the remote lacks the commit.
- **Riding line.** Atlas gets a commit and an annotated tag `v2`, both pushed.
  - bfed143: both runs refuse with `member-to-not-a-commit ... is an annotated tag`.
  - eeadc65: both runs print the peel line. The dry run shows `commit <old> -> <commit12>`. The real run pins the commit in `pin.commit` and in the gitlink alike, and the validator says `family ok`.
  - `add --at <Cello's tag id> --dry-run` pins the tagged commit at both shas.
- **Tag of a tree.**
  - bfed143: `bump` says "is an annotated tag of a tree" and `add` says "names a tree".
  - eeadc65: both say "names a tree".

No line needs real GitHub.

Each behavioural hunk was reverted or broken on its own in a throwaway worktree (M1 to M10 and R1 at eeadc65, M11 to M13 at 8024f9c), and the tests were run:

| Probe | Result |
|---|---|
| M1, the dry run's duplicate-id check removed | `test_bump_to_a_commit_recording_another_rows_id_is_refused_alike` fails |
| M2, the real run's pre-checkout duplicate-id check removed | the same test fails: the refusal comes after the checkout, worded differently from the dry run's |
| M3, the post-checkout duplicate-id check removed | `test_a_duplicate_id_read_through_a_linked_manifest_is_put_back` fails |
| M4, an id the member lacks goes on as before | both `member-to-unknown` tests fail |
| M5, a failed fetch worded as a successful one | the failed-fetch test fails |
| M6, the peel turned back into a refusal | both `pins_the_commit_it_tags` cases and the release-tag test fail (3) |
| M7, the old tree, blob and tag-of-one wording | the 4 `refused_by_name` cases fail |
| M8, the peel line not printed | the same 3 peel tests fail |
| M9, the returned commit ignored | the same 3 peel tests fail |
| M10, the member's own row asked against itself | every ordinary bump is refused: 4 of 4 selected tests fail, including `test_bump_dry_run_prints_the_move_and_a_real_bump_writes_exactly_that` |
| R1, `add`'s refusal written inline again (a refactor, no change in output) | `add`'s tag, tree and blob tests pass: 7 passed |
| M11, the dry run takes its branch hint from the peeled commit (the reviewer's D4 mutant) | `test_both_runs_name_the_branch_for_the_tag_id_as_typed` fails; 1 failed, 29 passed |
| M12, the peel line says "is what is pinned" again | 5 fail: the duplicate-id-behind-a-tag test, the branch test, and the 3 peel tests |
| M13, `bump --to`'s help without the peel | 5 `test_cli_reference.py` tests fail, `test_the_committed_reference_is_what_the_generator_produces` among them |

The merge is clean (rc=0) against each of these:
- origin/main 7d9bec9, the current tip. Lane 3's #554 (2247dbf) and lane 4's #561 (7d9bec9) have both landed since this branch was cut;
- lane 5's siblings on origin:
  - `fix/family-siblings-tracking-branch` at 43d32eb;
  - `fix/family-members-case-variants` at 40eda84;
  - `fix/family-remove-gitmodules-missing` at b1120c3;
  - `fix/link-source-chain-defaults` at 04c9d77;
- lane 2's #573 (`fix/537-dry-run-asks-remotes`) at 0c81423.

On the tree merged with main 7d9bec9, `scripts/family.py` and `scripts/bump-leg.py` parse, and `render-cli-reference.py --check` says `[ok] docs/cli.md is what the generator produces`. The review checked the same on the merges with `fix/link-source-chain-defaults` and lane 2's #573.

**#561 landed first, so this PR fixes the cross-reference** (lane 4's note on #541, issuecomment-6175160194). On main, #561 made two sentences false: `scripts/family.py:3991`, "REFUSED, NOT PEELED, as `bump-leg.py` refuses the same `--to`", and the module docstring at `tests/test_family_bump_to_a_tag.py:17`. This PR rewrites both to compare with `family.py add --at` only. The comparisons with `bump-leg.py` that remain still hold on main 7d9bec9: a tag whose commit is on no branch is still `bump-leg-commit-not-on-remote` there, in both runs, and the `nothing to do` sentence and the `trailers` lines are unchanged.

Main was not merged into this branch.

- **A commit only the member's mount holds is still pinned.** This is a commit made inside `members/<P>` and never pushed. The object is present, so `bump` proceeds.
  - #541 is about the commit that fails.
  - `bump-leg.py` refuses this input as `bump-leg-commit-local-only`. Refusing it here needs a ruling on offline bumps, because today a bump whose fetch failed goes on from the objects the member already has.
  - The reviewer measured the ripple with bump-leg's reachability rule applied: at least 12 cases in 5 files fail, and only 6 of them make their commit through `commit_in_mount`. The twelfth is this PR's own peel-line test.
  - It is the first checklist candidate.
- **A tag whose commit is on no branch.** `add --at` pins its id, but `bump --to` refuses the tag and its commit as `member-to-unknown`.
  - `bump`'s fetch brings every branch and, by git's own rule, only the tags that point into the history those branches bring. `add`'s bare clone brings every tag. A `git push origin v2` without the branch makes one, and the refusal's push to a branch is what lets `bump` pin it.
  - `bump-leg.py` refuses its twin the same way, as `bump-leg-commit-not-on-remote`. Fetching every tag would write refs the person's member did not have.
  - The behaviour is unchanged and named in `_checked_to_names_a_commit`'s docstring. It is the second checklist candidate.
- **`docs/release-notes/v1.0.0.md`** describes v1.0.0's behaviour (its lines 259-260, 461-462 and 487) and stays true of v1.0.0. The v1.1.0 notes should say what changed.
- **No README, AGENTS.md or handbook sentence became false**, so none is edited.

- **`family.py bump --to` a commit only the member's mount holds pins it, where `bump-leg.py` refuses `bump-leg-commit-local-only`.**
  - A commit made inside `members/<P>` and never pushed passes `bump`'s object check, because the object is in the mount's store. It is pinned with exit 0, and the holder then cannot be bootstrapped anywhere else.
  - `bump-leg.py` asks `for-each-ref --contains` against `refs/remotes/origin/` and refuses the same input.
  - A fix needs a ruling on offline bumps.
  - Under bump-leg's rule, at least 12 cases in 5 test files fail: 11 existing ones (review rv541's `c1.log` and `c2.log`) and this PR's own peel-line test. Only 6 of the 12 make their commit through `commit_in_mount`.
- **`family.py bump --to` refuses a tag whose commit is on no branch (`member-to-unknown`), where `add --at` pins the same id.**
  - The remedy it prints, pushing the commit to a branch, works. But a person who pushed only the tag is told to push.
  - Telling that case apart (for example with a `git ls-remote --tags` check, or a fetch of that one tag) is a change of its own, and so is any rule that brings it in line with `add`.

Review: rv541 found 1 should and 5 nits on eeadc65; the fix round 8024f9c was re-verified APPROVE WITH NITS, and its one nit (this message's merge paragraph and ripple count) was fixed in the body. CI (Brett Heap's ruling 27): H0 8024f9c passed tests, tests-macos, tests-windows and SonarCloud; the head 51ec8ff is a clean update-branch merge of main a2243f1, with tests and SonarCloud green. Landed on Brett Heap's word of 2026-10-15T13:32:47Z, "All five, once green".

Closes #541

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…h.py: #569's three #538 blocks sit where #573 inserts nothing, so the merge after #573 lands is clean and needs no new three-OS run (#538)

A PURE MOVE; nothing inside a block changes. #569 and #573 both insert at the
point after `assert_a_taken_clone_path_is_refused` (conftest.py) and after
`test_a_taken_clone_path_is_refused_before_anything` (test_setup_project_py.py
and test_setup_sh.py), so once #573 lands, #569 conflicts with main in all
three files, and a conflicted merge is a new H0 that needs a full three-OS
run (Windows alone takes over an hour and a half). With #569's blocks
elsewhere, the merge after #573 is clean and, by ruling 27, needs only Linux
and SonarCloud.

Moved:
  tests/conftest.py                the `# --- setup's paste lines, its step
                                   checks and the upstream guard (#538)`
                                   block, to the end of the file
  tests/test_setup_project_py.py   the `# --- #538, on an interpreter alone`
                                   wrappers, to just before `# --- setup.sh
                                   is a shim, not a second flag list`
  tests/test_setup_sh.py           the `# --- #538, through the shim`
                                   wrappers, to just before `# --- self-
                                   bootstrap`

Checked: each file's lines are the same multiset before and after and so are
its top-level definitions; pyflakes is clean; no name the conftest block
defines is used at import time outside it (it moved down, so what it needs is
still above it); `git merge-tree --write-tree` of this head with the
predicted main bd0c6b5c reports no conflict.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 15, 2026
…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>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 2989b4c into main Oct 15, 2026
3 checks passed
@brettheap
brettheap deleted the fix/537-dry-run-asks-remotes branch October 15, 2026 18:10
brettheap pushed a commit that referenced this pull request Oct 15, 2026
Main moved while this merge was being made: a61cf47 (#589) at the
coordinator's request, then 70331aa (#586, shape-doctor.py only) and
2989b4c (#573: family.py, scaffold-project.py, docs/cli.md's scaffold and
family sections, their tests). This merges 2989b4c, the tip at merge
time. 10 commits since 2e6c6fa: 8890f57, fb786c1 (#581), 26c1600,
2bf8fd4, 654c476 (#597), 3f768a6, 024ea55, a61cf47 (#589),
70331aa (#586), 2989b4c (#573).

One conflict, adopt-project.py, the end of `_entry_findings`' return:

  HEAD (#520) added `names_remediation` to the remediation list, so a
  misnamed `adopt_branch:` or `tracking_branch:` prints its remedy;
  main (#589, #449) added `pin_notes` to the notes, so a pin whose digest
  `check` could not recompute is said once at the end.

  Resolved by keeping both, each where its side put it:

            + names_remediation + tracking_remediation + moved_remediation
            + field_remediation,
            pin_notes + notes)

  The two edits touch different elements of the returned tuple, so
  neither changes what the other means: #589's
  `digest_findings, pin_notes = _pin_digest_findings(plan)` and its
  docstring paragraph auto-merged beside #520's `_branch_name_findings`
  call and paragraph.

docs/cli.md auto-merged (#573 changed the scaffold and family sections,
this branch the adopt section); `scripts/render-cli-reference.py --check`
passes on the merge.

Against the new main, each of this branch's six files (adopt-project.py,
docs/cli.md, tests/test_adopt_branch_names.py,
tests/test_adopt_plan_yaml_quoting.py, tests/test_adopt_split_branch.py,
tests/test_adopt_template_values.py) differs by exactly the +/- lines it
did against 2e6c6fa at c598ec3 (adopt-project.py: 399).

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 15, 2026
…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>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…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>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
… 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>
brettheap added a commit that referenced this pull request Oct 16, 2026
… drive that is not there, step (4) verifies a chain from `--pin-source`, `--pin` no longer takes a flag as its value, and step (5) says a printed plan was refused (#608) (#623)

Four defects in setup-project.py's step checks, from the #569 and #573
reviews. Each reproduces on main bcfa364.

- Step (3) asked only that what stands above the clone is a folder. A
  parent at mode 555 passed, all three repositories were created, and
  step (7) then refused `could not be made a folder (Permission
  denied)`; a path whose drive is not there passed too. Step (3) now
  makes a folder in the nearest existing parent and takes it away again,
  as step (7) does, and refuses by name with the system's reason when the
  folder cannot be made; a missing drive is refused naming it. Both come
  before the plan. Taking the probe away is best-effort, and names the
  folder when it cannot.
- Step (4) was given a chain but not the `--pin-source` the scaffold
  reads its links from, so a chain the plan verified showed
  `[declared-unverified]` and a chain its tree broke showed no break. The
  last `--pin-source` now goes to the naming validator as a bare
  `--link-source`, with a chain of two links at least (a chain of one
  has no holder to read a source for). An exactly empty one goes as no
  source at all: since #570 the scaffold refuses `--pin-source ''` as
  `pin-source-no-path`, so step (4) leaves it to the plan, which refuses
  it by name, and the validator is never asked about a flag the person
  never typed (`--link-source=` would end step (4) as
  `link-source-no-path`, and `.` names a folder the plan never reads). A
  blank value is a path and goes as typed.
- `passed_values` took a following word that starts with `-` as the
  flag's value, so `--pin --referent-chain x` gave step (4)
  `--pins=--referent-chain`. It leaves such a word out.
- Step (5) said "the plan could not be produced" under a plan that had
  printed, which #573's dry run does before it refuses an existing
  remote. After a refusal setup asks the plan once more, captured, and
  says "the plan was refused" when it printed; a plan that never printed
  keeps the old words, and a second run that does not refuse says the
  refusal did not repeat and to run setup again.

Closes #608

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
…they make can be made and take only gh's not-found answer as "absent", so `--dry-run` refuses a file where a folder goes, or a `gh repo view` that fails otherwise, by name as the real run does, and a refused `init` leaves no empty family folder (#609) (#622)

#459's review of #573 found three lines (L2-34, L2-36, L2-35), its review
of this change a fourth (L2-60), and a re-verify of that fix two problems
more:

- Neither dry run asked `scaffold-target-exists` or `family-target-exists`,
  or whether `--work-dir`, `--local-remote-dir` or the family folder could be
  made. In the seven cases measured (four scaffold, three `init`) each dry
  run exited 0 with `--dry-run: nothing was created.`, and the real runs gave
  two clean refusals and five `FileExistsError`/`NotADirectoryError`
  tracebacks.
- Any `gh repo view` failure read as "the repository does not exist": with
  a gh answering `HTTP 401: Bad credentials` both dry runs exited 0, the real
  scaffold failed at `gh repo create`, and the real `init` left an empty
  family folder.
- A real `init` made `<into>/<Family>/` before it asked whether the holder's
  remote exists, so a refusal left that folder empty.
- An existing EMPTY working tree or holder this user cannot write in passed
  the question about trees: the dry run exited 0, and the real run made the
  remotes and then died in a `PermissionError` traceback at the first file
  it wrote there (L2-60).
- `init --into <a folder named for the family>` asked `Path.is_file()` of the
  holder, which raised `PermissionError` for one with no search bit: a
  traceback from both runs before any question was asked.
- A folder a run makes that is only unwritable was told to "move what stands
  in the way aside", though nothing stands in the way.

Both tools now ask one question before anything is made, in the dry run and
the real run alike: every folder the run makes and each working tree it
writes, then every remote. The questions are one definition in
`scripts/shape_materialize.py`, which is not a shape copy:

- `folder_cannot_be_made` / `refuse_unmakeable_folder`: a file at the folder
  or above it, or a folder this user cannot write in, is
  `scaffold-folder-cannot-be-made` / `family-folder-cannot-be-made`; a file
  is told to be moved aside, and an unwritable folder to be given write and
  search permission or replaced by another `--work-dir`, `--local-remote-dir`
  or `--into`, the flag it was met by;
- `target_in_the_way`: a file where a working tree goes, and an empty folder
  this user cannot write in (`os.access(W_OK | X_OK)`, asked after the
  emptiness check), are `scaffold-target-exists` / `family-target-exists`,
  beside the non-empty folder both already refused; the empty unwritable
  tree is told to be given write and search permission, or to be replaced by
  another `--work-dir` (`--into` for an `init` landed by it), where the
  non-empty one keeps "choose an empty folder; there is no --force"
  (`target_remediation`);
- `refuse_unless_not_found`: only exit 1 with gh's `Could not resolve to a
  Repository` means absent; any other failure is `remote-unreadable`, naming
  the command, its exit and gh's first line;
- `make_folder`: the real run's own `mkdir`, after the question, refuses in
  the system's words rather than raising.

`family.py` asks the holder under `--into` named for the family with
`os.path.isfile`, which answers False for one it cannot search, so that
case is refused by name too. The real runs make their folders only after the
remote question, so a refused `init` leaves no family folder and a refused
scaffold no work directory. `--dry-run`'s help in both tools says so, and
`docs/cli.md` is regenerated. `tests/test_scaffold_manifest_values.py`'s fake
gh now answers `repo view` as GitHub answers for a repository it does not
have, the one failure the scaffold still reads as absent. The two tests that
spelled POSIX `strerror` text build it from the exception `mkdir` raises
instead (L2-67).

Closes #609

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>
brettheap added a commit that referenced this pull request Oct 16, 2026
…y.yaml` record arrives as `leg: drop`, that the adopted Makefile's `CONTRACTS_DIR` follows the spec leg's mount, and that a second setup run is refused at the plan, so the three sentences #566, #572 and #573 left false or incomplete are true of main (#606) (#627)

One README and handbook change for three lines of the v1.1.0 checklist
triage (D7), with the words #566, #572 and #573's bodies held, checked
against the code on main first, and for one sentence #621 (#602) leaves
false.

- L2-50, "Ambiguous is an answer": a source's own `single-repository.yaml`
  record is written `leg: drop`, its rule's default, still
  `review_required: true` with its question beside it, and `execute` takes
  it as written. It drops the record unless a person changes `leg:`; the
  question offers `root`, and `check` passes `spec` too, which the #566
  body's "unless a person writes `root`" left out.
- L2-51, the worked example: the root Makefile exports `CONTRACTS_DIR` at
  the `contracts/` of the spec leg where the plan mounts it,
  `$(CURDIR)/spec/contracts` by default (#572).
- L2-41, the "If it fails part-way" box: a second run is refused at the
  plan, before the `yes`, because the plan is the scaffold's `--dry-run`
  and asks whether each of the three already exists (#573). The #573 body
  said step (5), which is the number `setup-project.py` prints; the README
  numbers the plan (3), so it says (3).
- The Free-plan secrets (#621's rule): the README said a private leg's CI
  credential is kept "as REPOSITORY secrets on the GitHub Free plan",
  which is false of a public root, because the assembly root's `validate`
  reads the secrets and on Free the organisation's secrets reach public
  repositories. README § Starting now says "on the assembly root when it
  is private or internal and the organisation is on the GitHub Free plan",
  in the six lines it had; "Reading private legs in CI" says the root
  decides and that a public root takes org secrets registered with
  `--visibility selected` and the root in `--repos`, or `all`, which
  shows them to every repository, since the commands above it default
  to `private`, which on Free reaches no repository (+3 lines); the
  handbook's `ci` part says the same.

`tests/readme-cap.d/606.txt` raises the cap by the ten lines (1449 to
1459). The handbook's list item and adopt paragraph carry the same
sentences, and its markers move to the new hashes of README § Starting a
project in a new organisation (seven page parts), § The three legs (three)
and § Adopting an existing repository (one). No footer clause: ruling 29
retired them.

Lands after #621: the Free-plan sentences describe the root rule #621 puts
in the tools.

Closes #606

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

family.py init --dry-run and scaffold-project.py --dry-run exit 0 without asking whether the remotes already exist

1 participant