Skip to content

scaffold-project.py and family.py: both runs ask whether each folder 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

Merged
brettheap merged 6 commits into
mainfrom
fix/609-dry-run-parity
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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

Merge and round (2026-10-15): main 396c0ff (#570) is merged in (c3d8a97), with no conflict to resolve, and every case below still holds on it, #574's family.py changes included. The round adds #459's L2-60 and #461's L2-67 (60e1601), described under L2-34 and Tests, a second commit (5263d78) that gives L2-60's refusal a remediation that fits, and a third (227bc3b) for the two problems the reviewer's re-verify of that found: a traceback for init --into named for the family over a holder with no search bit, and the same kind of remediation for a folder that is only unwritable.

What changed

scaffold-project.py and scripts/family.py init now ask the same question in the dry run and the real run, before anything is made: every folder the run makes and each working tree it writes, then every remote. The real run makes its folders only after all of that.

  • L2-34: folders and target-exists. Before the remote question, both runs ask:

    • whether --work-dir, --local-remote-dir and the family folder can be made. A file at the folder or above it is refused, and so is a folder this user cannot write in. The codes are scaffold-folder-cannot-be-made and family-folder-cannot-be-made. A file is told to be moved aside; a folder that is only unwritable is told to be given write and search permission, or replaced by another --work-dir, --local-remote-dir or --into (the flag it was met by): "Remediation: nothing was created, and --dry-run refuses the same. Give this user write and search permission on that folder, or choose another , and run this again." It is the sentence the trees get, from one _unwritable_remediation.
    • whether a working tree is in the way. A file where a tree or the holder goes is now scaffold-target-exists / family-target-exists, beside the non-empty folder both tools already refused.

    The real run's own mkdir (make_folder) refuses in the system's words, so a folder that changes between the question and the mkdir gets a refusal too. No run ends in a FileExistsError or NotADirectoryError traceback any more.

  • L2-60: an existing EMPTY working tree or holder this user cannot write in. The question about a working tree passed any folder that held nothing, so with such a tree at mode 555 the dry run exited 0 and the real run made the bare remotes and then died with a PermissionError traceback at the first file it wrote there (on GitHub, that leaves created repositories behind). target_in_the_way now asks, after the emptiness check, the question folder_cannot_be_made asks of a folder to make, os.access(W_OK | X_OK), and answers is a folder this user cannot write in (one constant, shared by both). A folder that holds anything is still is not empty, whatever its mode. Both tools already asked target_in_the_way for every tree and for the holder before the remote question, so each refuses it by name, scaffold-target-exists or family-target-exists, in the dry run and the real run, before any remote is made, and no caller changed.

    • Its remediation. The tools' own sentence for this code, "choose an empty --work-dir. There is no --force: re-running over a live tree…", is about a tree that holds something, and wrong for an empty folder whose only fault is its permissions. target_remediation (scripts/shape_materialize.py) is the one place that chooses, by what target_in_the_way said. For the unwritable folder it says: "Remediation: nothing was created, and --dry-run refuses the same. Give this user write and search permission on that folder, or choose another <flag>, and run this again." The flag is --work-dir for the scaffold, and for init the one the person landed by: --work-dir where there is no family folder, --into otherwise. Every other answer (a file, a folder that holds something) gets the tool's own sentence, exactly as it was. The finding codes are unchanged.
  • init --into named for the family, over a holder this user cannot search. Orbit/Orbit is the holder there, and _landing asked (into / family / MANIFEST).is_file() of it: Path.is_file() raised PermissionError for a holder with no search bit (mode 600), so both runs ended in a traceback before any question; at mode 555 it was already refused by name. It is the same on main 396c0ff. _landing now asks os.path.isfile, which answers False, as _standard_checkout beside it does. Every other .is_file() or .exists() on the landing path is guarded already: the folder and tree questions use os.path.lexists and stat / iterdir inside try, and the two bare.exists() calls (_local_remote_is_empty, both tools) come after the --local-remote-dir question, which refuses a folder with no w or no x first (probed at modes 600 and 000, the folder itself and a path below it, and the default landing in a folder named for the family).

  • L2-36: only gh's not-found means absent. gh 2.x answers a missing repository with exit 1 and GraphQL: Could not resolve to a Repository with the name '<o>/<r>'. (repository). The words come from GitHub's GraphQL error, and gh's FetchRepository builds the same ones when the API returns no repository. The string is in the local gh 2.96.0 binary. Only exit 1 with those words reads as absent. Every other gh repo view failure, in the dry run and the real run, is remote-unreadable, which names the command, its exit and gh's first line. That covers a 401, a rate limit, no network, gh's exit 4, and the not-found words under any exit but 1.

  • L2-35: no empty family folder. A real init asks _checked_reuse before _prepare_landing_dir, so a refusal leaves no folder. The scaffold's real run likewise no longer makes --work-dir (or a temporary directory) before its remote question.

The questions are one definition in scripts/shape_materialize.py, which is not a shape copy, so nothing is re-pinned: folder_cannot_be_made, refuse_unmakeable_folder, make_folder, target_in_the_way, target_remediation and refuse_unless_not_found. Both tools import them.

--dry-run's help in both tools names the folder question, and docs/cli.md is regenerated (render-cli-reference.py --check passes).

tests/test_scaffold_manifest_values.py's fake gh used to answer every call fake gh: no network here, exit 1. It now answers repo view the way GitHub answers for a repository it does not have, so test_a_value_once_misread_reaches_gh_as_an_accepted_one_does still reaches gh repo create.

Tests

New file: tests/test_dry_run_parity.py, 86 tests: the 43 of the first head, 9 for L2-60, 11 for its remediation and 23 for the third commit.

  • The issue's seven measured cases (four scaffold, three init) and six beside them: a file above --work-dir or --local-remote-dir, --into a file, the family's --work-dir a file or holding a file named for the holder, and the family's --local-remote-dir a file. In every case both runs refuse with the same REFUSED line and the same stdout and stderr, and nothing under the test's directory is made, changed or removed, the temporary directory included.
  • A folder this user cannot write in, for both tools.
  • A stub gh answering repo view in each way, for both tools and both modes. The failing answers are an HTTP 401, a rate limit, no network, exit 4, and the not-found words under exit 4: each is refused by name at the first repo view, and nothing is created. gh's not-found answer still plans (dry run) and goes on to gh repo create (real run). One more case covers --reuse-empty-repo with a 401.
  • L2-35: a real init refused family-remote-exists leaves no into/. The scaffold's real run refused scaffold-remote-exists or gh-not-found leaves no --work-dir.
  • The real run's own mkdir refusing by name, when the question is made to say yes.
  • In-process unit tests of the five helpers.
  • L2-60: one test per tool (scaffold's root, init's holder), per mode (--dry-run, real run) and per way the empty tree cannot be written in (no w, mode 555; no x, mode 600): 8 tests. Each asserts the REFUSED line, exit 2, no traceback, nothing made or changed under the test's directory and no bare remote. Plus an in-process test that target_in_the_way names an empty unwritable folder and still says is not empty for one that holds a file. They are skipped for root and on Windows, as the unwritable fixture is, which is now built on the same maker.
  • The remediation: the eight L2-60 run tests also pin the new sentence on each tool's unwritable row (--work-dir for the scaffold, --into for init); two more pin --work-dir under init --work-dir; eight pin that a tree holding a file or a non-empty folder keeps its old sentence exactly, per tool and mode; one tests the chooser. On 60e1601's source 13 of the 63 fail (the eight keep-old tests pass there, as they pin what must stay).
  • The third commit: the Orbit/Orbit layout (--into named for the family) at modes 555 and 600, dry run and real run, refused by name with nothing made; one row per flag for the unwritable folder's sentence (scaffold --work-dir and --local-remote-dir; init --into, --work-dir and --local-remote-dir), dry run and real run, 10 tests; the ancestor case (ro/wd) pins it too; eight cases where a file stands in the way keep the old "move what stands in the way aside"; one tests refuse_unmakeable_folder's choice. On 5263d78's source 15 of the 86 fail (the 8 keep-old tests and the 555 layout pass there, as controls).
  • L2-67: the two tests that spelled POSIX strerror text (File exists, Not a directory) now build the expected text from the exception mkdir raises (mkdir_says), so they hold where the system words it otherwise. Under an uncommitted plugin that words os.mkdir's failures as Windows does, the old file fails 5 tests and the new one passes the same 5.

Red on main 2989b4c: 41 failed, 2 passed (the first head's 43 tests). The two that pass are the not-found controls, which must pass on main.

Red on the merge (main 396c0ff and this file, before the L2-60 change): 9 failed, 43 passed. The 9 are the L2-60 tests: each dry run exited 0 and each real run made the bare remotes and then raised PermissionError.

Runs (Linux, /usr/bin/python3 3.12, targeted files only; the first three rows are the first head's):

Run Tree Result
New file on main 2989b4c + the test 41 failed, 2 passed
Targeted: every test_scaffold*, test_family*, test_setup*, plus test_dry_run_asks_remotes, test_dry_run_parity, test_reuse_visibility, test_tracking_branch_ref, test_free_plan_hint, test_cli_reference, test_repo_hygiene 2989b4c + the fix 1775 passed, 19 skipped
New file, with Path.write_text writing CRLF in pytest and every child the fix 43 passed
test_dry_run_parity, test_dry_run_asks_remotes the merge c3d8a97 71 passed
New file with the L2-60 tests the merge, before the L2-60 change 9 failed, 43 passed
Same targeted set, in two runs c47bde20 (this head less one test-only fixture merge) 913 + 1041 = 1954 passed, 19 skipped (test_family_init_values' template-quoting skips)
New file, plain and with the CRLF plugin 60e1601 52 passed, 52 passed
Confirmation: test_dry_run_parity, test_dry_run_asks_remotes, test_family, test_family_init_values, test_family_init_landing, test_family_dry_run, test_scaffold_manifest_values, test_cli_reference, test_repo_hygiene 60e1601 554 passed, 19 skipped
New file on 60e1601's source, before the remediation change 60e1601 + the new tests 13 failed, 50 passed
test_dry_run_parity, test_dry_run_asks_remotes 5263d78 91 passed
New file, with the CRLF plugin 5263d78 63 passed
test_dry_run_parity, test_dry_run_asks_remotes, test_family_dry_run, test_family_init_landing, test_family_init_values, test_scaffold_manifest_values, test_cli_reference, test_repo_hygiene 5263d78 459 passed, 19 skipped
New file on 5263d78's source, before the third commit 5263d78 + the new tests 15 failed, 71 passed
test_dry_run_parity, test_dry_run_asks_remotes, test_family_dry_run, test_family_init_landing 227bc3b 184 passed
New file, with the CRLF plugin 227bc3b 86 passed
The four files above, every test_scaffold*, test_cli_reference, test_repo_hygiene 227bc3b 905 passed
Every test_family* and test_setup* 227bc3b 976 passed, 19 skipped

An independent probe (the reviewer's, run by hand against a stub gh that refuses anything but repo view) agrees on the merge: the seven cases, the 401 stub in both tools and both modes, and --reuse-empty-repo each give the same REFUSED line in the dry run and the real run with nothing made, and the L2-60 case X1 and X2 refuse alike after the change.

Mutants: 39 killed of 39, none survived, each run against the new file in a scratch clone.

  • The first head's 13, re-run on the merged code: any failure reading as absent again, and the not-found words accepted under any exit; init making its folder before _checked_reuse; either dry run skipping the folder question; no writability check, and the question asked of the path only; no question about what stands at the target, no --local-remote-dir question, no holder question, no tree question; make_folder letting the OSError out; the scaffold making --work-dir before the remote question.
  • L2-60's, 9: target_in_the_way asking nothing; asking only W_OK (killed by the no-x tests) or only X_OK (the no-w tests); asking the folder above the tree; asking before the emptiness check (killed by the unit test alone); the scaffold's dry run or its real run skipping the trees; init's dry run skipping the holder; init's holder question ignoring the answer.
  • L2-67's, 2: make_folder quoting the whole exception, or a fixed File exists.
  • The third commit's, 8 (on 227bc3b): _landing asking Path.is_file() again (killed by the two mode-600 Orbit/Orbit tests); an unwritable folder getting the old sentence again, or every folder refusal getting the new one; init's family-folder question naming no flag; _landing_flag always --into; init's --local-remote-dir question naming --into, and the scaffold's naming --work-dir; refuse_unmakeable_folder ignoring the flag it is given.
  • The remediation's, 7 (on 5263d78): the chooser always returning the old sentence (the unwritable case gets it), or never (a held tree gets the new one); init always saying --into, or always --work-dir; the scaffold saying --into; each caller asking the chooser about a held tree.

README follow-up

None. README's and AGENTS.md's words about the plan and init --dry-run stay true as written.

Closes #609

🤖 Generated with Claude Code

Summary by Sourcery

Make scaffold and family initialization preflight all filesystem targets and remote lookups consistently, refusing unsafe or indeterminate runs before creating files or repositories.

New Features:

  • Align dry-run and real-run validation for scaffold and family initialization so both reject unmakeable folders and occupied or unwritable targets before creating anything.
  • Treat only GitHub CLI's explicit repository-not-found response as an absent remote, and refuse other repository lookup failures.

Bug Fixes:

  • Prevent refused family initialization and scaffold runs from leaving empty directories or partially created remotes behind.
  • Replace filesystem tracebacks caused by files, permissions, or inaccessible paths with named refusals and actionable remediation.
  • Handle family initialization paths that cannot be searched without raising a permission traceback.

Enhancements:

  • Centralize folder, target, remediation, and remote-lookup checks for reuse by both commands.
  • Report system-specific directory creation errors without hard-coded platform wording.

Documentation:

  • Update CLI help and reference documentation to describe preflight folder checks and dry-run behavior.

Tests:

  • Add comprehensive parity tests covering filesystem conflicts, permissions, remote lookup failures, refusal remediation, and platform-specific mkdir errors.
  • Update fake GitHub CLI responses to use the canonical repository-not-found response.

…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)

#459's review of #573 found three lines, measured on main 26c1600 and at
#573's head; this branch is cut from main 2989b4c, where #573 has landed.

- L2-34. 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.
- L2-36. 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.
- L2-35. A real `init` made `<into>/<Family>/` before it asked whether the
  holder's remote exists, so a refusal left that folder empty.

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, so nothing is
re-pinned:

- `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`;
- `target_in_the_way`: a file where a working tree goes is
  `scaffold-target-exists` / `family-target-exists`, beside the non-empty
  folder both already refused;
- `refuse_unless_not_found`: only exit 1 with gh's `Could not resolve to a
  Repository` (gh 2.x's words for GitHub's NOT_FOUND) 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.

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.

Tests: `tests/test_dry_run_parity.py`, 43, of which 41 are red on main
2989b4c (the two not-found controls pass there, as they must).

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

sourcery-ai Bot commented Oct 15, 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 4 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR centralizes filesystem and GitHub remote preflight logic, makes scaffold and family init ask identical questions in dry-run and real modes before creating anything, and adds extensive tests and documentation for refusal semantics and side-effect-free failures.

Sequence diagram for parity-safe scaffold and family initialization

sequenceDiagram
    participant User
    participant Tool as ScaffoldOrFamilyInit
    participant Preflight as shape_materialize
    participant GH as gh_repo_view
    participant FS as Filesystem
    participant Writer as RemoteAndTreeWriter

    User->>Tool: run(dry_run_or_real)
    Tool->>Preflight: refuse_unmakeable_folder()
    Tool->>Preflight: target_in_the_way()
    Tool->>GH: repo_view(repository)
    GH-->>Tool: exit 1 + Could not resolve to a Repository
    Tool->>Preflight: refuse_unless_not_found()
    Preflight-->>Tool: remote is absent
    alt dry run
        Tool-->>User: print plan, create nothing
    else real run
        Tool->>Preflight: make_folder()
        Tool->>Writer: create remotes and working trees
        Writer->>FS: write folders and trees
    end
Loading

Flow diagram for preflight refusal decisions

flowchart TD
    Start[Start scaffold or family init] --> Folders[Check folders with folder_cannot_be_made]
    Folders --> Targets[Check trees or holder with target_in_the_way]
    Targets --> Remote[Run gh repo view]
    Remote --> Status{Exit 1 and gh not-found words?}
    Status -->|No| Unreadable[Refuse remote-unreadable]
    Status -->|Yes| Repos[Continue as repository absent]
    Folders -->|Cannot make| FolderRefusal[Refuse scaffold-folder-cannot-be-made or family-folder-cannot-be-made]
    Targets -->|File, non-empty, or unreadable| TargetRefusal[Refuse scaffold-target-exists or family-target-exists]
    Repos --> Mode{Dry run?}
    Mode -->|Yes| Plan[Print plan; make nothing]
    Mode -->|No| Make[make_folder, then create remotes and trees]
Loading

File-Level Changes

Change Details Files
Align dry-run and real-run preflight checks so all filesystem destinations are validated before any writes.
  • Added shared checks for creatable parent folders, writable directories, existing files, and non-empty working-tree targets.
  • Moved folder creation until after filesystem and remote checks, preventing leftover temporary, work, or family directories.
  • Wrapped race-condition mkdir failures as named refusals instead of traceback errors.
  • Applied the same ordering and refusal behavior to scaffold and family initialization.
scaffold-project.py
scripts/family.py
scripts/shape_materialize.py
docs/cli.md
Treat only GitHub CLI's canonical exit-1 not-found response as evidence that a remote is absent.
  • Added shared validation of gh repo view results, including exit status and first diagnostic line.
  • Classified authentication, rate-limit, network, exit-4, and malformed not-found responses as remote-unreadable.
  • Updated both scaffold and family remote probes to use the stricter classification.
scaffold-project.py
scripts/family.py
scripts/shape_materialize.py
Add comprehensive parity and failure-mode coverage for the new preflight behavior.
  • Added 43 tests covering filesystem conflicts, permissions, remote failures, ordering, no-side-effect guarantees, helper behavior, and race-time mkdir failures.
  • Updated the fake gh fixture to emit the canonical GitHub not-found response.
  • Added CLI documentation and help-text assertions for folder preflight questions.
tests/test_dry_run_parity.py
tests/test_scaffold_manifest_values.py
docs/cli.md

Assessment against linked issues

Issue Objective Addressed Explanation
#609 Make scaffold and family.py init dry runs check whether the folders they need can be created and whether their working-tree targets are clear, refusing files, non-empty targets, and unwritable locations as the real runs do. ✅
#609 Treat only GitHub CLI’s not-found response as evidence that a repository is absent; refuse other gh repo view failures by name in both dry and real runs. ✅
#609 Have family.py init check whether the holder’s remote can be reused before creating its family folder, so a refusal leaves no empty folder. ✅

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-15T19:28:50Z)

This lane reviewed its own PR #622 at 7f7e1b4, read-only in its own detached worktrees, and authored nothing.

MERGEABLE WITH NITS, with no sequencing constraint.

  • P3, follow-up: an empty tree target or holder this user cannot write in is still not asked about (scripts/shape_materialize.py:1363). The dry run exits 0. The real run makes the remote, then raises a PermissionError traceback. Main does the same.
  • nit, follow-up: tests/test_dry_run_parity.py:369 and :608 assert POSIX strerror text with no Windows skip.

A separate probe checked the seven cases and the 401 stub in both tools and both modes. Each time the dry and real runs refused alike and made nothing. 11 of 13 mutants were killed.

Tests at the head: 1775 passed, 19 skipped; 967 passed, 1 skipped. Merged: 1898 passed, 19 skipped; 947 passed, 1 skipped. merge-tree with bcfa364 is clean.

Report: handoffs/xFactory/attachments/lane-openRepoShape-2/review622-7f7e1b4/final-report.md in opensoft/brett-wip.

Brett Heap and others added 2 commits October 15, 2026 22:30
… 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>
…holder this user cannot write in is refused `scaffold-target-exists` / `family-target-exists`, by the dry run and the real run, before any remote is made, so neither exits 0 and then dies in a `PermissionError` traceback after the bare remotes exist (L2-60, #609)

#459's review of #622 (L2-60, rv609's P3): `target_in_the_way` asked only
whether the target held anything, so an empty folder at mode 555 passed both
tools' questions. With local remotes the dry run exited 0 with
`--dry-run: nothing was created.`, and the real run made the bare remotes and
raised `PermissionError: [Errno 13] ... README.md` from the first write into
the tree (`_build_assembly_root`, `_create_holder_worktree`). Against GitHub
that leaves created repositories behind.

`target_in_the_way` now asks, after the emptiness check, the question
`folder_cannot_be_made` asks of a folder to make: `os.access(W_OK | X_OK)`,
answered `is a folder this user cannot write in` (one constant, shared with
`folder_cannot_be_made`). A folder that holds anything is still `is not
empty`, whatever its mode. Both tools already asked `target_in_the_way` for
every tree and the holder before the remote question, so no caller changed.

Tests (tests/test_dry_run_parity.py), red on the merge of main 396c0ff
before this change: one test per tool, mode (`--dry-run` or real) and
permission (no `w`, no `x`), 8 in all, and the in-process
`target_in_the_way` test; each is skipped for root and on Windows as the
`unwritable` fixture is.

L2-67 (#461's review of #622, rv609's nit 2): the two tests that spelled POSIX
`strerror` (`File exists`, `Not a directory`) now build the expected text
from the exception `mkdir` raises, `mkdir_says`, so they hold where the
system words it otherwise.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 15, 2026 23:02
…ee this user cannot write in says to give this user write and search permission on that folder or choose another `--work-dir` (`--into` for an `init` landed by it), so it no longer tells a person to "choose an empty --work-dir" about a folder that is empty (L2-60, #609)

The previous commit (L2-60) made `target_in_the_way` answer "is a folder this
user cannot write in" for an empty tree, but both callers kept their one
remediation for it: "choose an empty --work-dir. There is no --force:
re-running over a live tree is not a scaffold." That is about a tree that
holds something, and wrong for an empty folder whose only fault is its
permissions.

`target_remediation` in scripts/shape_materialize.py is now the one place that
chooses, by what `target_in_the_way` said: for `CANNOT_WRITE_IN` it says
"nothing was created, and `--dry-run` refuses the same. Give this user write
and search permission on that folder, or choose another <flag>, and run this
again", and for every other answer it returns the tool's own sentence
unchanged. The scaffold passes `--work-dir`; `init` passes the flag the
person landed by, `--work-dir` where there is no family folder and `--into`
otherwise, so `_refuse_a_taken_holder` takes the folder. The finding codes
(`scaffold-target-exists`, `family-target-exists`) are unchanged.

tests/test_dry_run_parity.py pins the new sentence on the unwritable row of
each tool (the eight run tests) and under `init --work-dir`, pins that a tree
holding a file or anything else keeps the old sentence exactly, and checks
the chooser itself.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…amily>` refuses a holder this user cannot search by name instead of raising `PermissionError`, and a folder a run makes that is only unwritable is told to be given write and search permission or replaced by another `--work-dir`, `--local-remote-dir` or `--into`, so neither ends in a traceback or tells a person to move aside what is not in the way (#609)

rv622r's re-verify of the L2-60 change found two things.

Problem 1. With `--into` named for the family (or the working directory
named for it), `Orbit/Orbit` is the holder, and `_landing` asked
`(into / family / MANIFEST).is_file()` of it. For a holder at mode 600, with
no search bit, `Path.is_file()` raised `PermissionError` (3.12 ignores only
ENOENT, ENOTDIR, EBADF and ELOOP), so both runs exited 1 with a traceback
before `_refuse_a_taken_holder` could name the folder; with the search bit
(mode 555) it was already refused by name. It is the same on main 396c0ff
(#79). `_landing` now asks `os.path.isfile`, which answers False, as
`_standard_checkout` beside it already does. Every other `.is_file()` or
`.exists()` on that path is already guarded: the folder and tree questions
use `os.path.lexists` and `stat`/`iterdir` inside `try`, and the two
`bare.exists()` calls (`_local_remote_is_empty` in both tools) come after
the `--local-remote-dir` question, which refuses a folder with no `w` or no
`x` first (probed at modes 600 and 000, the folder itself and a path below
it).

Problem 2. `FOLDER_REMEDIATION` says "Move what stands in the way aside",
which is wrong for an existing folder (`--work-dir`, `--local-remote-dir`,
the family folder, or one above it) whose only fault is its permissions.
`refuse_unmakeable_folder` now takes the flag to name (`--into` for the
family folder, which `named` spells "the family folder") and chooses by what
`folder_cannot_be_made` said: for `CANNOT_WRITE_IN` it says what the trees'
`target_remediation` says, from one `_unwritable_remediation`; every other
answer keeps `FOLDER_REMEDIATION` exactly. `family.py` names the flag by
`_landing_flag`, also used for the holder's refusal.

tests/test_dry_run_parity.py: the `Orbit/Orbit` layout at modes 555 and 600,
both runs; one row per flag (scaffold `--work-dir` and `--local-remote-dir`,
`init` `--into`, `--work-dir` and `--local-remote-dir`), both runs; the
ancestor case; eight files-in-the-way cases that keep the old sentence; the
chooser.

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
…es in Git for Windows' own bash, so README and the handbook say Git Bash runs the one-line install, tested in CI but for a typed yes, which it cannot take there (#585) (#596)

**Fix round 7: main after #598 (3fb21d9) (head 52524fe).** Lane 3's #598 (#595) landed on main as 3fb21d9. It rewrote README step 3's `install.sh` paragraph and the handbook's masthead and `start` for `uv`, and for the two readinesses install.sh now ends with. It also changed `install.sh` and `tests/test_install_sh.py`, and added `tests/test_install_sh_filter_repo.py`. One merge commit, **52524fe3**, merges main 396c0ff: 55faad2 (#591), fbbbc8d (#572), 7fdec0a (#624), 3fb21d9 (#598) and 396c0ff (#570). No other change.
- **Conflicts.** README.md had 1 region (step 3's `install.sh` paragraph). docs/handbook.html had 8: the consent rules' `install.sh` item and the seven § Starting markers. `tests/test_install_sh.py` merged as text, and so did the handbook's masthead and `start`.
- **The head holds all of main.** `git merge-tree --write-tree 52524fe origin/main` gives 52524fe's own tree, 6d8ffea. `git diff --stat origin/main 52524fe` names only this PR's six files (436+, 147-).

**The README and handbook resolution with #598 (52524fe).**
- **README, before.** This PR (4c3b72a) had added "; in Git Bash it can take none" after step 3's "each behind a typed `yes` at your terminal". #598 rewrote the same paragraph: "It installs `git-filter-repo` with `pipx`, or `uv`, where it can, or prints the command, runs `openRepoShape --preflight`, and says last if the machine is ready to adopt or only to scaffold. `install.sh` never runs `sudo`; the preflight offers what is missing, each behind a typed `yes` at your terminal."
- **Fitting the cap.** Keeping both made nine lines where #598 has eight, and README was at its cap. The cap was not raised. Two rewordings fit it, and no fact is dropped:
  - "`install.sh` is attached to every release and installs the newest, with no `gh` and no login." became "`install.sh`, attached to every release, installs the newest with no `gh` and no login."
  - #598's "`install.sh` never runs `sudo`;" became "It never runs `sudo` itself;". "It" is install.sh, the subject of the sentence before it, and "itself" is #564's own word for the contrast with the preflight.
- **README, after** (8 lines, at most 80 characters, as 78 README lines on main already are):
  ```
  `install.sh`, attached to every release, installs the newest with no `gh` and no
  login. Its command is PINNED to the release tag, recorded in `openRepoShape.ref`
  beside it: `setup.sh` and the standard are fetched at that tag, not at main. It
  installs `git-filter-repo` with `pipx`, or `uv`, where it can, or prints the
  command, runs `openRepoShape --preflight`, and says last if the machine is ready
  to adopt or only to scaffold. It never runs `sudo` itself; the preflight offers
  what is missing, each behind a typed `yes` at your terminal; in Git Bash it can
  take none. `| bash -s -- v1.0.1` names a release; the same line again upgrades.
  ```
  #598's `pipx`/`uv` sentence and this PR's clause are word for word. README is **1449** lines, its cap (1422 + 5 for `550.txt` + 22 for `564.txt`).
- **The Git Bash clause is still true after #598.** Under `curl … | bash`, `[ -t 0 ] || exec </dev/null` (install.sh:342; #598 moved it down from :277 and left its text alone) leaves the preflight no terminal on stdin. In Git Bash, setup-project.py runs on native Windows Python, where `open_terminal()` returns None (`os.name == "nt"`, setup-project.py:1372). So `can_ask()` is False, and `offer()` takes no `yes`. #598 changes what install.sh does before and after the preflight, not how the preflight reads an answer.
- **Handbook, consent rules.** The `install.sh` item keeps #598's text, "installs `git-filter-repo` with `pipx` or `uv` where one is there, and itself never runs `sudo` or installs either of them", and then this PR's clause, word for word: "; in Git Bash it can take no `yes`, so it installs nothing."
- **Handbook, masthead and `start`.** They merged as text: this PR's "macOS, Linux, WSL2, Git Bash" heading and PowerShell line, beside #598's "where `pipx` or `uv` can install it … and whether the machine is ready to adopt or only to scaffold". The `start` step-3 paragraph is #598's. It is `data-platform="linux mac wsl"`, as before, so it carries no Git Bash clause.
- **The seven § "Starting a project in a new organisation" markers.** This PR's 278a1947 conflicted with #598's d1666fa6. All seven now record `badbb6b273215042ba0e8ae167fd760dc25430ee`, computed with `tests/test_repo_hygiene.py`'s own `readme_segments` and `readme_fingerprint`. That driver reproduces both parents' hashes (main d1666fa6, 4c3b72a 278a1947). All 23 markers match the merged README.
- **`tests/test_install_sh.py`.** #598's hunks are `no_pipx` and `no_pipx_but_apt`'s printed commands, the test's docstring, and the line before the printed command, which now starts "there is neither pipx nor uv". They sit beside this PR's `bash_path` in `present_on_path` and in the same test's preflight line. Both sides' tests keep their meaning.

**Fix round 6: retarget to main after #587 (22eacee) (head 4c3b72a).** #587 squash-landed on main as 22eacee at 2026-10-15T20:27:52Z, and GitHub retargeted this PR to `main`. 22eacee's tree, 831e71d, is `git merge-tree --write-tree 2b6dbb7 3d8fa05`: main holds exactly #587's final head 3d8fa05. So this round adds two merge commits and no other change:
- **3080ac0d** merges #587's final head, 3d8fa05. It conflicted only in README.md and docs/handbook.html, with lane 4's #588 (709eec1), which 3d8fa05 had merged from main. The resolution keeps both sides' text (below).
- **4c3b72ae** merges main, 77bb5f0: 22eacee, plus 2b6dbb7 (#584), which the squash carried but 3d8fa05 lacked, and #615's 77bb5f0, which landed after it. Four files conflicted, because the squash is not an ancestor of 3d8fa05 (merge base 62ef256). For each of them, main's copy is the same blob as 3d8fa05's, and 77bb5f0 does not touch it. Each therefore takes this branch's copy, which is 3d8fa05's copy plus this PR's change:

  | File | `3d8fa05e:` = `origin/main:` (77bb5f0) = `22eaceed:` |
  |---|---|
  | README.md | d164131 |
  | docs/handbook.html | 59ec34d |
  | tests/test_install_sh.py (add/add) | 34c7de5 |
  | tests/test_openreposhape_command.py | 5e49547 |

  No file was merged by hand. `git merge-tree --write-tree 4c3b72a origin/main` gives 4c3b72a's own tree, 97843ec, so the head holds all of main. `git diff --stat origin/main 4c3b72a` names only this PR's six files: `.github/workflows/tests.yml`, README.md, docs/handbook.html, `tests/conftest.py` and the two suites (430+, 141-).

**The README and handbook resolution with #588 (3080ac0).** #588 ends README's "Windows, natively" and the handbook's Windows panel with one sentence: "Printed commands are quoted for a POSIX shell, so paste them into Git Bash. Only `shape-doctor.py`'s lines, `setup-project.py`'s family lines and `family.py`'s `NEXT`, `switch -c`, `add` and `git rm -r --cached` lines are quoted for PowerShell, on Windows." That sentence now follows this PR's Git Bash sentences, word for word. `tests/test_readme_windows_quoting_sentence.py` holds the handbook to README's words, and it passes.
- **README**, before (3d8fa05): "That two-liner IS the Windows way in; the `openRepoShape` command in step 3 below is a macOS, Linux and WSL2 convenience with no Windows twin, because on Windows there is nothing to install. Printed commands are quoted …"
- **README**, after: "That two-liner IS the Windows way in from PowerShell, which has no twin of step 3's `openRepoShape` command. Git Bash runs that command and step 3's one line, tested in CI but for a typed `yes`, which its preflight cannot take there. Printed commands are quoted …"
- **Fitting the cap.** The two texts side by side came to seven lines where #588 had six, and README was at its cap. So "no twin of the `openRepoShape` command in step 3 below" became "no twin of step 3's `openRepoShape` command". Step 3 is "Install the `openRepoShape` command", so the meaning is the same. The six lines rewrap, and README stays at **1449** lines, its cap (1422 + 5 for `550.txt` + 22 for `564.txt`). No raise.
- **#588's other README hunk**, the tighter `python setup-project.py` and "Two commands rather than one pipe" paragraphs, merged as text.
- **Handbook Windows panel.** It keeps this PR's "There is no `openRepoShape` command to install in PowerShell … so none of its offers can be accepted there." and then #588's sentence, unchanged.
- **The seven § "Starting a project in a new organisation" markers** (this PR's 34723d9a against #588's d77a1e59) now record `278a19479524fe8aae292daaca84492902b140ed`, the merged section's hash. It was computed with `tests/test_repo_hygiene.py`'s own `readme_segments` and `readme_fingerprint`, and all 23 handbook markers match the merged README.
- **Consistency.** Every Windows, Git Bash, PowerShell and WSL2 sentence was read again in the merged text. #588's sentence is true in Git Bash too: the named tools run under native Windows Python there, where `os.name` is `nt`, so their lines are quoted for PowerShell, "on Windows", as it says. See checklist candidate 7.

**Fix round 5 (head fe58d66).** One comments-only commit to `tests.yml`, for the re-review's N1 and N3. The header says the FULL suite runs on Linux only, so read alone it stays true beside the exception. The job's comment gives the measured "about a minute and a half (86 s on the first run, 50 s of it pytest)" where it said about three minutes.

**Fix round 4 (head 5f001de).** Brett Heap merged #597 ("ci: run the test suite on Linux only") at 2026-10-15T16:54:43Z, which removed the Windows job these suites ran in. His word at 2026-10-15T18:20:35Z: "Option 1: keep a small Git Bash job for #596". That round added two commits:
- **f6552862** merged lane 3's `l3-564-carry-to-main` b23b245 (#587's branch, this PR's base until fix round 6), which held main 654c476 and so #597. `tests.yml` became that branch's, byte for byte: the guard step and the macOS comment edit went with the two jobs #597 removed. The handbook keeps #597's macOS panel beside this PR's Git Bash text, and its seven markers were moved.
- **5f001de1** adds the `tests-git-bash` job, and fixes the macOS sentence that #597 made say "CI runs on Linux only".

v1.0.1's one-line install (#564) is meant to work in Git Bash on Windows, but nothing ran it there: `tests/test_openreposhape_command.py` and `tests/test_install_sh.py` wore `WINDOWS_SKIP`. They now run in the `tests-git-bash` job, under Git Bash.

**Which bash.** `BASH` in `tests/conftest.py` is Git for Windows' `usr\bin\bash.exe`, found from `git --exec-path`, never from PATH. Probe 1 on `windows-latest` showed why:
- A bare `bash` through `subprocess` is System32's WSL launcher. It prints "Windows Subsystem for Linux has no installed distributions." in UTF-16. CreateProcess searches System32 before PATH.
- `shutil.which("bash")` finds `Git\bin\bash.EXE`. That launcher puts Git's `mingw64\bin` and `usr\bin` first on PATH, so Git's real `curl` came before the suite's fake one. The old install.sh suite, run that way, reached github.com and printed the real newest release: `release: v1.0.1, the newest release of opensoft/openRepoShape (https://github.com/opensoft/openRepoShape/releases/latest)`.

A `git_bash_session` fixture puts Git's `mingw64\bin` (or `clangarm64\bin` on ARM64), `usr\bin` and `MSYSTEM` where Git Bash puts them, behind any fake a test puts first. `BASH_SKIP` replaces `WINDOWS_SKIP` in these two files only.

**What the tests now do on Windows.**
- **Paths.** Every path handed to bash, or expected in what it prints, is spelled the way Git Bash spells it (`/d/a/...`). The conversion uses Git's own `cygpath` (`bash_path`, `native_path`). Git Bash converts `HOME`, `TMPDIR` and `PATH` itself.
- **Line endings.** Every script a test writes for bash is written as LF bytes. The fake's redirect headers and its `gh` tag are written as bytes: Python on Windows prints `\r\n`, and `$(...)` keeps the `\r`.
- **The executable bit.** Git Bash's drives are mounted `noacl`, so `chmod 755` changes nothing. A file that starts with `#!` runs anyway. `--install`'s output is checked by mode 755 on POSIX, and by bash's own `test -x` on Windows.
- **A test's own PATH.** On Windows it holds `#!/bin/sh` wrappers. A symlink to a Git Bash program cannot load its DLL: with symlinks, `mktemp` failed and install.sh refused `could not make a temporary directory`.
- **An argument holding a newline** reaches Git Bash through the environment. On the Windows command line it was split in two: `v1\nforged` arrived as "two versions given". In Git Bash, bash hands bash the argument whole, so the test now does the same.
- **`ln -s`.** A new test makes a link with Git Bash's own `ln -s`, after dropping any `$MSYS` the caller set. By default that is a copy, which has no `openRepoShape.ref` beside it and so follows main. With `MSYS=winsymlinks:nativestrict` it is a real link, which reads the record.
- **Two anchors (fix round 2).**
  - The fetched-setup.sh test now also asserts that the parent of the stub's directory exists. Its "the directory is gone" line therefore cannot pass on a path Python cannot open. Both asserts read one `ran_dir = native_path(ran_in)` (fix round 3), so the existence check cannot be reverted to `Path(ran_in)` alone.
  - The `gh` shim test already could not pass with the shim unreachable: it asserts that the shim's own log holds exactly `auth status`, and only the shim writes that log. Probe 4 reverted its `os.pathsep` and it still passed: the shim stayed reachable either way, most likely through MSYS's conversion of PATH, which the log does not show.

**The workflow (fix round 4).**
- **The header** keeps #597's text and adds one sentence: "One exception, on Brett Heap's word at 2026-10-15T18:20:35Z, "Option 1: keep a small Git Bash job for #596": the `tests-git-bash` job below runs the launcher's and the installer's suites, and nothing else, on Windows under Git Bash."
- **`tests-git-bash`**: `runs-on: windows-latest`, `timeout-minutes: 15`, and no `if:`, so it runs on every trigger the workflow has: a push to main, a pull request, a manual run. Its steps:
  1. checkout and setup-python, at the ubuntu job's two commits, with `fetch-depth: 0`;
  2. `python -m pip install --disable-pip-version-check pytest==8.3.4`;
  3. the git identity, appended to `GITHUB_ENV` in PowerShell, as the old Windows job did;
  4. "Git Bash is the bash the launcher and installer suites run": fails the job when conftest finds no Git Bash, and prints the bash the suites will run;
  5. `python -m pytest -q -rA tests/test_openreposhape_command.py tests/test_install_sh.py --basetemp=${{ runner.temp }}\t`.
- **Its env** is the old Windows job's: `PYTHONUTF8=1`, `core.autocrlf=false` and `core.longpaths=true` through `GIT_CONFIG_COUNT`.
- **Its comment** says why it exists (#585, the one-line install on Windows), why it runs two suites only (#597), why its bash is Git's `usr\bin\bash.exe` (pointing to conftest), and why it runs on a push to main.

**The docs.**
- **README § "Windows, natively" and the handbook's Windows panel** say: PowerShell has no twin of the command; Git Bash runs it and the one-line install, tested in CI but for a typed `yes`, which the preflight the one-line install ends with cannot take there. #588's sentence about POSIX-quoted printed commands follows (fix round 6).
  - Why: install.sh:342 (:277 before #598) runs `exec </dev/null` under `curl … | bash`, and setup-project.py's `open_terminal()` has no Windows path.
  - Nothing installs without a typed `yes`, so consent holds.
  - Fix round 4: this sentence was left with nothing behind it once #597 removed the Windows job. `tests-git-bash` makes it true again, so its text is unchanged.
- **README's macOS sentence and the handbook's macOS panel (fix round 4).** #597 made them say "though CI runs on Linux only and no run of it checks them against bash 3.2". With this job that is false. They now say "though no CI run, on Linux or in Git Bash on Windows, checks them against bash 3.2". README rewraps inside the same four lines.
- **Step 3's sentence about the preflight's offers** (README) and **the consent rules** (handbook) add that in Git Bash it can take no `yes`. Since fix round 7, both clauses follow #598's `pipx`-or-`uv` text, and README's paragraph is reworded to fit (above).
  - The handbook's own step-3 install paragraph is `data-platform="linux mac wsl"`, so it is already scoped away from Windows, and is left.
- **The WSL2 sentence** says WSL2 "runs every bash entry point in Linux itself", instead of being "still how you run" them.
- **The handbook's masthead** lists Git Bash beside macOS, Linux and WSL2.
- **README keeps its line count**: 1449, the cap (1422 + 5 for `550.txt` + 22 for `564.txt`).
- **The seven handbook markers** for README.md § "Starting a project in a new organisation" record its new hash, `badbb6b273215042ba0e8ae167fd760dc25430ee` (fix round 7, after #598). Fix round 4's merge set them to `a88ac2d0…`, 5f001de moved them to `34723d9a…` for the macOS sentence, and fix round 6 to `278a1947…` after #588. Every hash was computed with `tests/test_repo_hygiene.py`'s own `readme_segments` and `readme_fingerprint`.

- **A separate small job, on Brett Heap's word.** At 2026-10-15T18:20:35Z he chose "Option 1: keep a small Git Bash job for #596". The option read: "A small Windows job for just these two suites. The probe runs took 2-3 minutes end to end, so it finishes well inside the 34-36 minute Linux job and adds no wall-clock time. The README claim stays true. It is an exception to #597's decision, so it needs your word." So the job runs these two suites and nothing else. It is not a matrix entry on the ubuntu job, whose actionlint download is a linux-amd64 binary.
- **Every trigger, a push to main included.** A squash lands a tree no pull-request run tested (#122), and these two suites are the only run of the one-line install on Windows. At about a minute and a half beside the ubuntu job (86 s on its first run), it adds no wall-clock time.
- **What the job keeps from the old Windows job, and why.**
  - `PYTHONUTF8=1`: the suites decode bash's output with `text=True`, which uses the locale's code page (cp1252) outside UTF-8 mode, and the launcher's `--help` prints em dashes (openRepoShape:108, :119-120).
  - `core.autocrlf=false` and `core.longpaths=true`: every Windows run of these suites had both, #585's four probes included, and no run without them exists.
  - `fetch-depth: 0`: `test_a_ref_the_doctor_would_check_out_is_validated` sets `OPENREPOSHAPE_REPO` to this checkout, so the launcher clones it, and every Windows run of it cloned a full one.
  - `--basetemp=${{ runner.temp }}\t`: the old job's short temp root, for MAX_PATH.
- **What it leaves out.**
  - **git-filter-repo.** These suites never reach a real one. The install suite's PATH holds only its own fakes, `git-filter-repo` and `pipx` among them. The launcher's machine report gets a fake `git-filter-repo` on the PATH `machine_path` builds. On Linux, with every PATH directory that held a `git-filter-repo` removed (`filter-repo=none` in the run log), the two suites gave **113 passed**. `test_it_installs_the_git_filter_repo_ci_runs` still finds the ubuntu job's pin.
  - **actionlint.** The ubuntu job lints this file.
- **`usr\bin\bash.exe`, not `bin\bash.exe`, and not PATH.** The probe-1 lines quoted above.
- **`git_bash_session` is needed, though the runner's own PATH hides it.**
  - The runner's PATH already holds Git's `bin`, `usr\bin` and `mingw64\bin`, with `bin` before System32 (probe-1 PATH dump). So reverting the fixture changes nothing there (probe 2: 113 passed).
  - A stock Git for Windows install puts only `Git\cmd` on PATH. Probe 3 ran the two files on exactly that PATH, where `which mktemp` is `None` and `which bash` is `C:\Windows\system32\bash.EXE`:
    - with the fixture: **113 passed**;
    - with it reverted: **59 failed, 15 passed, 39 errors**, for example `bash: line 199: mktemp: command not found` and `this machine has no mktemp, which the install needs`.
- **`bash -s` runs still take their script through a text-mode pipe.** That pipe hands bash CRLF on Windows, but Git Bash's bash drops a carriage return before a newline: a CRLF script piped to it printed `crlf-ok` and exited 0 (probe 1). Sending bytes instead changed no result (mutation `text-stdin`, 113 passed), so that change was dropped.
- **Python can make symlinks on the runner** (`os.symlink: ok`), so the three existing symlink tests run there unchanged.
- **The macOS sentence names Git Bash without a line.** "No CI run, on Linux or in Git Bash on Windows, checks them against bash 3.2" is true with this job, and fits README's four lines. A clause naming the two suites would have needed a fifth line and a cap raise.
- **Not claimed: that setup's own `yes` is asked in Git Bash.** Typed at a Git Bash prompt, `openRepoShape <Project>` reads its `yes` through native Windows Python. Whether that Python sees a console depends on Git Bash's pseudo-console support, and nobody has confirmed it on a Windows machine.

- **Linux, on the head 52524fe** (fix round 7, `dirty=0`):
  - the two suites: **113 passed** in 41.23s;
  - `tests/test_install_sh_filter_repo.py` (#598's, unchanged): **18 passed** in 7.32s;
  - `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_cli_reference.py`, `tests/test_setup_by_hand_commands.py` and `tests/test_readme_windows_quoting_sentence.py`: **171 passed** in 25.43s.
  - `git grep -l 'install.sh' tests/` finds `conftest.py`, `readme-cap.d/564.txt`, `test_install_sh.py`, `test_install_sh_filter_repo.py` and `test_repo_hygiene.py`. Every test file among them was in these runs. No skips.
  - The merge, before its commit: `test_repo_hygiene.py`, `test_readme_sections.py` and `test_readme_windows_quoting_sentence.py`, 144 passed.
- **actionlint 1.7.12 on 52524fe**, on `tests.yml` and the two template workflows: exit 0, with shellcheck on PATH. `tests.yml` is the same blob as at 4c3b72a (5fc46de); #598 does not touch it.
- **Linux, on the head 4c3b72a** (fix round 6, `dirty=0`):
  - the two suites: **113 passed** in 28.57s;
  - `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_cli_reference.py`, `tests/test_setup_by_hand_commands.py` and `tests/test_readme_windows_quoting_sentence.py`: **171 passed** in 17.09s.
  - Those cover every `tests/test_install_sh*.py` (there is one) and every test file `git grep -l 'install.sh' tests/` finds (`test_install_sh.py`, `test_repo_hygiene.py`; the other two hits are `conftest.py` and `readme-cap.d/564.txt`).
  - 3080ac0's merge, before its commit: `test_repo_hygiene.py`, `test_readme_sections.py` and `test_readme_windows_quoting_sentence.py`, 144 passed.
- **actionlint 1.7.12 on 4c3b72a**, on `tests.yml` and the two template workflows: exit 0, with shellcheck on PATH. The workflow keeps main's #593 `concurrency` block byte for byte (this PR's two `tests.yml` hunks are the header and the new job), the header's "The FULL suite runs on LINUX ONLY" with the exception sentence, and `tests-git-bash`.
- **Linux, on the head 5f001de** (`dirty=0`): the two suites, `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_cli_reference.py` and `tests/test_setup_by_hand_commands.py`: **281 passed** in 61.24s.
- **Linux, fix round 4, before its commit:**
  - the two suites: 113 passed;
  - the two suites with no `git-filter-repo` on PATH: **113 passed**;
  - the four docs and hygiene files: 168 passed;
  - the merge alone, before its commit: `test_repo_hygiene.py` and `test_readme_sections.py`, 141 passed.
- **actionlint 1.7.12**, fetched and checksum-checked as the ubuntu job does, on `tests.yml` and the two template workflows: exit 0, with shellcheck on PATH.
- **The YAML parse.** Triggers: `push` to `main`, `pull_request`, `workflow_dispatch`. Jobs: `tests` (ubuntu-latest) and `tests-git-bash` (windows-latest, `timeout-minutes: 15`). Neither has an `if:`, so both run on every trigger.
- **Linux, earlier rounds:** on 01e8c2c, the same five files without `test_setup_by_hand_commands.py`, 276 passed. Lane 5's re-review got the same 276 there. On 649b089, with `tests/test_setup_by_hand_commands.py`, `tests/test_agents_md_codes.py` and `tests/test_gh_guard.py` as well, 334 passed.
- **Windows, `windows-latest`, through probe branches that ran only these suites:**
  - Probe 1 (run 38752845751): 109 passed, 2 failed, 0 skipped. The two failures were the newline arguments, fixed since.
  - Probe 2 (run 38754067543, tree ebba347): **113 passed, 0 skipped**, in 68.83s.
  - Probe 3 (run 38757083883, on 649b089): **113 passed** in 50.97s. On a stock Git for Windows PATH (only `Git\cmd`): 113 passed.
  - Probe 4 (`l5-585-probe-4`, on 01e8c2c): **113 passed** in 77.92s (run 38769166462).
- **The same two files as they were at 5191f0e, with only `WINDOWS_SKIP` lifted** (probe 1): 88 failed, 23 passed. Two causes: the WSL launcher, and the real network through `Git\bin\bash.EXE`.
- **Windows on 5f001de, the PR's own run of `tests-git-bash`:** **113 passed** in 50.28s, with the whole job taking 1 min 26 s (18:37:00Z to 18:38:26Z, check run 116601758252).
  - The guard step printed `C:\Program Files\Git\usr\bin\bash.exe`.
  - Its steps and env are probe 4's, less the git-filter-repo install, and with `-q -rA` and a 15-minute job timeout.

Probe 2 ran these on **ebba347f**, the tree before the squash, which still had a `run_piped` helper; the `text-stdin` row is that helper. The squashed head 649b089 was then re-measured whole by probe 3 (113 passed), and fix round 2's head 01e8c2c by probe 4. Each row reverts one Windows hunk and runs the two files again.

| Hunk reverted | Result |
|---|---|
| `BASH` is the bare word `bash` | 108 failed |
| paths keep Windows' spelling | 25 failed |
| scripts written with `write_text` (CRLF) | 1 failed: `test_a_release_whose_command_records_no_ref_exits_one`, whose launcher's first line became `#!/usr/bin/env bash\r` and was refused as not the command |
| the fake prints its headers and tag in text mode | 22 failed |
| mode 755 checked on Windows too | 2 failed |
| symlinks on a test's own PATH | 28 failed |
| newline arguments on the command line | 2 failed |
| no `git_bash_session` | 113 passed on the runner's PATH; on a stock Git for Windows PATH (probe 3) 59 failed and 39 errors, against 113 passed with it |
| `bash -s` script sent as bytes (`run_piped`) | 113 passed either way; this hunk was dropped |

Fix round 2's anchors (probe 4, on 01e8c2c): 

| Run | Result |
|---|---|
| the fetched-setup.sh test, `native_path(ran_in)` reverted to `Path(ran_in)` in both asserts | exit 1: 1 failed. The new parent-directory anchor catches it. Since fix round 3 both asserts read one `ran_dir`, so this is the only way to revert it. |
| the `gh` shim test, its PATH joined with `:` instead of `os.pathsep` | exit 0: 1 passed. The shim stays reachable, so the hunk is not load-bearing on this runner. The log shows only that outcome; MSYS's conversion of PATH is the likely reason. The test is still not vacuous: it asserts that the shim's own log holds exactly `auth status`, and only the shim writes that log, so an unreachable shim would fail it. |
| both `ln -s` cases with `MSYS=winsymlinks:nativestrict` exported, head | exit 0: 2 passed |
| the same, with the `MSYS` drop reverted | exit 1: 1 failed (`ln-default` made a link), 1 passed |

#590 adds four cases to these two suites, written while they skipped on Windows:
- `test_a_command_that_is_a_symlink_is_refused_before_any_request[live|dangling]`;
- `test_install_refuses_a_target_that_is_a_symlink[live|dangling]`.

Each compares a path bash printed with `str(path)`. Each also compares `os.readlink`'s answer with the target, and on Windows that answer typically starts `\\?\` (Python's `os.readlink` documentation, since 3.8; the probe's failures stop at the first assert, so no log shows it). Linux passes on the merged tree (117 passed), but under Git Bash those four cases fail, and `tests-git-bash` runs them.

**Whichever of #596 and #590 lands second applies these hunks** to the merged tree. Probe 4 (run 38769166462) applied exactly these.

```diff
--- a/tests/test_install_sh.py
+++ b/tests/test_install_sh.py
@@ -668,12 +668,14 @@ def test_a_command_that_is_a_symlink_is_refused_before_any_request(
     result = net.run()
     assert result.returncode == 2, result.stdout + result.stderr
     said = " ".join(result.stderr.split())
-    assert f"{link} is a symbolic link, so nothing was installed" in said, said
+    assert f"{bash_path(link)} is a symbolic link, so nothing was installed" \
+        in said, said
     assert "pipx upgrade openreposhape" in said
     assert "pipx uninstall openreposhape" in said
     assert net.calls() == [], net.calls()
     assert link.is_symlink()
-    assert link.readlink() == packaged
+    # Windows reads a link back with `\\?\` ahead of its target.
+    assert str(link.readlink()).removeprefix("\\\\?\\") == str(packaged)
     if not dangling:
         assert packaged.read_bytes() == original
     assert sorted(path.name for path in net.bin.iterdir()) == \
--- a/tests/test_openreposhape_command.py
+++ b/tests/test_openreposhape_command.py
@@ -407,17 +407,18 @@ def test_install_refuses_a_target_that_is_a_symlink(tmp_path, dangling):
     link = bin_dir / "openRepoShape"
     link.symlink_to(packaged)
     result = run_cmd("--install", home=tmp_path,
-                     env={"OPENREPOSHAPE_BIN_DIR": str(bin_dir),
+                     env={"OPENREPOSHAPE_BIN_DIR": bash_path(bin_dir),
                           "OPENREPOSHAPE_REF": "v9.9.9",
                           "OPENREPOSHAPE_RECORD_REF": "1"})
     assert result.returncode == 2, result.stdout + result.stderr
     said = " ".join(result.stderr.split())
-    assert f"{link} is a symbolic link, so --install will not copy this " \
-        "command through it" in said, said
+    assert f"{bash_path(link)} is a symbolic link, so --install will not " \
+        "copy this command through it" in said, said
     assert "pipx upgrade openreposhape" in said
     assert "pipx uninstall openreposhape" in said
     assert link.is_symlink()
-    assert os.readlink(link) == str(packaged)
+    # Windows reads a link back with `\\?\` ahead of its target.
+    assert os.readlink(link).removeprefix("\\\\?\\") == str(packaged)
     if not dangling:
         assert packaged.read_bytes() == original
     assert not packaged.with_name("openRepoShape.ref").exists()
```

Both files already import `bash_path` once #596 is in. On Linux, the merge of 649b089 with d090f0c, with these hunks: **117 passed**. Fix round 7, on the merge of 52524fe with #590's head 1c8a708, with fix round 6's resolved `tests.yml` (both inputs unchanged since; the result is blob 266c62c): the hunks apply (`git apply --check` exit 0). The two suites (117 by collection), `tests/test_install_sh_filter_repo.py` (18), `tests/test_openreposhape_package.py` and `tests/test_repo_hygiene.py` give **270 passed** in 48.69s. Fix round 6: on the merge of 4c3b72a with #590's current head 1c8a708, with `tests.yml` resolved as below, the hunks apply (`git apply --check` exit 0) and the two suites give **117 passed**; with `tests/test_openreposhape_package.py` and `tests/test_repo_hygiene.py`, 252 passed. Probe 4 runs the merge of 01e8c2c with `l3-563-pypi-package` on Windows: the four cases without the hunks, then both suites with them. In run 38769166462, on Windows:
- **Without the hunks**: exit 1, `4 failed, 113 deselected`. These are exactly the four cases. The launcher pair failed on `REFUSED: D:\a\_temp\…\bin/openRepoShape is a symbol…`, which confirms rv585's inferred launcher failures; the install pair failed on `REFUSED: /d/a/_temp/…/.local/bin/openRepoShape …`.
- **With the hunks**: they apply (exit 0), and both suites give **117 passed** in 75.13s.

**`tests.yml` conflicts with #590 (fix round 6; unchanged in fix round 7, where #590's head is still 1c8a708 and this PR's `tests.yml` is the same blob).** #590 now targets `main` too: at fix round 6 its merge ref's first parent was main 77bb5f0. Its head 1c8a708 has merged #587's final head and main, so it holds #597, and it merges with main cleanly. Both PRs add to the same two places in `tests.yml`, so against 4c3b72a there are three regions:
- **The header.** Keep this PR's "The FULL suite runs on LINUX ONLY" and its exception sentence, then #590's four lines about the `package` job.
- **The end of the file, two regions.** git splits the two new jobs where they share the checkout and setup-python lines. Keep both jobs whole: `tests-git-bash`, then #590's `package`.
- **Checked.** I built that file: this PR's `tests.yml` with #590's header paragraph and its `package` job added. Against #590's file it differs only in this PR's two hunks, and against this PR's file only in #590's two. `yaml.safe_load` gives the jobs `['tests', 'tests-git-bash', 'package']`, and actionlint 1.7.12 exits 0 on it. #590's `package` comment now says "the other jobs" and "on Linux like the rest of the suite since #597", which stay true beside `tests-git-bash`. Fix round 4's advice to drop `tests-windows` and `tests-macos` and to reword "three jobs" no longer applies: #590 has done both.

The open pull requests are the 17 with a `refs/pull/<n>/merge` ref in `git ls-remote origin 'refs/pull/*'`: 583, 590, 594, 596, 599, 614, 617-623 and 625-628. Each head was merged with 52524fe and, separately, with main 396c0ff. The ones not in the table merge cleanly with this head and touch none of its six files: #583, #614, #617, #618, #619, #621, #622, #625, #626 and #628.

| Against | Result |
|---|---|
| origin/main 396c0ff (#591, #572, #624, #598 3fb21d9, #570) | **clean**. The merge gives this head's own tree, 6d8ffea: the head holds all of main. |
| #590 1c8a708 (`l3-563-pypi-package`) | **conflicts** in `.github/workflows/tests.yml`, 3 regions: the header and the end of the jobs. #590 against main alone is clean, so this is the two PRs' own overlap. It also touches `tests/test_install_sh.py` and `tests/test_openreposhape_command.py`, which merge as text. See the #590 section above for the resolution, the four test cases and fix round 7's 270 passed. |
| #599 b0182e2 (lane 3, stacked on #590's old head d090f0c) | **conflicts** in `.github/workflows/tests.yml`, 2 regions. #599 also conflicts with main alone, in six files (`tests.yml`, README.md, the handbook, `install.sh`, `openRepoShape`, `tests/test_install_sh.py`). It is #597's conflict, through #599's stale base, not this job's. |
| #627 fc0656b (`fix/606-readme-followups`, #606; new since fix round 6) | **conflicts** in docs/handbook.html only: 7 regions, each one § Starting marker line (this PR's badbb6b2 against #627's 51702a2b). Its README hunks (the second-run refusal in § Starting, and `CONTRACTS_DIR` and the `single-repository.yaml` question in § Adopting) merge as text with this PR's. #627 against main alone is clean. **Whoever lands second** sets the seven markers to the merged README's hash. On this head plus fc0656b that is `fe0d932b031e4a08fc885f93d6cd978df0d5a25c`. README is then 1456 lines: 1449 plus #627's own `readme-cap.d/606.txt` (+7). |
| #594 be808a7, #623 1e30fa7 | **clean**. Both touch `tests/conftest.py`, which merges as text. |
| #620 4c049a8 | **conflicts** in `adopt-project.py`, and so does #620 against main alone: its own conflict with main, not an overlap with this PR. |

#598 landed as 3fb21d9 and is in the main row; fix round 7 is that merge. Every lane-5 PR and every other PR that has landed is in the main row.

- **`tests/test_setup_sh.py`, and `test_repo_hygiene.py`'s `bash -n` test, still skip on Windows,** and since #597 nothing runs them on Windows at all. #585 names the launcher's and the installer's suites, and Brett Heap's word keeps a job for those two only. The same `BASH` would serve both.
- **Any typed `yes`.** CI has no terminal.
- **`tests/test_install_sh_filter_repo.py` in `tests-git-bash` (fix round 7).** #598's file is not added to the job; that is the coordinator's call. See checklist candidate 8.
- **`docs/release-notes/v1.0.0.md` and `docs/release-notes/v1.0.1.md`** are records of those releases, and #597 left them unedited too. v1.0.1's notes say #585 runs the suites under Git Bash; that stays true. The v1.1.0 text is below.
- **A required check.** This PR does not make `tests-git-bash` a required status check. That is a ruleset setting, and Brett Heap's call.

No v1.1.0 notes file exists on main yet, so this PR writes none. Proposed text, a section of its own:

> **Git Bash on Windows (#585).** Git Bash, the bash that Git for Windows installs, runs the one-line install and the `openRepoShape` command. CI's `tests-git-bash` job runs their suites under Git for Windows' own bash, with the PATH Git Bash makes, and fails if it cannot find that bash. That job is an exception to #597's Linux-only CI, made on Brett Heap's word. In PowerShell the way in is unchanged: `py setup-project.py`. Two things differ from macOS and Linux. `ln -s` in Git Bash makes a COPY unless `MSYS=winsymlinks:nativestrict` is set; a copy has no `openRepoShape.ref` beside it, so it follows main, and `openRepoShape --version` says so. Link it with that setting (Windows lets a non-administrator make a symlink only in Developer Mode), or put `~/.local/bin` on `PATH` instead. And `chmod` changes nothing on Git Bash's drives; a file that starts with `#!` runs as a command anyway. Not tested there: `setup.sh` run on its own (its suite still skips on Windows), and any typed `yes`, for which CI has no terminal. In Git Bash the preflight that the one-line install ends with cannot take a `yes` at all, so it installs nothing for you.

1. **The machine report's `python3` line on Windows.** `--install`'s `machine:` block probes only `python3`. Where Python came from python.org, Git Bash has `python` and no `python3`, or only the Microsoft Store stub. The block then says `python3 MISSING`, or "present" for a stub, while `--preflight` passes with `python`.
2. **A drive-letter `$OPENREPOSHAPE_REPO`.** `shape_clone_url` treats `C:/src/openRepoShape` as an owner/name and builds `https://github.com/C:/src/openRepoShape.git` from it. Git Bash and git itself both accept the drive-letter spelling.
3. **A Windows-spelled `$OPENREPOSHAPE_BIN_DIR`.** `--install` then prints `export PATH="D:\...:$PATH"`, which bash cannot use: the drive's colon splits it.
4. **A stale docstring.** `test_a_ref_the_doctor_would_check_out_is_validated` says the refusal arrives before any clone. `shape_checkout` clones first, then checks the ref.
5. **On Windows, the preflight a piped install ends with cannot take a typed `yes`.**
   - Under `curl … | bash`, install.sh:342 (:277 before #598) hands its children /dev/null for stdin.
   - setup-project.py's `open_terminal()` returns None when `os.name == "nt"`, so it has no Windows path to the person's terminal. Opening `CONIN$`, where POSIX opens `/dev/tty`, might be the analogue.
   - Found by rv585; it is the root of this PR's README wording.
6. **The handbook's step 3 has no Git Bash path.** Its install block and paragraph are `data-platform="linux mac wsl"` (docs/handbook.html:775-784 on 01e8c2c), so a reader on the Windows tab never sees the one-line install that the Windows panel says Git Bash runs. Adding it to the Windows tab is a docs decision.
7. **In Git Bash, the host-quoted lines are still quoted for PowerShell.** `openRepoShape --doctor`, a family `setup-project.py` run and `family.py`, started from Git Bash, run under native Windows Python, where `os.name` is `nt`. So `quote_arg`, `shell_quoted`, `shell_line` and `shell_word` (for example setup-project.py:2331, `if os.name == "nt" and not posix`) quote for PowerShell, even though the person is at a bash prompt. #588's sentence stays true ("on Windows"). But someone who follows "Git Bash runs that command" and pastes such a line back into Git Bash gets the failure that `tests/test_readme_windows_quoting_sentence.py`'s docstring names: `git -C C:\work\Orbit` reads as `C:workOrbit`. Choosing `sh`'s dialect under Git Bash (for example on `MSYSTEM`) would be a #532 decision.
8. **#598's installer cases do not run under Git Bash.** `tests-git-bash` runs `tests/test_install_sh.py`, and since fix round 7 that suite reaches #598's code in two ways: every run now prints `adoption:` and `done:`, and `no_pipx` expects uv's printed command. But the uv install, the readinesses' wording and the pasted next steps are tested in `tests/test_install_sh_filter_repo.py` only, on Linux. README says Git Bash runs step 3's one line, "tested in CI".
   - **What the file uses.** It inherits `test_install_sh.pytestmark` (`BASH_SKIP` and `git_bash_session`), and drives every run through `Net`: `fake` (LF bytes through `write_script`), `run` (`BASH`, `bash_path(INSTALLER)`), `serve`, `installed` and `calls`. Its own `uv` fake writes LF bytes, like `Net.fake`, and names the interpreter as `shlex.quote(sys.executable)`, as `Net.fake` does in the job today. No assert compares a path bash printed with a Python path; each checks a line's start or end.
   - **Under Git Bash it would likely pass.** Its programs see `PATH=net.path` alone, so the runner's own pipx or uv cannot leak in. The launcher's `--install` probes `python3` with `command -v` and never runs it, so the logging `python3`/`python` fakes stay silent. Its `paste()` sends a script through a text-mode pipe, as `Net.run(piped=True)` already does in the job.
   - **Not run on Windows.** On Linux: 18 passed in 7.32s. Adding it to the job is one more path on the job's pytest line.
9. **README still names `pip install git-filter-repo` for adopting.** README:768 (§ Adopting) says "`git filter-repo` is a **hard dependency** — `pip install git-filter-repo`, or the same name from apt or brew." #595 found that `pip install` is refused on an externally managed Python (Debian 12, Ubuntu 23.04, Arch; PEP 668), and #598's install.sh no longer prints it. Neither #598 nor this PR touches that line.

Review: rv585 found 0 blockers, 2 should-fix and 8 nits on 649b089 (CHANGES NEEDED), fixed or answered in 01e8c2c; the re-review rv585b found 5 nits on 01e8c2c (APPROVE WITH NITS), fixed or answered in 3095a9c; rv585c found 4 nits on 5f001de (APPROVE WITH NITS), fixed or answered in fe58d66. CI (Brett Heap's ruling 27, Linux-only since #597): H0 is the head 52524fe, a hand-resolved merge of main 396c0ff, green on tests (Linux), tests-git-bash and SonarCloud; main since then (#625 8f453d0, #583 de428f5) touched none of this PR's files, and the local gate passed on the predicted squash over de428f5: the launcher's, the installer's and install.sh's filter-repo suites, 131 passed; and the hygiene, README-section, CLI-reference, setup-by-hand and Windows-quoting sets, 171 passed. The tests-git-bash job is on Brett Heap's word of 2026-10-15T18:20:35Z, "Option 1: keep a small Git Bash job for #596". Landed on Brett Heap's word of 2026-10-15T22:24:36Z, "Merge #596 when its turn comes".

Closes #585

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-15T23:58:42Z) Readiness round on this PR, re-verified CLEAN at 227bc3b (rv622r). The round carries no merge word.

  • c3d8a97 is git's own clean merge of main 396c0ff. This PR's earlier diff is unchanged by it.
  • L2-60 (Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459). An EXISTING EMPTY target or holder folder that this user cannot write in is now refused by name, in the dry run and the real run, in both the scaffold and family.py init, before any remote exists. The refusal is scaffold-target-exists or family-target-exists, "is a folder this user cannot write in".
    • The remediation: give this user write and search permission on that folder, or choose another flag. The flag named is --work-dir, --into or --local-remote-dir, whichever placed the folder.
    • Every other case keeps its old sentence exactly.
  • L2-67 (Tracking: the test suite, CI and the docs #461). The strerror tests build their expected text from the exception, so they pass with Windows wording.
  • A pre-existing traceback is fixed. With --into named for the family and the holder at mode 600, init crashed at _landing's is_file(). It now refuses by name.
  • Probes:
    • 58 cases, plus a sweep of 128 cases (8 modes over 16 landing and remote positions, each in both modes). The sweep found no traceback and no difference between the dry run and the real run, and no refused real run made anything.
    • rv609's own 22-case probe now differs from 7f7e1b4 only where L2-60 refuses.
  • Tests and mutants:
    • test_dry_run_parity (86), test_dry_run_asks_remotes, test_family_dry_run and test_family_init_landing give 184 passed.
    • The writer's wider sets give 905 + 976 passed.
    • On a predicted merge with main bf7b19b, 490 passed.
    • 31 + 8 writer mutants and 19 reviewer mutants were all killed.

227bc3b needs Linux green as a new H0 (SonarCloud has passed). It is waiting for a merge word from Brett Heap.

brettheap added a commit that referenced this pull request Oct 16, 2026
…h, the `switch -c` remedy says to give it another name, or go back only if it holds work not merged yet, and the docs say `family/add-<Project>` (#607) (#637)

### Fix round 1 (rv607)

Review rv607 of 6212c31 said CHANGES NEEDED: 0 blockers, 2 should, 7 nits. This round is one new commit, 66b1043, on top of 6212c31. No amend, and main is not merged in. Each point:

- **S1, the sentence.** It sent a person back to the old branch first. That is right only where the branch holds work not merged yet. `BRANCH_LEFT` now puts another name first and going back second (the text is under "L2-46" below). It is word for word the same in `scripts/family.py`, in the pinned `validate-family.py` and in the pinned test constant, and the comments that described the old order are rewritten. Two new tests type the printed line in the states the review probed: `main` has moved since the earlier act, and a commit was made on a detached HEAD.
- **S2, a guard for every `_switch_line` remedy.** A new test reads the validator's source with `ast` and fails any remedy that names `_switch_line` whose prose does not end with `BRANCH_LEFT`. On a merge of this head with #634 it fails at `_lockstep`, which is the point: whichever of #637 and #634 lands second adds the sentence there. #634's lines are not on this branch and are not touched.
- **N1.** Under "Left out".
- **N2.** The commit body of 6212c31 said neither tool can print the one line that works by asking first. That holds for the validator only. `family.py` could ask; it says the validator's words so the two tools keep one rule. Decision 1 says so, and the new commit's body corrects the old one.
- **N3.** The merge table is refreshed: current origin/main, #620, #622, #626, #630 to #636, #638, #639 (lane 5's #611) and #634 (S2).
- **N4.** Decision 1 no longer says the typed line works "in every shell". It says what a person types is "the printed line with one word changed (the branch name, or `-c` taken out)", so it keeps the printed quoting, and that the tests type both changes as printed.
- **N5.** Decision 3 now says the case of the branch name "does not matter to `add`", and adds the note about case-insensitive disks.
- **N6.** The `init` NEXT STEPS test read the lines with a regex whose `--family-root \S+` breaks on a root with a space in it (`C:\Users\Jane Doe`, or any `tmp_path` with a space). It now reads the words with `host_words`, as the NEXT STEPS test beside it does. Run with a space in the base temp directory, the old test fails with `AttributeError: 'NoneType' object has no attribute 'group'` and the new one passes.
- **N7.** "Three existing tests take the new text" was wrong. It is two test functions plus `GITLINK_REMEDY`, a constant four cases share. Fixed under "Tests". The one new test that passes on BASE is disclosed there too.

### Fix round 2 (rv607b)

The re-verify rv607b of 66b1043 said APPROVE WITH NITS: 0 blockers, 0 should, 5 nits. This round is one new commit, a277695, on top of 66b1043. No amend, and main is not merged in. It changes comments and docstrings only, so no behaviour moves and no mutation row changes.

- **Nit 1.** Two comments called another name "right in every state". It is not: where the old branch holds work not merged yet, another name leaves that work behind. The comment above `BRANCH_LEFT` in `scripts/family.py` and the one in the pinned `validate-family.py` now say a new branch starts where the holder stands now, "which works in every state". The three `BRANCH_LEFT` texts are unchanged and still equal.
- **Nit 3.** The guard's docstring said it finds each `_switch_line` call "however it is passed on". A switch line moved into a variable would defeat that. It now says "wherever the call stands inside the string expression that is passed on".
- **Nit 4.** The N4 line above now quotes decision 1 as written.
- **Nit 5, the docstring half.** The docstring of `test_another_name_works_where_main_has_moved_since` said going back to the old branch conflicts, but the test does not assert that. It now says the test shows the other name works, and that the conflict on the old branch is shown by rv607's probe and decision 2's table. No assertion is added.
- **Nit 2, left as written.** The guard's control counts stay: each case fails loudly with a clear message.

Checks on a277695: the diff to 66b1043 adds ten lines and removes seven, in three files, all comments and docstrings, and the `ast` of each file is equal once docstrings are blanked. The four files `test_family_switch_branch_exists.py`, `test_validate_family_branch_step.py`, `test_validate_family_unnamed_mounts.py` and `test_family_tracking_branch.py` give `96 passed in 51.34s`, and `test_repo_hygiene.py` gives `65 passed in 1.39s`.

### L2-46, the fix

`family.py add`, `bump` and `remove` refuse the holder's tracking branch with `git -C <holder> switch -c family/<act>-<member>` (#487). The holder's `validate-family.py` names the same line before each `add` and `remove` it prints (#539, landed as 54041cc). If an earlier act left that branch, the line fails with `fatal: a branch named 'family/add-atlas' already exists` (exit 128), and neither tool said what to do next.

Both tools now add one sentence, in the same words (`BRANCH_LEFT` in each file):

> If `git switch -c` says that branch already exists, an earlier act left it: give the branch another name, which starts it where the holder stands, or, only if that branch holds work not merged yet, the same command with `switch` in place of `switch -c` goes back to it and keeps every commit on it.

- `scripts/family.py`: the sentence comes on its own line, right under the `switch -c` line, in the `family-on-tracking-branch` and `family-holder-detached` refusals (`_checked_branch`), and in the dry run's `branch` lines (`_branch_said`).
- `templates/family-root/scripts/validate-family.py`: the sentence ends the remedy text of `member-gitlink-absent`, `member-duplicate-id` and `member-gitlink-unnamed`, the three remedies that print `_switch_line`. The command lines under them are unchanged.

### L2-45, rides along

AGENTS.md, the holder's README, its AGENTS-shape.md and `init`'s NEXT STEPS spelled the branch `family/add-<project>`, right beside `--member <org>/<Project>`. All four now say `family/add-<Project>`, the one placeholder the validator already uses for both (`PROJECT_PLACEHOLDER`, #539).

**1. What the remedy prints when the branch exists: one sentence that names the way out, not a second command line.**

- *One line that works in both states* (`git switch X 2>/dev/null || git switch -c X`): rejected. On Windows, `family.py` quotes its lines for PowerShell (`shell_line` uses `os.name`; README.md:155-158 says so). Windows PowerShell 5.1 has no `||`; `&&` and `||` arrived in PowerShell 7, and `family.py`'s own `_print_next_steps` docstring says 5.1 rejects `&&`. PowerShell also has no `/dev/null`. And `2>/dev/null` would hide the real reason a `switch` fails, such as a changed file it would overwrite.
- *The tool checks and prints the one line that works*: rejected for the validator. Its report is read in CI: the holder's `validate.yml` runs `python3 scripts/validate-family.py` (and `--no-members`) in a runner's checkout, which holds none of the person's branches. `family.py` could check, but then the two tools would follow two rules, and one rule in the same words was the aim. So `family.py` says the validator's words; it is not that it cannot ask.
- *Two printed lines, one per state*: rejected. In the validator, each indented line is a step to run in order, and going back to the branch replaces step one; it is not another step. In `family.py`, a new `git switch` line would also be quoted for PowerShell on Windows. README.md:155-158 lists exactly which `family.py` lines are quoted that way (`NEXT`, `switch -c`, `add`, `git rm -r --cached`). That sentence would become false, and fixing it would mean editing README § "Starting a project in a new organisation". Seven handbook parts record that section's hash (`grep -c` gives 7).
- *Chosen*: a sentence that names both ways out. It prints no new command, so nothing new needs quoting, and the text is the same in both tools. What a person types is the printed line with one word changed (the branch name, or `-c` taken out), so it keeps the printed quoting. The tests type both changes as printed (tests below); none of them runs a shell, and Windows and macOS are not checked (see "Left out").

**2. Another name first, going back second (S1).** The reviewer's probes, and mine, in real local holders (a holder `Orbit`, members `Atlas` and `Beacon`, local bare remotes, the `gh` stub first on PATH):

| State of the old branch `family/add-atlas` | `git switch family/add-atlas` (going back) | `git switch -c family/add-atlas-2` (another name) |
|---|---|---|
| No commit on it, `main` has not moved (rv607) | works | works |
| Holds a commit of its own, not merged yet (mine: `probe-atlas.sh`, `probe-b.sh`) | works; `add` then commits on top of it, and the earlier commit is the parent | works, but the earlier commit stays only on the old branch (`git branch --contains` names `family/add-atlas` alone) |
| `main` has moved: a later member (`Beacon`) was squash-merged (mine: `probe-d.sh`; test `test_another_name_works_where_main_has_moved_since`) | `add` exits 0, but `git merge-tree --write-tree main family/add-atlas` exits 1 (`CONFLICT (add/add)` in `.gitmodules`, `CONFLICT (content)` in `family.yaml`), and an untracked `members/Beacon` is left (`warning: unable to rmdir 'members/Beacon'`) | `add` exits 0, `git merge-tree --write-tree main family/add-atlas-2` exits 0, and the validator says `2 member(s)` |
| Merged, then the member removed and that merged too; the old branch is still local (rv607's probe, and mine: `probe-c.sh`) | `add` says `member-already-present`, whose remedy points at `bump` and `remove`: a dead end | `add` exits 0, once the object store `remove` leaves behind is deleted, as its NOTE says (either choice needs that) |
| Detached HEAD with a commit made on it (mine: `probe-detached.sh`; test `test_another_name_keeps_a_commit_made_on_a_detached_head`) | `Warning: you are leaving 1 commit behind, not connected to any of your branches`, and `git branch --contains` names no branch | the commit stays: `git branch --contains` names the new branch |

So going back is right only where the branch holds work not merged yet, and another name is right in every state but that one. The sentence says so, in that order.

**3. The person's earlier work on that branch is never discarded.** `git switch <branch>` keeps the branch's commits. It refuses to switch when that would overwrite a changed file. The act then commits on top of what the branch holds; the tests check that the earlier commit is the parent of the act's commit. "Give the branch another name" leaves the old branch alone. No printed line names `switch -C` (which resets a branch), `branch -D`, `reset` or `--discard-changes`; a test checks that too.

**4. The placeholder: `<Project>` everywhere.**

- The validator already said `family/add-<Project>` beside `--member <org>/<Project>` (#539's fix round, pinned by `tests/test_validate_family_unnamed_mounts.py`). It is the only spelling that is one placeholder for both values.
- The other option, a sentence saying the branch word is the name in lowercase, does not fit: AGENTS.md is at its cap, 492 of 492 lines (`tests/test_repo_hygiene.py:633`). This change keeps it at 492.
- The case of the branch name does not matter to `add`. `_checked_branch` takes any branch but the tracking one, so `family/add-IRQS`, the doc line filled in as written, is a branch `add` commits on (test). Where the tools know the name, they still print it lowercased (`_suggested_branch`). On a case-insensitive disk (macOS, Windows) `family/add-Atlas` and `family/add-atlas` can be one loose ref there. That is untested: CI is Linux only.

**5. Pinned copies.** This changes two pinned holder files: `templates/family-root/scripts/validate-family.py` and `templates/family-root/AGENTS-shape.md` (`scripts/shape_materialize.py` lists the pinned set). Each holder takes them as two `upstream-changed` rows on its next `update-shape.py` run. The holder's README is rendered once and is not pinned. No test pins those files' bytes; the tests that compare them read the template at run time. Fix round 1 changes only `validate-family.py` of the two.

New file `tests/test_family_switch_branch_exists.py`, 25 tests (21 before the fix round). Four are new in the fix round:

- `test_another_name_works_where_main_has_moved_since` (S1, state (d)): `family/add-irqs` is left at the `main` of its day; a later member (`IRXS`, scaffolded in the test) is added on a branch and squash-merged onto `main`, so `main` has one new commit; the refusal's `switch -c`, typed as printed, fails with `already exists` (exit 128); the same line with another name works; `add` exits 0, `git merge-tree --write-tree main <branch>` exits 0, and the validator passes.
- `test_another_name_keeps_a_commit_made_on_a_detached_head` (S1): the refusal is `family-holder-detached`; after the printed line with another name, `git branch --contains <the commit>` names the new branch and no other.
- `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence` (S2, the guard) and `test_the_guard_sees_a_remedy_left_without_the_sentence` (its control: the source with the sentence taken out of any one remedy fails it, and so does a fourth remedy built by a helper as `_lockstep` builds its, until the sentence ends it).

The other 21:

- `test_the_refusal_typed_as_printed_puts_the_act_on_its_branch` [add, bump, remove] x [no-branch, branch-left]: the refusal, then its line typed as printed. With no branch, the `switch -c` makes one. With a branch an earlier act left, holding a commit of its own, the `switch -c` fails with `fatal: a branch named '<branch>' already exists` (exit 128) and changes nothing, and the same command with `switch` in place of `switch -c` goes back to that branch. The act then commits on top of the earlier commit, and NEXT pushes that branch.
- `test_a_dry_run_on_the_tracking_branch_says_it_too` [add, bump, remove].
- `test_a_missing_gitlinks_remedy_typed_as_printed`, `test_a_duplicate_ids_remedy_typed_as_printed` (with the `remove` run as printed) and `test_an_unnamed_gitlinks_remedy_typed_as_printed`, each in both states.
- `test_the_two_tools_say_the_same_words`.
- `test_each_doc_spells_the_branch_as_its_member` [AGENTS.md, AGENTS-shape.md, holder README], `test_init_next_steps_spell_the_branch_as_its_member` (read with `host_words` now, N6), and `test_the_branch_filled_in_as_written_is_one_add_commits_on`.

Two existing test functions take the new text: `test_init_next_steps_make_the_branch_before_the_add` (`<Project>`) and `test_a_missing_gitlink_remedy_makes_the_branch_and_names_the_holder` (the remedy line). `GITLINK_REMEDY` in `test_validate_family_unnamed_mounts.py`, a constant four cases share (`test_a_member_mounted_by_hand_is_two_findings` x 3 and `test_a_removed_row_is_named_by_its_gitlink_and_its_section`), ends with the sentence. The sentence is pinned once, as text, in `tests/test_validate_family_branch_step.py`.

Before and after, with the four changed test files copied in (`run9.sh`, the `gh` stub first on PATH):

- On BASE 396c0ff: `30 failed, 66 passed` in the four files. 24 of the 25 new-module tests fail. The one that passes, `test_the_branch_filled_in_as_written_is_one_add_commits_on`, is the evidence for decision 4: it shows behaviour this PR keeps.
- On 6212c31: `23 failed, 56 passed` in the three files that pin the sentence. Both new S1 tests fail there, where they assert the new wording. The two guard tests pass there: they check where the sentence stands, not its words.
- On the head 66b1043: the new module alone, `25 passed`.

### Targeted runs on the head 66b1043, BASE 396c0ff

- Set A, 29 files: every `tests/test_family*.py` and `tests/test_validate_family*.py`, plus `test_bump_leg.py`, `test_bump_leg_dry_run_tracking.py`, `test_bump_leg_dry_run_truth.py`, `test_bump_leg_paste_lines.py` and `test_update_shape_branch_any_directory.py`. Result: `1016 passed, 20 skipped in 511.01s`.
- From set B: `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_readme_sections.py` and `test_cli_reference.py`. Result: `210 passed in 15.73s`. The fix round changes no template other than `validate-family.py`'s text, so the rest of set B (the 27 files of the first round) is not re-run.
- Every skip is a platform skip that was there before this change (a case-ignoring disk, a Windows default, powershell.exe), or a template-quoting skip in `test_family_init_values.py`.

A real holder `Orbit`, made with `family.py init`, and a member `Atlas`, scaffolded with `scaffold-project.py --local-remote-dir`.

- **On BASE 396c0ff** (first round): `add` refuses with `switch -c family/add-atlas`. Typed as printed, that gives `fatal: a branch named 'family/add-atlas' already exists`, exit 128, and the remedy says nothing more. The validator's `member-gitlink-absent` remedy prints the same line, with the same result.
- **On the head, an unmerged act** (`probe-atlas.sh`, a branch holding a commit of its own): the refusal prints the new sentence. The same command with `switch` for `switch -c` gives `Switched to branch 'family/add-atlas'`, exit 0, at the earlier commit. `add` then exits 0, its NEXT pushes `family/add-atlas`, and its commit's parent is the earlier commit. The validator carries the same sentence.
- **On the head, `main` moved** (`probe-d.sh`): the printed line fails (128). With another name: `add` exits 0, `git merge-tree --write-tree main family/add-atlas-2` exits 0, `family ok: Orbit (orbit), 2 member(s)`. Going back instead: `add` exits 0, and `git merge-tree --write-tree main family/add-atlas` exits 1 with the two conflicts in decision 2.
- **On the head, a detached HEAD with a commit** (`probe-detached.sh`): the printed line fails (128). With another name, `git branch --contains` names `family/add-atlas-2`. Going back with `switch`: git warns it is leaving 1 commit behind, and no branch contains it.

No row needs real GitHub.

Each change below was made alone in a scratch clone of the head, then the new module was run (and, for the validator changes, the two other files that pin the sentence), and the file was put back. Every change that alters behaviour makes at least one new test fail:

| Change | Result |
|---|---|
| `family.py` `BRANCH_LEFT` back to the first round's words | 12 failed: the six refusal cases, the three dry-run cases, both new S1 tests, `test_the_two_tools_say_the_same_words` |
| `family.py` `BRANCH_LEFT` one clause dropped (`only if`) | 12 failed, the same tests |
| `_checked_branch` remedy with no sentence | 8 failed: the six refusal cases and both new S1 tests |
| sentence printed above the `switch -c` line, not under it | 8 failed: the same eight |
| `_branch_said` (dry run) with no sentence | 3 failed: `test_a_dry_run_on_the_tracking_branch_says_it_too` [add, bump, remove] |
| `_switch_command` prints `switch -C` | 11 failed, both new S1 tests among them: the printed line no longer fails `already exists` |
| validator `BRANCH_LEFT` back to the first round's words | 12 failed in the three files, `test_the_two_tools_say_the_same_words` among them |
| validator `member-gitlink-absent` with no sentence | 5 failed: its two typed-as-printed cases, the remedy test, the guard and its control |
| validator `member-duplicate-id` with no sentence | 4 failed: its two typed-as-printed cases, the guard and its control |
| validator `member-gitlink-unnamed` with no sentence | 8 failed: its two typed-as-printed cases, the four `GITLINK_REMEDY` cases, the guard and its control |
| **S2:** a fourth remedy added that names `_switch_line` and does not end with the sentence (built by a helper, as #634's `_lockstep` does) | 2 failed: the guard and its control, and nothing else in the three files |
| **S2:** the same fourth remedy ending with `{BRANCH_LEFT}` | 79 passed |
| `init` NEXT STEPS branch back to `<project>`; its `--member` lowercased | 1 failed each: `test_init_next_steps_spell_the_branch_as_its_member` |
| AGENTS.md, AGENTS-shape.md, holder README: branch back to `<project>` (and AGENTS.md's `--member`) | 1 failed each: `test_each_doc_spells_the_branch_as_its_member` for that doc |

The other hunks change only docstrings and comments (`_checked_branch`, `_branch_said`, `_print_next_steps`, `_switch_line`, and the comments above the two constants). They change no behaviour, so no test can catch reverting them.

| Ref | Result |
|---|---|
| origin/main f6376d7 (#614 landed; main has moved since BASE through #625, #583, #596) | clean, tree eb91b600. A committed merge of the head with it, e26dca8c, ran set A and #625's `test_family_bump_to_local_only.py`: `1024 passed, 20 skipped in 500.52s` |
| #620 a2b4f89 | clean (it conflicted in `adopt-project.py` in the first round; it no longer does) |
| #622 227bc3b (lane 2, #609, `scripts/family.py`) | clean |
| #626 207d4e1 (lane 4, `scripts/family.py`) | clean |
| #630 82f9403, #631 9733547, #632 86c6b9c, #633 f7f5de5, #635 2c0b157, #636 4aade9f | clean |
| #638 64e6621 | clean |
| #634 40a85e1 (lane 2, #610: `_lockstep` prints a fourth `switch -c`) | clean, and S2 fires on it. A committed merge, 4d1a7161: the new module, `test_validate_family_branch_step.py` and #634's `test_pinned_copy_text.py` give `2 failed, 86 passed`. The two failures are the guard, `assert [124] == []` (line 124 of the merged file is `_lockstep`'s return), and its control. With one line added to `_lockstep`, `f"moving one alone is what this refuses. {BRANCH_LEFT}"` in place of the plain string, the same merge gives `306 passed` over the nine files that read the validator or the lockstep rule (`test_validate_family*.py`, `test_family.py`, `test_family_tracking_branch.py`, `test_pinned_copy_text.py` and the new module). So the second PR to land needs that one line and nothing else. |
| #639 6f938ed (lane 5, #611) | conflicts in the same one sentence, in the same two places: the end of the `member-gitlink-unnamed` remedy text in `validate-family.py` (`_check_gitlinks_named`), and the `GITLINK_REMEDY` constant in `tests/test_validate_family_unnamed_mounts.py`. #611 adds "and commit that removal, since `family.py add` refuses ..." and this PR appends `BRANCH_LEFT`. Resolved as in the first round, keeping #611's clause and appending ` {BRANCH_LEFT}` in the validator and `+ BRANCH_LEFT` in the test constant, in a committed merge (92602308): `scripts/family.py` merged cleanly, and the six test files that either PR changes or pins gave `157 passed`. Neither branch was resolved. |

- No compound `||` line and no state check: see decision 1.
- No README.md edit: no README sentence becomes false, because no new kind of line is printed.
- The docs do not mention the exists case. AGENTS.md is at its cap, and the tools print the sentence where the failure happens.
- The `switch -c` in `init`'s NEXT STEPS gets no sentence: a holder `init` just made has no branch an earlier act could have left.
- **A branch checked out in another worktree (N1).** Where `family/add-atlas` is checked out in another worktree of the holder, `git switch family/add-atlas` gives `fatal: 'family/add-atlas' is already used by worktree at '<path>'` (exit 128), so the going-back half of the sentence does not work there. Git names the worktree, and another name works (`probe-n1.sh`: `switch -c family/add-atlas-2` exits 0). The sentence is not made longer for it.
- I could not run Windows PowerShell or macOS here. The tests read `family.py`'s lines with `host_words`, which parses PowerShell's quoting on Windows. They make the earlier branch with `commit-tree` and `update-ref`, move `main` with the same two, write no file with `write_text` and compare nothing CRLF-sensitive. `test_another_name_works_where_main_has_moved_since` runs `git merge-tree --write-tree`, which needs git 2.38 or newer. No CI job checks Windows or macOS.

- **bump-leg.py: the `git switch -c bump/<role>-<sha12>` remedy fails `already exists` when an earlier bump left that branch.** `_switch_command` (scripts/bump-leg.py:1124) prints the line for the two refusals and the dry run's closing hint. Like #607, it says nothing about a branch that is already there.
- **setup-project.py: the family `git -C <holder> switch -c family/add-<project>` line has the same gap.** The NEXT lines a scaffold into a family folder ends with (setup-project.py:2358) print it with no word on an existing branch. It matters only when an earlier attempt left that branch.

- L2-46 ([#459 comment](#459 (comment)))
- L2-45 ([#459 comment](#459 (comment)))

Review: rv607 found 0 blockers, 2 should-fix and 7 nits on 6212c31 (CHANGES NEEDED), fixed in 66b1043; the re-verify rv607b found 5 nits on 66b1043 (APPROVE WITH NITS), of which nits 1, 3 and 4 and the docstring half of nit 5 are fixed in a277695, and nit 2 is left. CI (Brett Heap's ruling 27, with his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a277695 passed tests (Linux), tests-git-bash and SonarCloud; the head 62263d2 is a clean update-branch merge of main a524542 (over e041867, a clean merge of 5b29ee1), with SonarCloud, tests-git-bash and package green on it, and the local gate passed on the head itself: the family and validator set, 1016 passed (20 skipped), and the hygiene, AGENTS.md-code, README-section and CLI-reference sets, 222 passed. Landed on Brett Heap's word of 2026-10-16T11:21:49Z, "Merge #637 when its turn comes".

Closes #607

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

Copy link
Copy Markdown

@brettheap
brettheap merged commit d4a7fab into main Oct 16, 2026
5 checks passed
@brettheap
brettheap deleted the fix/609-dry-run-parity branch October 16, 2026 12:12
brettheap pushed a commit that referenced this pull request Oct 16, 2026
main gained #563's own #590 (fe237ec) and #599 (32c22b0), whose trees
this branch already held through #599's 750366b (#599's final head,
eaa0cda, is 750366b plus a merge of main), and #626, #633, #642, #641,
#643, #644, #632, #636, #631, #638, #635, #640, #623, #637 and #622. No
conflict: the merged tree is 9be0d0d, the tree `git merge-tree
--write-tree` predicted. AGENTS.md, docs/cli.md and
tests/test_repo_hygiene.py merged as text; README.md and
docs/handbook.html are unchanged by the merge. README.md is 1461 lines,
its cap (1449 + 12, tests/readme-cap.d/563.txt), and AGENTS.md 494, its
cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
… tab shows the one-line install for Git Bash, held to the other copies by a test, and the notes are current to main d4a7fab with the fifteen pull requests since 4cf5fb0, the environments, the tag ruleset and the rehearsal (#563, #461)

The handbook (#461's line "The handbook's step 3 has no Git Bash path",
which lane 3 told lane 5 on #473 that #630 settles): step 3's install
block was `data-platform="linux mac wsl"`, so a reader on the Windows tab
never saw the one line the Windows panel says Git Bash runs. The Windows
tab now has a lead, "In Git Bash, the bash that Git for Windows installs,
the one-line install works as on Linux:", the Linux tab's block copied
byte for byte as `data-lang="sh"`, and the panel's caveat: the preflight
it ends with cannot take a typed `yes` in Git Bash. README.md is
unchanged: no test asked for it, and it is at its cap.

tests/test_repo_hygiene.py: the test above it finds one copy of the one
line per file; `test_every_handbook_copy_of_the_one_line_install_is_the_same`
holds every handbook copy (the masthead's and step 3's two) to
ONE_LINE_INSTALL, so a copy edited on one tab alone fails. A drifted
Windows copy fails it; the page as committed passes.

The release notes:
- "What changed since v1.0.1" gains one entry per pull request that
  landed after 4cf5fb0 and is not #563's own, from `git log
  --first-parent 4cf5fb0..origin/main` at d4a7fab: under update-shape.py
  and bump-leg.py, #644, #633 and #631; under scaffold, family and the
  materializer, #622, #643, #640, #641, #623, #638, #635, #626, #637 and
  #642; under shape-doctor.py, #636 and #632. Each from its squash
  commit and the issues GitHub says it closes.
- The list's placeholder says it is current to main d4a7fab and stays a
  placeholder for what lands after it; the counts are refreshed inside
  their placeholder (66 pull requests, 75 of 97 milestone issues closed).
- #590 and #599 are cited beside #563, and "Before tagging" says they
  landed (fe237ec, 32c22b0) and lists the 22 open milestone issues.
- "How this release is cut": Brett Heap's word at 2026-10-16T12:06:40Z,
  the environments testpypi (12:07:31Z) and pypi (12:07:39Z), the tag
  ruleset 25237399 (12:08:14Z), and the rehearsal, run 38898735926
  (12:10:53Z-12:12:00Z, success, both uploads skipped), each read back
  from the API. The trusted publishers' date stays a placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
…tmodules` gives is `member-gitmodules-absent`, `remove` refuses a holder with no `.gitmodules` before `deinit`, and two gitlink remedies name what they missed (#611) (#639)

A holder whose HEAD lost `.gitmodules` (the commit #407 describes) passed its own gate, and `family.py remove` in it emptied a member and then failed. This PR makes the gate find that state, makes `remove` refuse it before it writes anything, and fixes two of the gate's gitlink remedies.

**Pinned copy.** `templates/family-root/scripts/validate-family.py` is pinned in every holder (`scripts/shape_materialize.py` lists it). Each holder will see it as `upstream-changed` on its next `update-shape.py check`. No other pinned file changes. No test pins this file's bytes or digest; the tests that copy it into a holder take it from the template, and they all pass (below).

### Fix round 2: main after #637

Brett Heap's word (2026-10-16T11:21:49Z) was "Accept; merge #639 after #637". #637 (#607) landed as 95780dd, so this branch now holds one merge commit, 08b6245, of origin/main at 95780dd. It changes no line of #611's own work beyond one sentence, in two places:

- **The conflict.** #637 appended `BRANCH_LEFT` to the end of `member-gitlink-unnamed`'s hand-mount remedy, the same sentence 574/3 changes. The merge keeps this PR's "and commit that removal, since ..." clause and ends the prose with #607's `BRANCH_LEFT`. It is resolved in `templates/family-root/scripts/validate-family.py` (the last `report.finding` of `_check_gitlinks_named`) and in `tests/test_validate_family_unnamed_mounts.py` (`GITLINK_REMEDY`, now ending `+ BRANCH_LEFT`). No other file conflicted.
- **#637's guard** `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence` passes on the merge. The three remedies that name `_switch_line` (`member-gitlink-absent`, `member-duplicate-id` and `member-gitlink-unnamed`) all end with `BRANCH_LEFT`, and no other remedy this PR adds names it, so none was appended beyond the one resolved above. `git diff 6aace42 08b6245` on `validate-family.py` is #637's own change and nothing else.
- **#634 (#610) is not in main yet.** `_check_member_gitlink` is untouched by this merge. Its conflict, and the `BRANCH_LEFT` its `_lockstep` remedy needs, are in the merge table and in N-5 below.
- **Checks.** `git merge-tree --write-tree 08b6245 origin/main` gives 08b6245's own tree, 6a475a4, and `git diff --stat origin/main 08b6245` names only this PR's five files. The tests are in "Results" below.

### Fix round 1 (rv611b)

The review of 6f938ed gave 0 blockers, 3 should and 6 nits. Commit 6aace42 answers each one:

- **S1.** `_checked_gitmodules_held` now takes the member's gitlink at any stage, not only stage 0. With HEAD's `.gitmodules` gone and `members/IRRS` held only at stages 1, 2 and 3, the dry run exited 0 and the real run emptied the mount. Both now refuse `member-gitmodules-absent` before `deinit`, with the holder byte for byte as it was. Typed as printed, the remedy then lets `remove` commit. The docstring and the 571(b) text below use the review's wording.
- **S2.** One new test for each surviving mutant, each shown to kill it (table below): M1 (a checked-out member still gets its tree check), M2 (HEAD's unreadable file is refused in every mode), M4 (the `<url>` placeholder) and M5 (`remove`'s remedy names only members whose gitlink is in the index).
- **S3.** Body only. The 574/2 section now says, in the review's words, how the remedy departs from the letter of Brett Heap's "Refuse on every disk" ruling. The commands are unchanged. It needs Brett's word on that departure.
- **N1.** The variant move prints `git rm -r --cached --ignore-unmatch -- <variants>`. Where the variant's removal is staged and not committed, the gate still names it from HEAD, and the plain line failed (`pathspec ... did not match`). Tested on both disks.
- **N2.** The variant remedy now says what is left on disk. In a fresh clone, `git submodule update --init --recursive` then checks the member out at the row's path. On a case-sensitive disk the old `Members/<P>` directory stays, and `family.py add` refuses it as `family-members-dir-variant`, so the remedy gives an `mv` of it out of the holder, typed as printed. Tested with both directories on disk: every printed line typed, the gate passes, and another member is added.
- **N3.** The validator's docstring and this body say what a staged, uncommitted `.gitmodules` does: nothing, until it is committed (571(c) below).
- **N4.** The module docstring names `member-gitmodules-absent` and gives EXIT CODES' one exception.
- **N5.** M8 (one finding per path for a duplicate row) has a test. M3 and M7 are still untested; "Not changed, and why" gives the reason.
- **N6.** The three inexact claims now read as the review gives them, and the merge table is refreshed against the current heads, #634 included.

The other sections below describe the PR as it now stands. They are line by line against #459's comment 6192725913.

### Re-verify (rv611c, on 6aace42): APPROVE WITH NITS

The re-verify graded it 0 blockers, 0 should and 5 nits. S1, S2, N1, N3, N4 and N6 are fixed, and N2 and N5 are answered. S3's paragraph is in the body word for word; the departure itself waits for Brett Heap's word.
- **S1, confirmed.** A gitlink held at stages 1-3 only is refused before `deinit` in both runs; the holder and the mount's 15 entries are unchanged.
- **The mutants.** M1, M2, M4, M5, M8 and M12r are each killed. Three more mutants of this round's new text (the `mv` target, the macOS and Windows sentence, `--ignore-unmatch`) are killed too.
- **Typing every printed line works in layouts C1, C2, C2s, C3, C4 and C6:** the gate passes in all three modes, and `add --dry-run` of another member exits 0.
- **Left as nits,** since each case keeps every file and can be recovered:
  - **N-1.** Where the variant directory is a live submodule checkout with an absorbed gitdir, the printed `mv` leaves its `.git` file pointing nowhere. Its files and any unpushed commit stay, the commit in the holder's `.git/modules/<variant>`, and the gate also prints its own `gitmodules-path-unnamed` finding for the stale section. Reaching this needs a hand-mounted live checkout, the 574/3 layout.
  - **N-2.** The `mv`'s condition is stated by OS ("a case-sensitive disk, as Linux's usually is"; "on macOS and Windows ... leave it"), not by a check a person can run. On a case-folding Linux mount (WSL's `/mnt/c`), typing it moves the member's own checkout out; `git submodule update --init --recursive` then restores it, and the gate passes.
  - **N-3.** An empty `../<variant>` directory that already exists is nested into. A file or a non-empty directory of that name makes `mv` refuse, and nothing is lost.
  - **N-4.** M3 and M7 stay untested, as "Not changed, and why" says.
- **N-5, composition, not this PR's.** #634 and #637 together fail #637's guard test, on #634's `_lockstep` line. #637 has landed, so #634 appends `BRANCH_LEFT` there when it takes main.

### 571(b): `remove` in a holder whose HEAD lost `.gitmodules`

On BASE 396c0ff, with local bare remotes and a `gh` that exits 97:

```
remove --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 2
REFUSED a git or gh command failed. THE EXACT COMMAND WAS:
    git --literal-pathspecs add -f -- .gitmodules family.yaml
fatal: pathspec '.gitmodules' did not match any files
The unmount was undone only in part (...); NOT members/Nova checked out again ...
members/Nova holds 0 entries
```

Now `_checked_gitmodules_held` refuses this holder, before any git command that writes, and the dry run refuses it in the same words. It asks only where none of the work tree, the index and HEAD holds a `.gitmodules`, and the index records the member's gitlink at any stage: `deinit` and `git rm` run on a merged or an unmerged gitlink alike, and with no `.gitmodules` anywhere the commit cannot stage one. `_checked_gitmodules_as_staged` (#407) asks nothing there, since there is no copy to compare.

```
REFUSED member-gitmodules-absent: the holder has no .gitmodules: neither the work tree, the index nor HEAD holds one, though the index records a gitlink at members/IRRS. `git rm` edits only a .gitmodules the work tree has, and `remove` commits .gitmodules as `git rm` leaves it, so with none to commit it would fail after `git submodule deinit` and `git rm` had emptied members/IRRS. A recursive clone of the holder checks out none of its members, and its validate-family.py finds member-gitmodules-absent for each. No git command that writes was run and nothing was written.
Remediation: write back the section `family.py add` writes for each member the index records, this one included, in a POSIX shell — `git config -f .gitmodules submodule.members/IRRS.path members/IRRS` and `git config -f .gitmodules submodule.members/IRRS.url https://github.com/InkRouter/IRRS.git` and `git config -f .gitmodules submodule.members/IRSS.path members/IRSS` and `git config -f .gitmodules submodule.members/IRSS.url https://github.com/InkRouter/IRSS.git` — then stage it, `git add -f .gitmodules` (`-f`, since an operator's excludes may match it), and commit it on its own first if the command's commit should carry only its own change, and run the same command again.
```

The remedy names only the members whose gitlink the index records (tested).

### 571(c): the gate passes a row no section of HEAD's `.gitmodules` gives (#407's decision 3)

On BASE, in that holder: `validate-family.py` exit 0 `family ok`, and `--no-members` exit 0. In a fresh recursive clone of it: the clone exits 0 with every member empty, `bootstrap.py` says `every member and leg fetched` and `NOT CHECKED OUT; skipped` for each, `validate-family.py` refuses `member-uninitialized` (exit 2) and names `git submodule update --init --recursive`, which exits 0 there and checks nothing out, and `--no-members` says `family ok`.

Now the finding is `member-gitmodules-absent`, one per row, in every mode (`--no-members` and `--pins` too), exit 1:

```
FINDING member-gitmodules-absent: members/IRRS is a gitlink at <commit> that a row of members: names, and HEAD holds no .gitmodules, so a recursive clone of this holder checks out nothing there, and neither does make bootstrap in that clone, whatever checkout this holder has of it. Write its section back by hand, in a POSIX shell, as family.py add writes one, then stage and commit .gitmodules:
    git config -f .gitmodules submodule.members/IRRS.path members/IRRS
    git config -f .gitmodules submodule.members/IRRS.url https://github.com/InkRouter/IRRS.git
```

Where HEAD's file exists but gives no section the path, it says `no section of HEAD's .gitmodules gives that path`. A section of the name `family.py add` gives (`members/<P>`) that already has a url keeps it: only the `path` line is printed. Where the row's `repository:` is not `<org>/<Name>`, the url line prints `<url>` (tested).

- **It reads HEAD's file**, as a recursive clone and CI's checkout do. It lists HEAD's root tree with no pathspec, as `_member_gitlinks` reads HEAD. A file git cannot read is `gitmodules-unreadable`, in every mode, with "commit it, then run this again" (tested: HEAD's file broken, the work tree's mended). A HEAD git cannot list (no commit yet) is asked nothing.
- **A `.gitmodules` staged and not committed counts for nothing until it is committed.** HEAD's file is what counts, as #407's decision 3 says. The check reads three sources: the gitlinks from the index first, the rows from the work tree's `family.yaml`, and the sections from HEAD. So:
  - a `family.py add` staged and not committed (an `add`, then `git reset --soft HEAD~1`) is a finding for its own member;
  - a section taken out and staged, not committed, passes;
  - a `.gitmodules` put back and staged, not committed, in a holder whose HEAD lost it, is a finding for every member until it is committed.
- **Which rows.** Each row at `members/<project>` whose gitlink this repository records (`gitlinks`, read as `recorded_gitlink` reads one). A row with another `path:` is `member-path`'s, and a row with no gitlink is `member-gitlink-absent`'s. A row written twice is one finding (tested).
- **A member it names that is not checked out is not refused `member-uninitialized`.** That refusal dropped the finding and named a command that does nothing there. It is what a fresh clone and CI met. The finding still fails the run. A member it names that IS checked out is still asked its tree: a digest edited by hand is `member-digest-mismatch` beside the finding (tested).
- **No misfire.** A holder with no members, a work tree whose `.gitmodules` was deleted and not committed, an edit nobody staged, and a sparse checkout that leaves the file out each pass (tested). A `.gitmodules` put back by hand but untracked is still a finding until it is committed: HEAD, which a clone reads, still has none (tested).
- **Bootstrap, validate and a fresh recursive clone agree on the members.** In a fresh clone of the damaged holder, the clone leaves both members empty, `bootstrap.py` says `NOT CHECKED OUT; skipped` for each, and the gate names both, members checked or not. With the sections written back as printed and pushed, a fresh clone checks both out and the gate passes in every mode (tested). `bootstrap.py`'s own fetch line still says `every member and leg fetched` there, because `git submodule update` exits 0. That is checklist candidate (a) below; this PR does not change `bootstrap.py`.

### 574/2: a HEAD gitlink at a case variant (#363's item 3)

On BASE, with HEAD's gitlink at `Members/IRRS` and the row and section at `members/IRRS`, the gate refused `member-gitlink-absent` for `members/IRRS`, never named `Members/`, and named `family.py add`, which cannot help because the row is there. Now:

```
REFUSED member-gitlink-absent: no gitlink (mode 160000) recorded at 'members/IRRS'. IRRS is named in family.yaml, and this repository records its gitlink only at a case variant of that path, which macOS and Windows hold as one directory with it: Members/IRRS (at <commit>). A recursive clone checks out nothing at members/IRRS.
Remediation: the member is recorded in the wrong case. Move its gitlink to the row's path, at the commit the row pins, in the index alone, which touches no file, with the two lines below, and commit that. Where nothing is checked out at members/IRRS, as in a fresh clone, `git submodule update --init --recursive` then checks the member out there. On a case-sensitive disk, as Linux's usually is, a directory left at Members/IRRS stays there, untracked, and family.py add refuses it as family-members-dir-variant: move each one that is there out of the holder, `mv -- Members/IRRS ../Members-IRRS`. On macOS and Windows it is the row's own directory, so leave it. The two lines:
    git rm -r --cached --ignore-unmatch -- Members/IRRS
    git update-index --add --cacheinfo 160000,<pin>,members/IRRS
```

This keeps Brett Heap's "Refuse on every disk" ruling in naming the entries, giving one answer on every disk and ending in a commit, but not in the letter of its remedy, "a committed `git mv`": it takes every variant out of the index with `git rm -r --cached`, as #574 does where nothing is on disk in that spelling, and does so even where a checkout is on disk there, where #574 prints `git mv`, because `git mv` to the row's path fails or misplaces the gitlink (below). It needs Brett's word on that departure.

What `git mv -- Members/Nova members/Nova` did in a probe on Linux (git 2.43, a gitlink at `Members/Nova` in the index):
- the checkout at `members/Nova` and nothing at `Members/Nova` (the holder it happened in): `fatal: bad source, source=Members/Nova, destination=members/Nova/Nova`, exit 128;
- both directories there: exit 0, and the gitlink lands at `members/Nova/Nova`;
- only the empty `Members/Nova` a fresh clone leaves, where no other member makes a `members/` directory: `fatal: renaming 'Members/Nova' failed: No such file or directory`, exit 128 (where one does, the same `git mv` exits 0).

The index-only pair reads no disk, so it works in all three and on a folding disk. Several variants are taken out on one line, and the gitlink goes back once, at the pinned commit. `--ignore-unmatch` lets the first line run where the variant's removal is already staged. `member-gitlink-unnamed` still prints no command for the same gitlink under `--no-members`, since taking it out alone would un-register the member. The pair here takes it out and records it again together, so the two do not contradict.

**What is left on disk.** In a fresh clone the gate first refuses `member-uninitialized` once the move is committed; the `git submodule update --init --recursive` the remedy names cures that. On a case-sensitive disk the variant's directory stays: empty in a fresh clone, a full checkout where a holder had both. The gate passes, but `family.py add` of any other member then refused `family-members-dir-variant ... on disk and untracked: Members/<P>`. The `mv` moves it out of the holder, to `../<variant with / as ->`, which no spelling of `members/` meets. On macOS and Windows the variant's directory is the row's own, so the remedy says to leave it. A probe typed every printed line in three layouts on Linux: a fresh recursive clone (the `mv`, then `git submodule update`, then the gate passes and `add --dry-run` of a third member exits 0); both directories on disk (the gate passes and `add --dry-run` exits 0, where before the `mv` it refused); and the half-repaired holder (the `--ignore-unmatch` line exits 0, nothing to move, the gate passes). The same probe on 6f938ed: in the first two layouts `add --dry-run` of the third member still refused `family-members-dir-variant` after the move, and in the third the printed `git rm -r --cached -- Members/Atlas` exited 128 (`pathspec 'Members/Atlas' did not match any files`).

### 574/3: commit the `git rm` before the `add`

The hand-mount remedy now reads "take it out with the second, which refuses while its checkout holds changes and takes its .gitmodules section out too, and commit that removal, since family.py add refuses a path under another spelling of members/ that HEAD holds and the index no longer does, then mount it with the third", and, since #637, ends with its `BRANCH_LEFT` sentence. On BASE, typing the three lines for a hand mount at `Members/IRQS` gave `REFUSED member-path-taken: ... in HEAD and no longer in the index: Members/IRQS (a submodule)` at the `add`. With the commit between, `add` mounts it.

1. **`remove` refuses; it does not commit without `.gitmodules`.** The issue's approved recommendation (Brett Heap, 2026-10-15T16:49:38Z) is to refuse before `deinit`. The state is damage: the gate now finds every remaining member of such a holder. A `remove` that committed past it would leave a holder that still fails its gate, so the repair comes first, as #407 refuses a `.gitmodules` the work tree lacks. The refusal is narrow. Where HEAD's file lacks only the member's own section, `remove` runs as before and the gate passes after it (tested). Where the member has no gitlink, the run fails at its first git call as before (tested). Where its gitlink is unmerged, it is refused like a merged one (tested).
2. **The repair a person can type** is the section `family.py add` writes, by `git config -f .gitmodules`, with the url `family.py add` gives a member (`https://github.com/<repository>.git`). It is not restored from history, because that would bring back the sections of members removed since. It is not taken from the holder's `.git/config` registration, which is local and, in a `--local-remote-dir` holder, a local path (#540 keeps local paths out of committed files). Each test types it as printed and reaches the fixture's local remotes through a `url.<base>.insteadOf` for the one command.
3. **The code `member-gitmodules-absent`** is the gate's finding and `remove`'s refusal, one fact under one name, as `gitmodules-unreadable` is shared. It is not `gitmodules-*`: `remove`'s #407 refusals use that prefix for a work-tree copy that is not the index's.
4. **Where the new code is documented.** No document names the gate's codes: AGENTS.md (at its 492-line cap), the holder's `AGENTS-shape.md`, `README.md` and the holder's README name none of `member-gitlink-absent`, `member-gitlink-unnamed` or `gitmodules-path-*`. `tests/test_agents_md_codes.py` holds only the codes AGENTS.md names. So the new check is documented where the others are, in the validator's own "WHAT IS CHECKED" docstring, which ships in every holder; it names `member-gitmodules-absent` there and in EXIT CODES. AGENTS-shape.md is not changed, since that would move a second pinned file. Adding the code to AGENTS.md would need a line under the cap.
5. **574/2's refusal is reached in members mode.** In a plain clone followed by `git submodule update --init --recursive`, as CI and `make bootstrap` run it, it is reached when the variant's row is the first row not checked out: `git submodule update` stops at the first gitlink it has no url for, and `Members/` sorts before `members/`. CI then falls back to `--no-members`, where `member-gitlink-unnamed` names the variant. After `clone --recurse-submodules` the refusal is reached whatever the row order. This is checklist candidate (b) below.
6. **The `mv` target** is `../` and the variant with each `/` as `-` (`../Members-IRRS`): out of the holder, where no spelling of `members/` meets it, with nothing to fill in, and distinct for each variant on the disks where the step applies. It moves rather than deletes, since the directory can be a checkout with work in it.

`tests/test_family_head_lost_gitmodules.py`, new, 38 tests:
- 571(b): the refusal by `remove` and its dry run alike, exit 2, with the holder byte for byte as it was (`test_family_dry_run.state`), the member's checkout included; the remedy typed as printed, with no excludes and under an operator's `*` excludes; `remove` then commits and the gate passes in every mode; the pushed result cloned recursively checks the other member out and its gate passes; a member whose section alone is gone is removed as before; a member with no gitlink is not this refusal; a member whose gitlink is held only at stages 1, 2 and 3 is refused alike, with the mount and the stages kept, and the remedy typed lets `remove` commit (S1); the remedy names only the members whose gitlink the index records (M5).
- 571(c): one finding per member where HEAD lost the file, in every mode, typed and committed; one finding where one section is gone, in every mode; a section of `add`'s name keeps its url; a fresh clone says the findings and not `member-uninitialized`, `bootstrap.py` says `NOT CHECKED OUT` for each, and after the remedy a fresh clone passes; no misfire on the three work-tree states and on a holder with no members; an untracked copy put back is still a finding until committed; rows the gitlink checks do not read are asked nothing; a checked-out member it names is still asked its tree, members checked and `--pins` (M1); HEAD's unreadable file is `gitmodules-unreadable` in every mode, and the mended file committed passes (M2); a `repository:` that is not `<org>/<Name>` prints `<url>` (M4); a row written twice is one finding per path (M8).
- 574/2: one variant and two, on a Linux disk and an emulated folding disk (`core.ignorecase=true`, index records only), typed as printed and committed, then the gate passes in every mode; a fresh clone says it too; the variant's removal staged and not committed, on both disks, typed as printed and committed (N1); on a case-sensitive disk, a second checkout at `Members/IRRS`, every printed line typed (the two lines, the commit, the `mv`), then the gate passes in every mode and `family.py add` of IRQS exits 0 (N2; skipped on a case-insensitive disk, where the two directories cannot both exist).
- 574/3: a hand mount at `Members/IRQS` (index records, no `Members/` on disk), on both disks: the uncommitted `git rm` makes `add` refuse; the commit the text names makes it mount.

Changed tests:
- `tests/test_validate_family_unnamed_mounts.py`: the pinned hand-mount remedy text; the hand mount run as printed now commits the `git rm`; in members mode the case-variant state's refusal prints the index move, now with `--ignore-unmatch` (the `--no-members` finding still prints no command); the section-path-in-another-case state now also has `member-gitmodules-absent` with its one `path` line.
- `tests/test_family_remove_gitmodules.py`: the docstring of the HEAD-lost test, which said the real run "still fails" and the case goes to #459.

Results, Linux, `/usr/bin/python3 -m pytest`, `gh` stub first on PATH:
- Fix round 2, on the merge 08b6245 (every run through `run9.sh`, committed, clean tree):
  - #637's guard and the unnamed-mounts file, on the merge as resolved and before it was committed: `tests/test_family_switch_branch_exists.py tests/test_validate_family_unnamed_mounts.py`: `65 passed in 63.49s (0:01:03)`;
  - batch A: `tests/test_family_head_lost_gitmodules.py tests/test_validate_family_unnamed_mounts.py tests/test_family_switch_branch_exists.py tests/test_validate_family_branch_step.py tests/test_family_remove_gitmodules.py`: `152 passed in 204.98s (0:03:24)` (38 in the new file and 114 in the other four);
  - batch B: `tests/test_validate_family*.py tests/test_family_members_case_variants.py tests/test_repo_hygiene.py` (the five `test_validate_family*` files are `branch_step`, `duplicates`, `member_path_glob`, `unnamed_mounts` and `without_git`): `246 passed in 165.92s (0:02:45)`;
  - no skips in either run. Batch A of round 1 (704 tests, 18 files) and batch B of round 1 (194 tests) were not re-run: 08b6245's five files differ from 6aace42's only by what main changed in them, #637's `BRANCH_LEFT` lines included.
- Fix round 1, on 6aace42:
  - the new file and `tests/test_validate_family_unnamed_mounts.py`: `78 passed in 44.40s`;
  - the same two files on 6f938ed (a throwaway clone with them copied in): `11 failed, 67 passed in 42.52s`. The S1 test fails with `work tree changed` (the mount emptied). The N1 and N2 tests fail at the refusal's text, which lacks `--ignore-unmatch` and the `mv`; the probe below shows what that text did when typed. The other seven pin the refusal text this round changes: the four case-variant moves, the fresh clone of one, and the unnamed-mounts test's two members-mode cases. The five S2 and N5 tests pass there, as they must: they pin behaviour 6f938ed already has, and the mutants below are their proof;
  - batch A (the list below): `704 passed in 368.87s (0:06:08)`, no skips;
  - from batch B, `tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py tests/test_family_members_case_variants.py`: `194 passed in 42.58s`.
- Round 0:
  - the new file on BASE 396c0ff (a throwaway clone with the file copied in): `19 failed, 7 passed in 21.36s`. The 7 that pass pin behaviour this PR keeps: the three work-tree states and the holder with no members (no misfire), the member whose section alone is gone, the member with no gitlink, and the rows the gitlink checks do not read;
  - the new file on 6f938ed: `26 passed in 33.48s`;
  - targeted batch A on c2f2884: `tests/test_family_head_lost_gitmodules.py tests/test_family_remove_gitmodules.py tests/test_validate_family_branch_step.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_members_case_variants.py tests/test_family_add_refusals.py tests/test_refusal_default_remediation.py tests/test_family_dry_run.py tests/test_family_member_path.py tests/test_family_bump_remove_rollback.py tests/test_family_tracking_branch.py tests/test_family_printed_quoting.py tests/test_family_add_excludes.py tests/test_family_add_member_id.py tests/test_family.py`: `690 passed in 585.40s (0:09:45)`;
  - targeted batch B on c2f2884, every other test file that runs the holder's validator, `family.py` or the codes: `tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_shape_doctor.py tests/test_doctor_gitlink_repairs.py tests/test_doctor_legs_members_next.py tests/test_doctor_next_is_a_command.py tests/test_doctor_validator_rows.py tests/test_family_bump_to_a_tag.py tests/test_family_bump_to_checks.py tests/test_family_init_values.py tests/test_family_makefile_siblings.py tests/test_family_member_name.py tests/test_shape_pin_files_not_a_list.py tests/test_stdout_encoding.py tests/test_template_text_placeholders.py tests/test_template_values_single_pass.py tests/test_update_shape.py tests/test_update_shape_add.py tests/test_update_shape_header_comments.py tests/test_update_shape_kept_helper.py tests/test_update_shape_pr_base.py tests/test_cli_reference.py tests/test_validate_pins_without_git.py tests/test_bump_leg_dry_run_truth.py tests/test_bump_leg_unopenable.py tests/test_bump_leg_unreadable.py`: `1746 passed, 23 skipped in 1448.78s (0:24:08)`. The skips are the Windows-only PowerShell and stdout lines and `test_family_init_values.py`'s template-quoting cases, as on main;
  - on the merge of 6f938ed with origin/main 8f453d0, committed and detached in a throwaway shared clone: batch A plus `tests/test_family_bump_to_checks.py` and `tests/test_family_bump_to_local_only.py`: `706 passed in 732.59s (0:12:12)`; batch B without `test_family_bump_to_checks.py`: `1740 passed, 23 skipped in 1383.61s (0:23:03)`.

### Fix round 1

Each mutant applied alone to a throwaway shared clone at 6aace42, with the new file run against it. M1, M2, M4, M5 and M8 are the review's own text replacements. M12r puts back the stage-0 test that S1 takes out; N1r drops `--ignore-unmatch`.

| mutant | result | new tests that fail |
|---|---|---|
| M1 the tree check skipped even for a checked-out member | 2 failed, 36 passed | `test_a_checked_out_member_it_names_still_has_its_tree_checked[members-checked]`, `[pins]` |
| M2 HEAD's `gitmodules-unreadable` swallowed | 3 failed, 35 passed | `test_a_head_gitmodules_git_cannot_read_is_refused` (all three modes) |
| M4 the `<url>` placeholder never printed | 1 failed, 37 passed | `test_a_repository_not_org_and_name_prints_the_url_placeholder` |
| M5 `remove`'s remedy not filtered to members with a gitlink in the index | 1 failed, 37 passed | `test_removes_remedy_names_only_members_whose_gitlink_is_indexed` |
| M8 no dedupe, one finding per row | 1 failed, 37 passed | `test_a_row_written_twice_is_one_finding_per_path` |
| M12r the stage-0 test put back | 1 failed, 37 passed | `test_remove_refuses_it_where_the_members_gitlink_is_unmerged` |
| N1r `--ignore-unmatch` dropped | 8 failed, 30 passed | every test that pins the variant refusal: the four moves, the half-repaired move on both disks, the case-sensitive disk's `mv`, the fresh clone |

M11 (the variant sort) was already killed at 6f938ed (2 failed in the review's run).

### Round 0

Each hunk (or one condition in it) reverted alone in a throwaway clone at 6f938ed, with the new file run against it (`mutate.py`, exact text replacements). Every one fails at least one new test:

| probe | result | new tests that fail |
|---|---|---|
| F1 `remove` does not call `_checked_gitmodules_held` | 3 failed, 23 passed | `test_remove_refuses_a_holder_whose_head_lost_gitmodules[no-excludes]`, `[star]`, `test_the_holder_remove_leaves_clones_with_the_other_member` |
| F2 the refusal asked whatever the member's gitlink | 1 failed, 25 passed | `test_remove_of_a_member_with_no_gitlink_is_not_this_refusal` |
| V1 `member-gitmodules-absent` never made | 9 failed, 17 passed | the per-member finding (3 modes), the one-section finding (3 modes), the kept url, the fresh clone, the untracked copy |
| V2 the work tree's `.gitmodules` read in place of HEAD's | 3 failed, 23 passed | no misfire `[deleted, not committed]`, `[left out by a sparse checkout]`, the untracked copy |
| V3 no skip of an unregistered member that is not checked out | 1 failed, 25 passed | `test_a_fresh_clone_of_it_says_the_finding_and_not_uninitialized` |
| V4 the url line always printed | 1 failed, 25 passed | `test_a_section_of_the_name_add_gives_keeps_its_url` |
| V5 "HEAD holds no .gitmodules" never said | 4 failed, 22 passed | the per-member finding (3 modes), the fresh clone |
| V6 a row with a hand-edited `path:` asked too | 1 failed, 25 passed | `test_a_row_the_gitlink_checks_do_not_read_is_asked_nothing` |
| V7 a row with no gitlink asked too | 1 failed, 25 passed | `test_a_row_the_gitlink_checks_do_not_read_is_asked_nothing` |
| V8 574/2's variant not named | 5 failed, 21 passed | the case-variant move (one and two variants, both disks), the fresh clone of it |
| V9 574/3's commit not said | 2 failed, 24 passed | the hand mount under `Members/` (both disks) |

The other hunks are docstrings, comments and `_sections_listed`, a split of `_gitmodules_sections` that changes no behaviour (the #300 and #361 tests in batch A pass through it), and the conditions S2 and N5 list, which no new test holds. (That was true of 6f938ed. Fix round 1 adds a test for each of them but M3 and M7; see "Not changed, and why".)

Of this head (08b6245, the merge of origin/main 95780dd), run 2026-10-16 about 12:00Z against the PR heads `git ls-remote` gave:

| Merged with | Head | Result |
|---|---|---|
| origin/main | 95780dd | clean: the merge tree is this head's own tree, 6a475a4. This branch now holds the merge of main, which `git diff --stat origin/main 08b6245` shows as these five files only. |
| #622 (lane 2, #609) | 227bc3b | clean |
| #626 (lane 4, #612) | landed (6f760b7, in main) | not applicable |
| #630 (lane 3, README, the handbook, `tests.yml`) | ca5c49f | clean |
| #621 (lane 2) | 10dda17 | clean |
| #627 | 83ca2db | clean |
| #634 (lane 2, #610) | b7472df | CONFLICT in `templates/family-root/scripts/validate-family.py`, two regions, as before: `_check_member_gitlink`'s signature and its one call in `_check_member`. This PR adds `gitlinks`, #634 adds `rule`; keep both: `gitlinks: dict[str, str], report: Report, rule: str`, called as `_check_member_gitlink(root, path, commit, project, gitlinks, report, rule)`. Lane 2's issuecomment-6206200388 says the same. #634 is not on main yet, and its `_lockstep` remedy must end with `{BRANCH_LEFT}` under #637's guard. |
| #637 (lane 5, #607) | landed as 95780dd | merged into this branch (Fix round 2): the one sentence, `GITLINK_REMEDY`, kept both. |

#607 and #611 landed one at a time: #607 first, so this PR took #607's sentence.

- `bootstrap.py` (pinned): its fetch line over members `git submodule update` did not check out. Candidate (a) below.
- `add` in a holder whose HEAD lost `.gitmodules`: it commits a new `.gitmodules` with its own section alone, which is its own change. The gate then names each other member.
- **M3 and M7 stay untested.** M3 turns the unborn-HEAD return into "no file": no holder is unborn, since `family.py init` commits, so the gate never meets one. M7 drops the blob-type guard on HEAD's `.gitmodules`: it matters only where HEAD holds a `.gitmodules` that is a directory, which only a hand-built tree can make. A test of either would build a holder no tool makes. M9 and M10 (in `_checked_gitmodules_held`) are equivalent mutants: `_checked_gitmodules_as_staged` runs first and, for `remove`, refuses every state in which either return would fire.
- macOS and Windows were not run (CI runs the full suite on Linux only since #597). The case-variant tests use index records and `core.ignorecase=true`, every file is written as bytes, git's stdin gets bytes, and nothing is compared against a file written with `write_text`. The one test that needs both `Members/IRRS` and `members/IRRS` on disk skips on a case-insensitive disk.

- **(a) `bootstrap.py` says `family bootstrap ok` over members it did not check out.** In a fresh clone of a holder whose HEAD lost `.gitmodules`, `git submodule update --init --recursive` exits 0 and checks nothing out, so step (a) says `every member and leg fetched`, step (b) says `NOT CHECKED OUT; skipped. See the fetch above.` for each, and the run ends `family bootstrap ok`, exit 0. It does the same in a plain clone with a case-variant gitlink, after printing `fatal: No url found for submodule path 'Members/Nova'`. The gate now names the cause; the fetch line should not claim the opposite.
- **(b) A clone's gate meets `member-uninitialized` before a later row's `member-gitlink-absent`.** In a plain clone of #363's state followed by `git submodule update --init --recursive` (exit 128), where the variant's row is not first, `git submodule update` dies at `Members/<P>` (`No url found for submodule path`), so the first row is not checked out and the gate refuses `member-uninitialized` for it, naming the same failing command. After `clone --recurse-submodules` this does not happen. Asking every row's gitlink before any row's checkout would name the variant first.
- **(c) `member-gitlink-unnamed` for the only record of a member in the wrong case prints no command** under `--no-members`, which is the path CI takes in (b), while members mode now prints the index move. The same pair could be printed there.
- **(d) `family.py add` of another member can un-register a member whose only gitlink is at `Members/<P>`** (against landed #574). It prints `git rm -r --cached -- Members/<P>` (nothing on disk in that spelling) or `git mv -- Members/<P> <new name>` (something on disk there). Either one un-registers that member.

Review: rv611 stalled before its verdict; rv611b found 0 blockers, 3 should-fix and 6 nits on 6f938ed (CHANGES NEEDED), fixed in 6aace42; the re-verify rv611c found 5 nits on 6aace42 (APPROVE WITH NITS), whose N-1 and N-2 go to a v1.1.0 issue filed from #459. 08b6245 merges main 95780dd by hand, appending #637's `BRANCH_LEFT` to #611's clause. CI (Brett Heap's ruling 27, with his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 08b6245 passed tests (Linux), tests-git-bash and SonarCloud; the head 159a08f is a clean update-branch merge of main 2476992, with SonarCloud, tests-git-bash and package green on it, and the local gate passed on the head itself: the family and validator set, 704 passed, and the hygiene, AGENTS.md-code, CLI-reference, case-variant and README-section sets, 282 passed. Landed on Brett Heap's word of 2026-10-16T11:21:49Z, "Accept; merge #639 after #637", which also accepts its index-only move for a case-variant gitlink in place of the committed `git mv` of his "Refuse on every disk" ruling.

Closes #611

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
…-letter `$OPENREPOSHAPE_REPO` is a path, a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` is respelled or refused, and `tests-git-bash` runs #598's installer cases (#649) (#656)

#596's Git Bash follow-ups, the five lines of #649. Two commits on main a524542: **d91b3144**, the fix, and **e85573ac**, fix round 1 from rv649's review. Together they change `openRepoShape`, `.github/workflows/tests.yml` and `tests/test_openreposhape_command.py`, and nothing else. `git diff --numstat`, d91b314: `openRepoShape` +42/-4, `tests.yml` +6/-1, the test file +182/-7. e85573a: +9/-6 (comments only), +3/-2 (a comment only), +56/-27. README, the handbook and `docs/cli.md` are unchanged: every sentence about these blocks stays true (below).

### Fix round 1 (rv649)

rv649 found the launcher's code correct and asked for changes: 0 blockers, 1 should, 6 nits. e85573a answers each point. It changes no line of the launcher's code and no command of the workflow: three comments and the tests.
- **SHOULD-1, the `D:/…` arm had no test.** Both BIN_DIR tests now run over `D:\tools\bin` and `D:/tools/bin`, and `fake_cygpath` takes the typed value. On Windows they use `str(...)` and `.as_posix()` of a path under `tmp_path`. With the arm dropped the suite stayed green (91 passed); now 5 cases fail.
- **NIT-1, the refusal's drive-answer arm had no test.** A third refusal case: a `cygpath` that answers `D:/tools/bin` with exit 0. With that arm dropped the suite stayed green; now 2 cases fail.
- **NIT-2, a `python` that is Python 2, or older than 3.9, reads as present.** Named as a cost in decision 1 and in the comment above `machine_report`. No code change: `--preflight` still refuses those machines.
- **NIT-3, the refusal's reason was false in Git Bash with `cygpath` off `PATH`.** Git Bash does read `D:\…` as a drive. The comment in `install_commands` and decision 3 now give the true reason.
- **NIT-4, the job's opening comment named two files.** It names the three.
- **NIT-5, on Windows the refusal test's value was a real `D:` drive path.** It is now a path under `tmp_path`, and the assert names that value, so a refusal that regressed would place the command where the test looks.
- **NIT-6, four claims.** Fixed in this body: "rows 1-3 are checked through the tests" (now says what runs), the clone URL for each slash (`C:\src\…` gave `https://github.com/C:\src\openRepoShape.git`), the Store stub clause (dropped, see decision 1) and the report's diffstat totals (the exact `--numstat` counts are at the top). d91b314's own commit message keeps its `....` typo and its one-URL sentence in history; the squash message is this body.
- **Composition and merge table.** Refreshed against origin/main 879d804 (below). #630 no longer conflicts.
- **Checklist candidate 3**, from rv649, is added below.

### The fix

**1. The `machine:` row for Python** (`machine_report`).
- Before: the block asked `command -v python3` and nothing else. Where Python came from python.org, Git Bash has `python` and no `python3`, so the row said `python3 MISSING` and printed the line pointing at `--preflight`, which then passed with `python`.
- Now: it asks for `python3`, then `python`, in `find_python`'s order. When only `python` answers, the row says `python3          present, as python`, and nothing points at `--preflight` for it. With neither, it says `MISSING`, as before. With `python3`, it says `present`, byte for byte as before.

**2. A drive-letter `$OPENREPOSHAPE_REPO`** (`shape_clone_url`).
- Before: `C:/src/openRepoShape` and `C:\src\openRepoShape` were read as an owner/name, so setup.sh's clone and `--doctor`'s were handed `https://github.com/C:/src/openRepoShape.git` for the first and `https://github.com/C:\src\openRepoShape.git` for the second.
- Now: a letter, a colon and then `/` or `\` is a path, and goes through as it is, as `/…` and `.…` do. No owner/name holds a colon. The package's own `clone_url` (`src/openreposhape/standard.py`, `DRIVE_RE`) already read a drive letter this way.

**3. A Windows-spelled `$OPENREPOSHAPE_BIN_DIR`** (`install_commands`).
- Before: `--install` named the directory as typed. With `D:\tools\bin` it printed `installed at D:\tools\bin/openRepoShape` and `export PATH="D:\tools\bin:$PATH"`, which the drive's colon splits in two. It also never found the directory on a `$PATH` that holds `/d/tools/bin`, so it printed that line every time.
- Now: a drive-letter value, in either slash, is spelled again with `cygpath -u`, which Git Bash has, before anything is checked or placed. Every line then names `/d/tools/bin`, and the PATH note appears only when that directory is not on `$PATH`.
- Where no `cygpath` answers (none on PATH, it fails, or it answers a drive spelling again), the value is refused before anything is placed: "--install would place this command in 'D:\tools\bin', which is spelled for Windows, and no cygpath here spells it the way bash does, so the directory could not go on $PATH: the drive's colon splits it. Nothing was placed. Set $OPENREPOSHAPE_BIN_DIR to the directory as this shell spells it, then run this again."

**4. `tests-git-bash` runs `tests/test_install_sh_filter_repo.py`.** That file holds #598's uv install, the two readinesses and the pasted next steps, which ran on Linux only, while README says Git Bash runs step 3's one line "tested in CI". It is the third path on the job's pytest line, with a comment saying the installer's suite is two files. The job's other lines, the header's "the launcher's and the installer's suites, and nothing else" and #630's release-notes sentence of the same meaning stay true.

**5. The stale docstring.** `test_a_ref_the_doctor_would_check_out_is_validated` said the refusal "has to arrive before any clone does". The command clones first, then checks the ref, and refuses before `git checkout` is handed it. The docstring now says that. The order is unchanged.

- **Line 1: `command -v` for both names, and no version check.**
  - The block promises to execute nothing but `gh auth status`, and `test_the_machine_report_runs_no_installer` holds it to that: every fake on its PATH logs a run. Running `find_python`'s check would break that promise.
  - What answers is therefore never asked its version, and that has a cost. The Microsoft Store's `python3` stub reads as present, as macOS's Command Line Tools stubs for `python3` and `git` already do (setup.sh's comment at :41-47). That is not new: the base asked `command -v python3` too, and the Store installs its `python.exe` and `python3.exe` aliases together, so `python3` answers first.
  - What is new is a `python` that is Python 2, or older than 3.9, where there is no `python3`. On the base such a machine said `python3 MISSING` and printed the `--preflight` pointer. Now it says `present, as python` and prints no pointer. It is rare (CentOS 7, macOS 10.14 or older, Python 2.7 from python.org), and `--preflight` still refuses it with "no Python 3.9 or newer on this machine (tried python3, python)". The comment above `machine_report` says so.
  - The row keeps its name `python3`, and the state carries the name that answered, in the form `gh`'s row already uses (`present, logged in`). So README's sentence ("`git`, `gh` and whether it is logged in, `python3`, and `git-filter-repo` … each `present` or `MISSING`") and the handbook's stay true, and README is not edited. Labelling the row `python` would have made both false in Git Bash.
- **Line 3: respell, and refuse only where respelling is not possible.** Respelling keeps a Git Bash user's value working: `cp`, `mkdir` and the record took the Windows spelling already, and only the printed lines were wrong. Where no `cygpath` answers, nothing in the command can spell the value as bash's `$PATH` needs it: in Git Bash with `cygpath` off `PATH` the drive is real but its colon still splits `$PATH`, and on Linux `D:\tools\bin` is a relative directory with a colon in its name. No `$PATH` line could work there, so it refuses. A `cygpath` that answers a drive spelling again is refused alike.
- **The tests' `install_with_machine` now names its directory with `bash_path`.** Its `$PATH` holds only the programs `--install` runs, and no `cygpath`. On Windows the old `str(bin_dir)` is a drive path, which would now be refused. Off Windows, `bash_path` changes nothing. Other tests that pass `str(...)` keep the full PATH, so on Windows they now go through Git Bash's own `cygpath`.
- **`run_cmd` takes a `cwd`.** On the base, and under a mutation, the new BIN_DIR tests' `D:\tools\bin` is a relative directory. With `cwd=tmp_path` it lands in the test's own directory, never in the checkout.
- **Both BIN_DIR tests run over both slashes** (`BIN_DIR_SPELLINGS`, `typed_bin_dir`). Off Windows the typed value is the fixed `D:\tools\bin` or `D:/tools/bin`. On Windows it is a path under `tmp_path`, so neither test can place a command on the machine's own `D:` drive.

New, in `tests/test_openreposhape_command.py`, 27 cases:
- `test_the_machine_report_finds_a_python_by_either_name`: `python` alone, both, neither (3).
- `test_a_windows_spelled_bin_dir_is_spelled_the_way_bash_spells_it`: not on `$PATH`, on `$PATH`, each in both slashes (4). On Windows it is a real drive path (`str(bin_dir)` or its `as_posix()`) and Git Bash's own `cygpath`; elsewhere a fake `cygpath` answers as that one would.
- `test_a_windows_spelled_bin_dir_is_refused_where_no_cygpath_spells_it`: no `cygpath`, a `cygpath` that fails, a `cygpath` that answers `D:/tools/bin`, each in both slashes (6), on the machine report's own `$PATH`.
- `test_a_clone_is_handed_a_path_or_url_as_it_is`: owner/name, URL, scp, POSIX path, `C:/…`, `C:\…` and `d:\…`, each through setup.sh (a setup.sh that prints `$OPENREPOSHAPE_REPO`) and through `--doctor` (a `git` that prints the URL it is handed and fails) (14). Nothing is cloned or fetched.

**On the base a524542** (this head's test file over the base's command): **17 failed, 80 passed** in 62.32s. The failures are exactly the new cases that need the fix: `python-alone`, the four respelling cases, the six refusal cases and the six drive-letter cases. The other 10 new cases pass there too, as they should.

**On the head e85573a** (Linux, `dirty=0`):
- `tests/test_openreposhape_command.py`, `tests/test_install_sh.py`, `tests/test_install_sh_filter_repo.py`, `tests/test_openreposhape_package.py`: **220 passed** in 175.18s. The package suite imports 13 of the launcher's cases and swaps `run_cmd`, so it is in the set.
- `tests/test_repo_hygiene.py`, `tests/test_workflow_concurrency.py`, `tests/test_release_workflow.py`: **138 passed** in 14.46s.
- The head merged with main 879d804 (a probe commit, b6cfafe3): the launcher's, the two installer files, the package suite and `tests/test_repo_hygiene.py`, **297 passed** in 188.95s.
- **The three files `tests-git-bash` runs, with Windows' `write_text` emulated**: a plugin made every `Path.write_text` in the test process write CRLF, as it does on `windows-latest`. **162 passed** in 121.06s (97 + 47 + 18). A probe test shows the plugin takes effect (it passes with the plugin and fails without it).
- **actionlint 1.7.12**, with shellcheck on PATH, on `tests.yml` alone and on the four files the CI job names: exit 0.

d91b314's own `tests-git-bash` run was green on `windows-latest`. It ran the first round's 21 new cases and `tests/test_install_sh_filter_repo.py` there, so Git Bash's own `cygpath` answered the backslash respelling on a real drive path, and the five existing tests that pass `str(...)` and now go through it passed. What was checked, and what was not:
- **Checked, from #596's Windows runs (113 passed on 5f001de, and its probes):** a fake that Python writes with `#!/bin/sh` runs under Git Bash with no mode bits, and `command -v` finds it. A script piped in text mode reaches Git Bash's bash as CRLF, and bash drops the CR. A `$OPENREPOSHAPE_BIN_DIR` set from Python reaches bash unconverted: #596's probe 4 printed `D:\a\_temp\…\bin/openRepoShape`, which is why the respelling now runs in those tests.
- **Checked on Linux:** LF bytes (the CRLF emulation above). No assert in the test file compares a path bash printed with a Python path.
- **Not checked, until e85573a's own run:** on Windows, the slash spelling (`as_posix()`) through Git Bash's real `cygpath`, the refusal's values under `tmp_path` on the machine report's `$PATH`, and the new drive-answer case. If one fails there, its log shows the typed value and the directory.

One script ran each row against the base's command and the head's, offline, with a `gh` stub first on PATH. Row 3's slash lines ran again for this round.

| Row | Base a524542 | Head e85573a |
|---|---|---|
| 1. PATH with `git`, a logged-in `gh`, `python`, `git-filter-repo`, no `python3` | `python3          MISSING`, then `run: openRepoShape --preflight …`; the log holds only `gh auth status` | `python3          present, as python`, no `run:` line; the log holds only `gh auth status` |
| 2. `OPENREPOSHAPE_REPO=C:/src/openRepoShape` | setup.sh clones `https://github.com/C:/src/openRepoShape.git`; the doctor's `git clone` too | `C:/src/openRepoShape`, both |
| 2. `OPENREPOSHAPE_REPO=C:\src\openRepoShape` | `https://github.com/C:\src\openRepoShape.git`, both | `C:\src\openRepoShape`, both |
| 2. `OPENREPOSHAPE_REPO=acme/openRepoShape` | `https://github.com/acme/openRepoShape.git` | the same |
| 3. `OPENREPOSHAPE_BIN_DIR='D:\tools\bin'`, no `cygpath` | exit 0, `installed at D:\tools\bin/openRepoShape`, `export PATH="D:\tools\bin:$PATH"` | exit 2, the refusal above, nothing placed |
| 3. `OPENREPOSHAPE_BIN_DIR='D:/tools/bin'`, no `cygpath` | exit 0, `installed at D:/tools/bin/openRepoShape`, `export PATH="D:/tools/bin:$PATH"` (on Linux, a relative directory named `D:` in the working directory) | exit 2, the same refusal with `'D:/tools/bin'`, nothing placed |
| 3. the same, a fake `cygpath -u` answering `<dir>/d/tools/bin` | the same as with none: `cygpath` is never asked | exit 0, `installed at <dir>/d/tools/bin/openRepoShape`, `export PATH="<dir>/d/tools/bin:$PATH"` |
| 4. `tests-git-bash`'s pytest line | two files | three files |
| 5. the docstring | "the refusal has to arrive before any clone does" | "clones this checkout first, then checks the ref" |

Rows 1-3 need Windows to be real: there, `python` is a real python.org install, and `cygpath` and the drive letter are Git Bash's own. On Linux they are fakes. The first Windows run runs rows 1-3's tests under Git Bash: row 3 with Git Bash's own `cygpath` on a real drive path, rows 1 and 2 with a fake `python` and a fake `git`, so a real python.org install and git's own reading of `C:/…` stay unchecked.

Each row reverts one hunk of the launcher, or one part of the BIN_DIR hunk, in a throwaway clone, then runs the 27 new cases.

| Hunk reverted | Result | Failing cases |
|---|---|---|
| none (the head) | 27 passed | — |
| `shape_clone_url`'s drive-letter arm | 6 failed | the six drive-letter cases, setup.sh and doctor |
| `machine_report`'s `python` arm | 1 failed | `python-alone` |
| the BIN_DIR block, whole | 10 failed | the four respelling cases and the six refusal cases |
| the `cygpath -u` call (always refuse) | 4 failed | the four respelling cases |
| the refusal (keep the value as typed) | 6 failed | the six refusal cases |
| `\|\| spelled=""` after the `cygpath` call | 2 failed | `cygpath-fails`, both slashes: `set -e` exits with `cygpath`'s status, 1, not the refusal's 2 |
| **the `D:/…` arm of the drive case (SHOULD-1)** | 5 failed | the two slash respelling cases and the three slash refusal cases (was green) |
| the `D:\…` arm of the drive case | 5 failed | the same five, backslash |
| **the refusal's drive-answer arm, `[A-Za-z]:*` (NIT-1)** | 2 failed | `cygpath-answers-a-drive`, both slashes (was green) |
| the refusal's empty-answer arm, `""` | 4 failed | `no-cygpath` and `cygpath-fails`, both slashes |
| `--` dropped from `cygpath -u -- "$dir"` | 4 failed | the four respelling cases: the fake answers only `-u -- <value>` |
| the respelling after the placement, not before | 4 failed | the four respelling cases |

Not probed by a test: the `tests.yml` line (CI configuration, checked by actionlint and by this PR's own Windows run), the docstring, and `install_with_machine`'s `bash_path` (identity off Windows; on Windows it is what keeps four machine-report tests from the new refusal).

| Against | Result |
|---|---|
| origin/main 879d804 (d4a7fab, then 879d804, since the last table's 95780dd) | **clean**. Of this PR's files, the three suites', README and the handbook, only `tests/conftest.py` has moved since the base a524542 (new helpers for setup-project's tests), and nothing since the last table's 95780dd. The head merged with main (a probe commit, b6cfafe3) passes the suites below. |
| lane 3's #630 61b3907 | **clean**. It conflicted at ca5c49f, at the `tests-git-bash` pytest line, because its base was older than main's #590; its head now holds main d4a7fab, and it does not change `tests.yml`. It changes `AGENTS.md`, README, `adopt-project.py`, the handbook, `docs/cli.md`, the release notes and four test or render files, none of which this PR touches. |
| #620 9f2b67c, #621 10dda17, #622 55666f3, #623 fc08a28, #627 83ca2db, #628 d64252a, #631 21bdac8, #634 b7472df, #636 30a52c0, #637 62263d2, #638 b393e1f, #639 08b6245, #642 1a42df4, #643 1fe3764, #644 733f853 | **clean** (#623 changes `tests/conftest.py`; #627 changes README and the handbook) |
| #641 e87fcb0 | conflicts in `scripts/family.py`, and so does #641 against main alone: its own conflict, not an overlap with this PR. |
| lane 5's `fix/645-fixture-copy-maintenance-race` accc7d2, `fix/646-add-member-variant-remedy` 79754b9, `fix/647-branch-left-bump-leg-setup` be1bff9 (#657), `fix/648-bootstrap-and-gate-order` 852c5c4 | **clean**, all four |

- **The Store stub check.** See the decision on line 1. `--preflight` is where a stub is told apart.
- **`install.sh`'s own lines.** It names a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` as typed in its `preflight:` and `done:` lines, though the command it installs now prints `/d/…`. #649 names `--install`'s PATH note, and `install.sh` prints no PATH line. Checklist candidate 1.
- **`--help`.** Its `$OPENREPOSHAPE_REPO` and `$OPENREPOSHAPE_BIN_DIR` lines stay true, so `docs/cli.md` is not regenerated.
- **`D:`, `D:tools` and UNC values.** Checklist candidate 3.

1. **`install.sh` echoes a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` as typed.** It builds `target="$bin_dir/openRepoShape"` from the raw value, so in Git Bash with `D:\tools\bin` its `preflight:` and `done:` lines say `D:\tools\bin/openRepoShape`, while the command's own lines say `/d/tools/bin/openRepoShape`. Both spellings work in Git Bash, so nothing breaks. On a machine with no `cygpath`, the command's `--install` now refuses the value, and `install.sh` stops with "the <version> openRepoShape's --install refused (exit 2); its own refusal above says why", which is right but could refuse earlier, before the fetch.
2. **The PATH note is not quoted for every directory.** `--install` prints `export PATH="$dir:$PATH"` with the directory between double quotes as it is. A directory holding `"`, `$`, a backquote or `\` prints a line that does not do what it says, and one holding `:` cannot be on `$PATH` at all. A drive letter was the one case a person in Git Bash meets by default; the rest need a deliberately odd directory name.
3. **Two kinds of value slip past both new arms** (rv649). `$OPENREPOSHAPE_BIN_DIR` set to `D:` or `D:tools` matches neither respelling arm, so it is kept as typed and prints `export PATH="D:tools:$PATH"`, as on the base. A UNC `\\server\share\…`, as `$OPENREPOSHAPE_BIN_DIR` or `$OPENREPOSHAPE_REPO`, is never respelled (the PATH note's `\\` collapses) or becomes a github.com URL. The package's `clone_url` passes a UNC path through on Windows (`os.path.isabs`).

Review: rv649 found 0 blockers, 1 should-fix and 6 nits on d91b314 (CHANGES NEEDED); the fix round e85573a answers all seven, so both mutations that survived now fail a test, and lane 5's coordinator checked it. CI (Brett Heap's ruling 27, with his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 e85573a passed tests (Linux), tests-git-bash (windows-latest) and SonarCloud; the head 022bd50 is a clean update-branch merge of main 8062810, with SonarCloud, tests-git-bash and package green on it, and the local gate passed on the head itself: the launcher, installer and package set, 220 passed, and the hygiene, workflow and README-section set, 214 passed. Landed on Brett Heap's word of 2026-10-16T13:07:34Z, "merge #656 and #657 when green".

Closes #649

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 16, 2026
 (c6122e8) under #646's move back (#646 x #611)

No conflict. main moved from a524542 to 35133f2 (#623, #637, #622,
#620, #628, #639, #621, #627, #657, #656). Of those, #637, #622 and
#639 change scripts/family.py, in hunks git merged on its own; none
changes this branch's three test files.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…another spelling of its row's path moves it back to that path in the index alone, so the printed remedy no longer takes the member's gitlink away from its row (#646) (#670)

**Goal (#646): no remedy `family.py` prints un-registers a member that is in the holder.** Three states are outside it, each under "Left out": a `.gitmodules` that a `git mv` made name the variant too (checklist candidate (e)); a file the index holds at or under the row's path, where #574's remedy is kept; and a file or link the index holds at `members` itself (candidate (h)).

A member's only gitlink sat at `Members/<P>`, while its row and `.gitmodules` said `members/<P>`. This is #363's state after the fact. `family.py add` of ANOTHER member refused, as #574 rules (`family-members-dir-variant`). Its remedy was #574's remedy for any path under another spelling of `members/`:

- `git rm -r --cached -- Members/<P>`, and commit the removal, where nothing is on disk in that spelling;
- `git mv -- Members/<P> <new name>`, and commit the rename, where something is;
- `git commit`, where HEAD alone holds it (the variant's removal staged, not committed).

Each one, typed as printed, left no gitlink at the row's path. The holder's validator then refused `member-gitlink-absent`. Reproduced on BASE a524542 in all five layouts below.

All in `scripts/family.py`.

- `_members_spellings` takes the rows' own paths (`_rows_paths`: the one path `_member_path` gives a row, `members/<project>`; a row it refuses is `member-path`'s).
- **Which path is the member's own.** The index must hold nothing at or under the row's path. Then a gitlink at a case variant of that path, in the index or in HEAD alone, is that member's. It gets a new remedy, `RESTORE`, placed in `REMEDY_ORDER` after the index's own remedies.
- **The remedy** is the index-only move that `validate-family.py` prints for the same state since #639. Brett Heap accepted it on 2026-10-16T11:21:49Z ("Accept; merge #639 after #637"), in place of the "committed `git mv`" of his "Refuse on every disk" ruling, because `git mv` to the row's path fails or misplaces the gitlink:
  - `git rm -r --cached --ignore-unmatch -- <variants>`. It touches no file, and it is no error where HEAD alone holds the variant. Where a sparse checkout keeps a variant off disk (`skip-worktree`), the line says `git rm -r --cached --sparse --ignore-unmatch -- …`.
  - `git update-index --add --cacheinfo 160000,<commit>,members/<P>`, one line per row, at the commit the variant entry records. For an unmerged variant, that is the commit at stage 2.
  - A commit, where one is left to make.
- **What the disk holds afterwards** gets the validator's own sentences, placed after every other remedy:
  - where nothing is checked out at the row's path, as in a fresh clone, `git submodule update --init --recursive`;
  - on a case-sensitive disk, `mv -- Members/<P> ../Members-<P>` for the directory left at the variant;
  - on macOS and Windows, leave it, since it is the row's own directory.
- A directory on disk at a gitlink moved back from HEAD is the move's to name. It is not named a second time as untracked.

The branch merges main 35133f2, which holds #639 (c6122e8), in one merge commit with no conflict.

Example: the issue's state, the checkout at `members/IRRS`:

```
REFUSED family-members-dir-variant: the family root holds a path at or under another spelling of members/, which a case-insensitive disk takes for members/ itself — in the index: `Members/IRRS` (a submodule)
Remediation: every member is mounted under members/, and a holder keeps that directory in one spelling on every disk. Move each member's gitlink held only under another spelling of its row's path, `Members/IRRS` for the row members/IRRS, to that path, at the commit it records, in the index alone, which touches no file (`git rm -r --cached --ignore-unmatch -- Members/IRRS`, then `git update-index --add --cacheinfo 160000,<commit>,members/IRRS`), and commit that; where nothing is checked out at members/IRRS, as in a fresh clone, `git submodule update --init --recursive` then checks the member out there; on a case-sensitive disk, as Linux's usually is, a directory left at Members/IRRS stays there, untracked, and add refuses it as family-members-dir-variant: move each one that is there out of the holder, `mv -- Members/IRRS ../Members-IRRS`; on macOS and Windows it is the row's own directory, so leave it; then re-run.
```

No pinned file changes: `scripts/family.py` is the standard's own tool, not a copy in a holder. No `--help` text changes, so `docs/cli.md` is untouched. A `git grep` of AGENTS.md, README.md, `docs/handbook.html` and the family-root templates found no sentence about `add`'s case-variant remedies, so none became false.

1. **The member's own gitlink: one whose path folds to a row's path, where the index holds nothing at or under that path.** The next commit records the index, and every remedy here ends in one.
   - If the index holds the row's gitlink, a gitlink at a variant is a second one. Taking it out removes nothing from the row, so it keeps #574's remedy, and moving it back would overwrite the row's gitlink.
   - If the index holds a FILE at the row's path, the move would replace it without a word. If it holds files UNDER the row's path, the move's second line fails (`appears as both a file and as a directory`) after the first has already taken the variant out. Both keep #574's remedy (lane 3's review RV670, N3).
   - A file at the variant, or anything under it, also keeps #574's remedy.
   - `test_only_the_members_own_gitlink_is_moved_back` holds all of these, 9 cases.
2. **The commit named is the one the variant entry records, so the remedy is a move, not a bump.** Brett Heap decided this at 2026-10-16T14:11:42Z: "take all recommendations for #670 and #681". That word replaces his 13:07:34Z take of "the row's pin".
   - Where a row has two variants, the first found is named, the index's before HEAD's.
   - **This departs from #646's Direction line (`<pin>`) and from #639's rule:** #639's restore in `validate-family.py` names the row's pin. The two tools give different lines where the variant records a commit the row does not pin, and, since S1, where a sparse checkout keeps the variant off disk: `add` prints `--sparse` and the validator does not (candidate (f)).
   - **The four reasons, as lane 3's review RV670 measured them:**
     1. **A pure rename.** With the variant's commit, the move changes only the path. Where the commits differ, the gate then reports `member-gitlink-mismatch`, which has its own LOCKSTEP text and `bump` remedy, and exits 1, and an `add` of another member still goes through. `test_a_commit_the_row_does_not_pin_is_moved_not_bumped` types that end to end. (Where the variant equals the pin, the bytes are the same either way.)
     2. **The pin would silently bump.** With the pin, the paste changes the commit the holder records: a roll-back or an advance, a bump done outside `bump`, which the LOCKSTEP text says the gate refuses.
     3. **A row with no pin needs a fallback.** The validator never prints its remedy for such a row, because `member-pin` or `member-pin-commit` stops the row first. So #639's rule has no answer there, and a pin-based `add` would need the variant's commit anyway.
     4. **A pin absent from the clone fails.** `update-index --cacheinfo` does not check that the object exists, and the printed `git submodule update --init --recursive` then fails with rc=128 (`Fetched in submodule path 'members/IRRS', but it did not contain 3333…`). The variant's commit is already recorded, so this remedy adds no new unreachable commit.
   - **The validator is not changed.** Naming the variant's commit there too (it already prints it, as `(at <commit>)`) would edit a pinned template, and that needs its own word, which nobody has given.
3. **An unmerged variant is moved back at its stage-2 commit** (RV670, N2). `ls-files -s` lists stage 1, the merge base, first, and the PR's first head took that one.
   - Stage 2 is HEAD's entry each time, so the move is a rename relative to HEAD, and the validator also answers an unmerged path from HEAD. In a merge and in a stash pop it is the holder's own side. In a rebase it is the upstream's side, which is HEAD while the rebase runs: after `git rebase --quit` the move names the upstream's commit, not the person's own, which is stage 3 (probed by rv670b, the re-verify of this PR).
   - The removal takes out every stage, and the commit after it goes through (`$Q/v112/w646b/probes/w646b-unmerged-probe.log`).
   - An unmerged variant with no gitlink at stage 2 is no member's own: HEAD's entry holds no gitlink there, whatever stages 1 and 3 hold. It keeps #574's remedy, where stage 2 is missing and where it is a file. A gitlink at stage 1, a file at stage 2 and a gitlink at stage 3 is an `OWN` state too (rv670b's NIT-3): git lists stage 1 first, and rv670b's mutant R1, which read the first record's mode in place of stage 2's, passed all 36 tests and moved the file's blob back as a gitlink.
   - The state is reachable only with unmerged entries and no in-progress mark. A conflicted `git stash pop` leaves it, and so do `git merge --quit` and `git rebase --quit` (rv670b probed both). `add` refuses a merge, rebase, cherry-pick, revert or `git am` in progress (`family-operation-in-progress`) before it gets here.
4. **A variant a sparse checkout keeps off disk is taken out with `--sparse`** (RV670, S1). Without it, `git rm -r --cached --ignore-unmatch` exits 0 and removes nothing, so the index then held two gitlinks, and the next `add` refused again. With it, the entry goes, measured on git 2.43 with and without `core.sparseCheckout` (`$Q/v112/w646b/probes/w646b-sparse-probe-*.log`). The flag is printed only where a variant needs it, so the line stays the validator's everywhere else. Git older than 2.34 has no `git rm --sparse`: see "Left out".
5. **The commit is printed only where one is left to make.** That is:
   - HEAD holds a variant (RV670's S2 state, HEAD holding the gitlink in both spellings at one commit, is one of these);
   - HEAD's gitlink at the row's path is another commit, or there is none;
   - or a pending change is committed first.

   A move to the variant that is only staged, with HEAD still holding the row's gitlink at that commit, leaves nothing to commit, and a printed `git commit` would fail as typed.
6. **The code stays `family-members-dir-variant`** (or `member-path-taken` where some path is also a variant of the new member's mount path). The add is still refused for this path, as for any path under another spelling of `members/`: with `core.ignorecase` set, git would stage the new gitlink as `Members/<new>` (#362). Only the remedy changes.
7. **The closing sentences come last**, after #574's disk remedies.
   - Where a `members/` renamed by hand is moved back (`mv -- Members members`), the checkout goes home with it.
   - If they came before that, the `mv` out of the holder would act on `Members/<P>` while it may still be the member's checkout. And `git submodule update` would make a `members/`, inside which `mv -- Members members` would then put `Members` (`members/Members`).
   - `test_with_the_other_remedies…[members/ renamed by hand]` holds the order: it types the move back and then `git submodule update`, which checks the member out in the `members/` moved back.
8. **One text on every disk.** The move reads the index and HEAD, never the disk. The dry run and the real run print the same two lines. Asked in process of an emulated folding disk and of a Linux one (`disk_as`, as `tests/test_family_members_case_variants.py` emulates it), each layout gives the same text the subprocess gives.
9. **The disk after the move.**
   - On a case-sensitive disk the variant keeps either a checkout (15 entries, `?? Members/`) or, after a fresh clone, an empty directory. The gate passes either way.
   - If the printed `mv` is not typed, the next `add` refuses `family-members-dir-variant ... on disk and untracked: Members/Atlas` with #574's `mv -- Members/Atlas <new name>`, and passes once that is typed.
   - If the printed `mv` is typed, the next `add` passes at once (`$Q/v110/w646/leftover.log`, `repro-head.log`).

All runs used `$Q/v110/run9.sh`: a `gh` stub that exits 97 first on PATH, `GH_TOKEN=bogus-l5`, an empty `GH_CONFIG_DIR`, `/usr/bin/python3 -m pytest`, and local bare remotes only.

`tests/test_family_add_member_variant.py` is new, with 37 tests. Every printed command is typed as printed. The holder's gate then passes (or, in the one case where the variant records a commit the row does not pin, names that drift), the member is where its row says at its commit, and the same `add` goes through.

- `test_each_layout_moves_the_gitlink_back_as_printed` (4 layouts) and `test_a_fresh_clone_moves_the_gitlink_back_as_printed`.
- `test_every_disk_gives_the_same_answer` (4 layouts × folding and Linux, in process).
- `test_with_the_other_remedies_each_runs_in_its_order` (8 states):
  - beside a file under `Members/`;
  - after a pending change in HEAD;
  - with nothing left to commit;
  - `members/` renamed by hand;
  - HEAD alone, with the checkout at the variant;
  - HEAD holding both spellings at one commit, the row's removal staged (S2);
  - kept off disk by a sparse checkout (S1; skipped on git older than 2.34, as #574's sparse case is);
  - unmerged at stages 1, 2 and 3, with nothing in progress (N2).
- `test_every_member_held_in_the_wrong_case_is_moved_back` (two rows).
- `test_the_gitlink_is_recorded_at_the_commit_it_records` (4 states).
- `test_a_commit_the_row_does_not_pin_is_moved_not_bumped` (S3): typed as printed, the gate exits 1 with `member-gitlink-mismatch` alone, and the same `add` goes through.
- `test_only_the_members_own_gitlink_is_moved_back` (9 states, four of them new: a file at the row's path, a file under it, an unmerged variant with no gitlink at stage 2, and one with a gitlink at stage 1, a file at stage 2 and a gitlink at stage 3).
- `test_the_rows_that_have_a_path_of_their_own`.

Layouts that need two directories differing only in case skip on a disk that folds case. No test makes such directories there.

Two existing lines change to the new shapes:
- `Spelled` has two more fields, `recorded` and `sparse`, so a seven-field tuple compared in `tests/test_family_members_case_variants.py` gains `"", False`;
- the three-argument stand-in for `_members_spellings` in `tests/test_family_add_refusals.py` takes `rows`.

The S1 case sits in the new file, beside its other move-back states, and not in `test_family_members_case_variants.py` beside #574's sparse test: it needs the new file's helpers, and that file already imports from the other one.

Results on this head. a837a02 is 2104a3d plus one test commit: it adds one state to `OWN` and changes no code, so the runs of 2104a3d below stand for every file it does not touch.

| Run | Head | Result |
|---|---|---|
| the new file | a837a02 | `37 passed in 48.55s` |
| `tests/test_family_members_case_variants.py` | a837a02 | `60 passed in 53.83s` |
| the new file, with `scripts/family.py` put back to the merge's (bc48c2a) | 2104a3d's tests | `5 failed, 31 passed in 170.09s`: the sparse and unmerged states typed, and the OWN cases of a file at the row's path, a file under it, and an unmerged variant with no gitlink at stage 2 |
| the new file, `test_family_members_case_variants.py`, `test_family_add_refusals.py` | 2104a3d | `161 passed in 809.30s` |
| `test_family.py`, `test_family_member_path.py`, `test_validate_family_unnamed_mounts.py`, `test_readme_windows_quoting_sentence.py`, `test_repo_hygiene.py` | 2104a3d | `269 passed in 803.63s` |

The S2 and S3 cases pass on the merge too, since they pin behaviour the PR already had. Lane 3's mutant X2 fails the S2 case (below). The S3 case holds the variant's-commit rule end to end.

Earlier, at the PR's first heads (before the merge with main):

| Run | Head | Result |
|---|---|---|
| the new file, on BASE a524542 | a524542 | `29 failed in 48.75s` |
| every `tests/test_family*.py`, 21 files | 2216e1c | `851 passed, 19 skipped in 1248.90s` |
| `tests/test_validate_family*.py` (5), `test_refusal_default_remediation.py`, `test_repo_hygiene.py`, `test_siblings_family_name.py`, `test_template_text_placeholders.py`, `test_clone_url_bytes.py`, `test_update_shape_header_comments.py`, `test_update_shape_pr_base.py`, `test_shape_doctor.py` | 2216e1c | `1014 passed, 3 skipped in 1323.30s` |

Holder `Fam` with members Atlas and Nova, Atlas's gitlink moved to `Members/Atlas` in the index at its own commit and committed; then `family.py add` of Zed (`$Q/v110/w646/probes/repro.sh`), at the PR's first head.

| Layout | BASE a524542 (`repro-base.log`) | head 79754b9 (`repro-head.log`) |
|---|---|---|
| L1, nothing on disk in that spelling | `git rm -r --cached -- Members/Atlas`, commit: no gitlink for Atlas; gate `member-gitlink-absent` | rm, `update-index`, commit: Atlas at `members/Atlas` at its commit; status clean; gate exit 0 in all three modes; next `add --dry-run` exit 0 |
| L2, the only checkout at `Members/Atlas` | `git mv -- Members/Atlas renamed-1`, commit: gitlink at `renamed-1`; gate `member-gitlink-absent` | rm, `update-index`, commit, `git submodule update --init --recursive`, `mv` out: the same end state |
| L3, both directories | `git mv`, commit: gitlink at `renamed-1`; `?? members/Atlas/`; gate `member-gitlink-absent` | rm, `update-index`, commit, `mv` out: the same end state |
| L4, a fresh recursive clone (an empty `Members/Atlas`) | `git mv`, commit: gitlink at `renamed-1`; gate `member-gitlink-absent` | rm, `update-index`, commit, `git submodule update`, `mv` out: the same end state |
| L5, the variant's removal staged, not committed | `git commit`: the removal committed; gate `member-gitlink-absent` | rm (`--ignore-unmatch`, no error), `update-index`, commit: the same end state |

In every layout, on both sides, the dry run and the real run printed the same stderr, and the in-process run under a folding disk and under a Linux disk printed the same two lines as the subprocess. This round's states (S1, S2, S3, N2, N3) are reproduced by the tests above, which type each printed line.

Not reproduced: a real macOS or Windows disk. The folding disk is emulated. CI runs Linux only (#597).

**This round** (`$Q/v112/w646b/probes/w646b-mutate.py`, in throwaway clones at 2104a3d). Each probe changed one hunk or condition, and the new file was then run with `-x`. Every hunk of this round fails a test, and so do lane 3's X1 and X2 and rv670b's R1, which needed a new state (last row).

| Probe | What it undoes | Result | First failing test |
|---|---|---|---|
| M1 | a variant never marked `sparse` | 1 failed, 19 passed | `test_with_the_other_remedies…[kept off disk by a sparse checkout]` |
| M2 | `--sparse` never printed | 1 failed, 19 passed | the same |
| M3 | back to "no GITLINK at the row's path" (the first heads' rule) | 1 failed, 31 passed | `test_only_the_members_own_gitlink…[a file at the row's path]` |
| M4 = lane 3's X1 | "no entry AT the row's path", under it not asked | 1 failed, 32 passed | `…[a file under the row's path]` |
| M5 | the first record git lists (stage 1), as the first heads had it | 1 failed, 20 passed | `test_with_the_other_remedies…[unmerged, with nothing in progress]` |
| M6 | stage 0 alone: an unmerged variant is never the member's own | 1 failed, 20 passed | the same |
| M7 | the last record (stage 3) | 1 failed, 20 passed | the same |
| lane 3's X2 | `name in in_head or` deleted | 1 failed, 18 passed | `test_with_the_other_remedies…[HEAD holding both, the row's removal staged]` |
| lane 3's X3 | `'posix'` dropped from the move back's three quotings | 36 passed (survives) | none on Linux: `shell_quoted` quotes for `os.name`, POSIX here (see "PowerShell" under "Left out") |
| rv670b's R1 | `ours[0] == b"160000"` replaced by the mode of the first record git lists (`mode == 0o160000`), in place of stage 2's | 36 passed (survived) at 2104a3d. At a837a02: `1 failed, 34 passed in 44.07s` (`-x`), and `1 failed, 8 passed, 28 deselected` for `-k test_only_the_members_own_gitlink` | `test_only_the_members_own_gitlink…[unmerged, with a file at stage 2 and gitlinks at 1 and 3]` |

The S3 test types the remedy end to end and asserts the gate's finding, so it holds the variant's-commit rule itself; no mutant of this round touches that rule.

**The first heads** (`$Q/v110/w646/probes/mutate.py`, in a throwaway clone at 2216e1c). The new file was run with `-x`.

| Probe | What it undoes | Result at 2216e1c | First failing test |
|---|---|---|---|
| H1 | `RESTORE` in `REMEDY_ORDER` | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H2 | no member's own gitlink found | 1 failed | the same |
| H3 | a gitlink at the row's path not asked | 1 failed, 22 passed | `test_only_the_members_own_gitlink…[a second gitlink beside the row's]` |
| H4 | any index entry, not a gitlink alone | 1 failed, 23 passed | `…[a file at the variant]` |
| H5 | the index's branch | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H6 | the commit always printed | 1 failed, 15 passed | `test_with_the_other_remedies…[with nothing left to commit]` |
| H7 | the commit printed only where HEAD holds the variant | 28 passed (survived) | killed at 79754b9: `test_the_gitlink_is_recorded…[staged at another commit, HEAD keeping the row's]` |
| H8 | the commit read from HEAD's gitlink at the row's path first | 28 passed (survived) | killed at 79754b9, by the same case |
| H9 | HEAD's branch | 1 failed, 3 passed | `test_each_layout…[the removal staged, not committed]` |
| H10 | HEAD's file taken for a gitlink | 1 failed, 25 passed | `test_only_the_members_own_gitlink…[a file at the variant in HEAD alone]` |
| H11 | the disk named again at a gitlink moved back from HEAD | 1 failed, 17 passed | `test_with_the_other_remedies…[HEAD alone, the checkout at the variant]` |
| H12 | the commit not printed after a pending change | 1 failed, 14 passed | `…[after a pending change in HEAD]` |
| H13 | no `--ignore-unmatch` | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H14 | the last variant found names the commit | 1 failed, 20 passed | `test_the_gitlink_is_recorded…[two variants in the index]` |
| H15 | the closing sentences before the disk's remedies | 1 failed, 16 passed | `test_with_the_other_remedies…[members/ renamed by hand]` |
| H16 | the closing sentences dropped | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H17 | `add` passes no rows | 1 failed | the same |
| H18 | `_checked_mount_path` drops the rows | 1 failed | the same |
| H19 | `_rows_paths` takes any row's path | 1 failed, 27 passed | `test_the_rows_that_have_a_path_of_their_own` |
| H20 | `_rows_paths` does not check the project's type | 1 failed, 27 passed | the same |
| H21 | the move's part dropped from the text | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |

The branch merges main 35133f2 in bc48c2a, with no conflict: its tree, 088aa26, is the one `merge-tree` predicted. Only #637, #622 and #639 had changed `scripts/family.py` since BASE.

`git merge-tree --write-tree` of the head a837a02, last after a fetch at about 18:45Z. Every one is clean.

- origin/main 2fac979 (tree 042d309). Since the last check (main de92614) it has landed #634 (30676c9), #659/#671 (6e37d0e), #658/#699 (b618ec1), #650/#719 (3dc34b8), #678/#701 (5c392dd) and #425/#717 (2fac979). The other rows are the heads as fetched now.
- Lane 4's five branches that change `scripts/family.py`: `fix/684-family-holder-states` 7015386, `fix/686-bump-reachability` 7567e60, `fix/688-family-init-values` aa95b6b, `fix/689-next-push-refspec` fa51384 (it also changes `tests/test_family_add_refusals.py`) and `fix/693-local-remote-dir` 96ebe45.
- Lane 4's other open heads: `fix/674-update-shape-apply` d069902, `fix/690-siblings-inputs` 38a40ab, #698 b069951, #700 fca40df, #702 48bac6e, #703 (`fix/696-setup-family-folder`) 4054ffd, #705 1bc63e8 and #706 (`fix/664-surviving-mutants`) 3e408b1.
- Lane 2's #671 and #699 and lane 4's #701 are on main now, so the main row covers them.
- Lane 5's #682 (#648, 8b39cd1), `fix/692-raw-folder-paths` ab55c29 (it changes `scripts/family.py` too) and `fix/704-bootstrap-leg-moves` 4e2b211.

- **A `.gitmodules` that names the variant too** (a `git mv members/<P> Members/<P>`, committed or staged; checklist candidate (e) below). The move puts the gitlink back at the row's path, but the section still gives `Members/<P>`. So `git submodule update` fails with `No url found for submodule path 'members/Atlas'`, and the gate refuses `member-uninitialized` (`$Q/v110/w646/gitmv-head.log`). On BASE the same remedy moved the gitlink and the section to `renamed-1` (`gitmv-base.log`). #639's validator prints the same incomplete lines for this state. A fix needs a `.gitmodules` edit that the validator does not print.
- **A file the index holds at or under the row's path, beside the variant's gitlink** (decision 1). #574's remedy is kept: it takes the variant out, and the file stays at the row's path. For a file at the row's path, typed, it leaves HEAD with `100644 blob … members/IRRS`, the gate refuses `member-gitlink-absent` (exit 2), and `add` of another member then goes through (exit 0), as rv670b ran it; a file under the row's path was not run. The move would replace that file, or stop half done, so no remedy here can both keep the file and put the member back. This state predates this PR.
- **A file or link the index holds at `members` itself** (a file `members`, or a committed link `members -> Members` left after a hand rename; checklist candidate (h) below). The rule asks about entries at or under a row's path, not above it. With every member at a variant and `members` a file in the index, `add` prints the move back, and rv670b typed it line by line: `git rm` takes out both variants (exit 0), then each `git update-index --add --cacheinfo` fails with exit 128, `'members/IRRS' appears as both a file and as a directory`. The next `add` prints the same move again, now from HEAD. A committed link behaves the same. #574's remedy also un-registered the member here, so it is not a regression. Adding `or row.startswith(name + "/")` to the rule would keep #574's remedy there, which leaves the member with no gitlink, as the entry above does. `scripts/family.py` is not changed for it here.
- **Git older than 2.34 and `--sparse`.** `git rm` has no `--sparse` option before 2.34 (git v2.33.0's `builtin/rm.c`), and it skips every `skip-worktree` entry (`if (ce_skip_worktree(ce)) continue;`), so there the line without the flag was the same silent no-op. The printed `--sparse` stops with an unknown-option usage error, exit 129 (from reading that source; not run). #574's `git mv --sparse` has the same limit. The S1 test is skipped on git older than 2.34, as #574's sparse test is.
- **PowerShell.** The printed lines are quoted for a POSIX shell (#532), so paste them into Git Bash. RV670 notes (N4) that in PowerShell the bare `160000,<commit>,members/IRRS` becomes an array passed as three arguments, which `--cacheinfo` also accepts. Not run, by RV670 or here. On Linux, dropping `'posix'` from these lines changes nothing (lane 3's mutant X3), since `shell_quoted` quotes for `os.name`, which is POSIX there.
- **The closing `mv` inherits #639's N-1 and N-2** (rv611c), and its N-3:
  - N-1: where the variant directory is a live checkout with an absorbed gitdir, the `mv` leaves its `.git` file pointing nowhere;
  - N-2: the `mv`'s condition is stated by OS, not by a check a person can run, so on a case-folding Linux mount (WSL's `/mnt/c`) typing it moves the member's own checkout out, and `git submodule update --init --recursive` then puts it back;
  - N-3: an empty `../<variant>` directory that already exists is moved into.
- **A variant under `members/` itself.** `members/irrs` for the row `members/IRRS` is invisible to `add`'s `_spelled_target`, while the validator's casefold check names it (RV670, N6). This predates this PR.
- **Case folding beyond ASCII**, as in #574: project names are ASCII, and git's `icase` folds ASCII only.

- **(e) `family.py add` and `validate-family.py`: the move back of a member's gitlink leaves `.gitmodules` naming the variant after a `git mv`.** After `git mv members/<P> Members/<P>`, git rewrites the section's `path` to `Members/<P>`. The index-only move both tools print puts the gitlink back at `members/<P>` and leaves the section where it is. `git submodule update --init` then fails with `No url found`, and the conditional `mv` would move the member's only checkout out of the holder. Both could also print `git config -f .gitmodules submodule.<name>.path members/<P>` and `git add .gitmodules` where a section gives a variant's path (evidence `$Q/v110/w646/gitmv-head.log`).
- **(f) `validate-family.py`: its move back of a member's gitlink is a silent no-op where a sparse checkout keeps the variant off disk.** Its `git rm -r --cached --ignore-unmatch -- <variant>` has no `--sparse`, so it exits 0 and removes nothing, and its `git update-index --add` then leaves two gitlinks in the index. This is the validator's half of RV670's S1, which RV670 assigns to #459. The validator is a pinned template.
- **(g) `validate-family.py` and `family.py add`: a file the index holds at or under a row's path, beside the member's gitlink at a variant, has no remedy that keeps the member.** `add` now keeps #574's remedy there, which takes the variant out. The validator asks `recorded_gitlink`, which skips any entry that is not mode 160000, so by reading its code (not run) it prints its move back for this state too: `update-index --add` would replace a file at the row's path without a word, and fail on files under it after its first line has taken the variant out. This state predates #670.
- **(h) `family.py add` (and probably the validator, not run): a file or link the index holds at `members` itself is never named.** The move printed beside it stops at its `update-index` line after `git rm` has taken every variant out (above, under "Left out"). Nothing here fixes it; the coordinator places it.

Review: lane 3's RV670 found 79754b9 READY with three SHOULDs; the fix round took S1-S3, N2 and N3, rv670b's re-review found it ready with nits, and a837a02 takes Brett Heap's ruling of 2026-10-16T14:11:42Z that the restore keeps the variant's own commit. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a837a02 passed tests, tests-git-bash and SonarCloud; the head 6d7e4ff is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree b46e560 over main 6794a9f: the family_add_member_variant, family_add_refusals, family_members_case_variants, repo_hygiene, agents_md_codes, cli_reference sets, 308 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #646

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
… in command scope and in a global config that includes the person's own, so a `copytree` of a fixture no longer meets a detached repack deleting its loose objects (#645) (#713)

**What changed.** One session fixture in `tests/conftest.py`, `no_auto_maintenance` (autouse), and one new test file, `tests/test_no_auto_maintenance.py`. No shipped file and no other test file changes. Four commits: the fix and its tests (e2110f8), a comment (accc7d2), and the fix round below (8cfc8eb, and 979bb39, which only shares a test helper).

The fixture sets `gc.auto=0` and `maintenance.auto=false` for every git the session starts, in two scopes:

- **Command scope** (`GIT_CONFIG_COUNT`), appended after the entries the environment already holds (the Windows job holds two). Every git the suite starts, every git a tool under test starts, and every git a `git submodule` starts inherits it. It outranks every config file.
- **A global config file** (`GIT_CONFIG_GLOBAL`, in the basetemp). It includes the global config git would read without it (the file `GIT_CONFIG_GLOBAL` already named, or `$XDG_CONFIG_HOME/git/config` and then `~/.gitconfig`) and sets the two keys after it. This one is for the `receive-pack` a push starts in a local remote: git starts it with `GIT_CONFIG_COUNT` and `GIT_CONFIG_PARAMETERS` removed, so command scope never reaches it. Where `HOME` is unset the `~/` paths are left out, and an empty `GIT_CONFIG_GLOBAL` includes nothing (fix round, below).

A comment beside #440's note in `clone_just_pushed` says why, names both sites, and says what is left.

**Why.** `shutil.copytree` reads a repository's `objects/` one file at a time. The suite copies a repository a fixture has just committed in, fetched into or pushed to in 139 places. Each `git commit`, `merge` and `fetch`, and the `receive-pack` in a remote after a push, runs `git maintenance run --auto` as it finishes. From git's source:

- git 2.46: the auto run stays in the foreground.
- git 2.47: it is started with `--detach`, takes `objects/maintenance.lock` and then detaches, so the command returns while it runs.
- git 2.52 and 2.53: the default strategy is still `gc`, which does nothing below 6700 loose objects.
- git 2.54: the default strategy is `geometric`. Its repack runs when packs are out of geometric order, or once its estimate, taken from `objects/17` alone, is above 256 loose objects, so two loose objects there are enough (`maintenance.geometric-repack.auto`, 100, rounded up). It deletes the loose objects it packed.

So on a current git, a copy taken just after a commit can find a loose object gone. It happened twice on the Linux runner:

- `holder` (`tests/test_family.py:129`), in `test_family_member_name.py` (lane 1's report on #473);
- `the_standard_moved` (`tests/test_doctor_pin_restore_merges.py:99`), a copy of the session's `standard` with its `.git`, in `test_copies_neither_side_edited_are_taken_whole_with_the_pin` on #623's run 38895011713: `shutil.Error ... standard5/openRepoShape/.git/objects/1e ... [Errno 2]` (lane 2's #473 comment 6215422231). The same function copies `moved` at :102 just after a commit in it.

Both are covered by the same session setting: the commits before each copy run under `os.environ` (`commit_all` passes `{**os.environ, ...}`).

A listing of `.git/objects` taken before and after such a repack also differs: lane 3's `objects changed` in `tests/test_family_bump_remove_rollback.py` (#736's Linux run 38981191418), 37 loose objects packed into two. The same setting covers it (i645 on #645).

**Decisions and evidence.**

- *Option A (the suite's environment), not B or C.*
  - B (config in each copied repository) would have to reach repositories the tools under test create themselves, such as the bare remotes `scaffold-project.py --local-remote-dir` makes and pushes into before a test sees them. A config written after that first push is too late.
  - C (a copy that survives a repack) cannot be made safe. A copy can list `objects/pack/` before the new pack lands, and then a loose-object directory after it was emptied. That copy succeeds and is missing objects.
  - A covers all 139 places from one place.
- *Two scopes, not one.* Probe on git 2.43, pushing into a local bare remote with `receive.denyDeletes=true` given each way:
  - with `GIT_CONFIG_COUNT`, the branch was deleted;
  - with `-c`, deleted;
  - through a `file://` URL with `GIT_CONFIG_COUNT`, deleted;
  - with `GIT_CONFIG_GLOBAL`, `remote: error: denying ref deletion` (refused).

  So command scope alone misses the remotes, and ten copies are of bare remotes. A global file alone would be dropped wherever a test gives git a global file of its own (14 test files set `GIT_CONFIG_GLOBAL`, five more through `excluding()`). No test file replaces both scopes and also copies a repository. rv645, the reviewer, repeated the `receive.denyDeletes` probe and got the same answers, and instrumented the git calls of the batches it finished before the checkpoint: the only receive-packs that ran without both settings were in `test_family_add_excludes.py` and `test_adopt_clone_template.py`, neither of which copies a remote.
- *Which verbs start auto maintenance* (git 2.43 trace2): `commit`, `fetch`, `pull` and `submodule update --remote` (its fetch in the submodule) start `git maintenance run --auto`. A push's `receive-pack` starts `git gc --auto --quiet`. `clone`, `submodule add` and `submodule update --init` start none.
- *Behaviour no test checks is unchanged.* The person's own global config is still read: it is included, and it comes first. A test of that is part of the new file. The tests that read or set a global config (`test_git_helper_identity.py`, `test_adopt_git_reads.py`, `test_tracking_branch_ref.py` and others) set their own, which replaces the session's.

**Fix round (8cfc8eb), on rv645's review** (no MUST; two SHOULD, five NIT).

- **SHOULD 1: with `HOME` unset every git was fatal.** The file's include of `~/.config/git/config` and `~/.gitconfig` made git exit 128, `error: could not expand include path '~/.config/git/config'`, in every command the session started. The estate runs with `HOME` set, so nothing failed here; an `env -i` or hermetic runner would have lost the whole suite.
  - `session_global_config` now writes a `~/` path only where `home_is_known()` (`HOME` is set, or `os.name == "nt"`, where Git for Windows finds a home itself), and writes no `[include]` header when nothing is included. An `XDG_CONFIG_HOME` that is set is still included, because it needs no `HOME`.
  - The two `test_a_persons_own_global_config...` cases skip where `HOME` was unset. They skip on the environment and not on the file's bytes, so a fixture that dropped its includes still fails them (probe M5 below).
  - A new test, `test_where_home_is_unset_the_file_is_one_git_can_read` (with and without `XDG_CONFIG_HOME`), reads the file back through `git config --global --includes`. It is skipped on Windows. It and the empty-value test share one helper, `probe_read_from_the_session_file`, so the new tests do not repeat a body for SonarCloud's duplication gate.
  - Measured under `env -u HOME -u XDG_CONFIG_HOME`, `run9.sh`, git 2.43.0:

    | file | before (accc7d2) | after (8cfc8eb) |
    |---|---|---|
    | `tests/test_clone_just_pushed.py` | 1 passed, 6 errors | 7 passed |
    | `tests/test_no_auto_maintenance.py` | 3 failed, 2 passed, 2 errors (7 tests) | 9 passed, 2 skipped (11 tests) |
    | `tests/test_family_member_name.py` | not run | 13 passed |

    The 2 skips are the two `test_a_persons_own_global_config...` cases.
  - Where `HOME` is set and the file is written as before, nothing changes: across 27 combinations of `HOME` (set, unset, empty), `XDG_CONFIG_HOME` (set, unset, empty) and `GIT_CONFIG_GLOBAL` (unset, a file, empty), the file's bytes are the same as accc7d2's in the 12 where `GIT_CONFIG_GLOBAL` names a file or `HOME` is set with `GIT_CONFIG_GLOBAL` unset, and differ in the 15 where `HOME` is not set or `GIT_CONFIG_GLOBAL` is empty.
- **SHOULD 2: nothing guarded the fixture's scope.** At function scope all seven tests passed (rv645's probe), and the session fixtures `scaffolded` and `family` would have been built before the setting existed. A new test, `test_the_settings_are_in_place_before_a_session_fixture_builds_a_repo`, takes a session fixture as its FIRST argument, which reads `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_COUNT` as that fixture sees them, and compares them with what the test sees and with the fixture's file. Pytest builds the higher scope first, and then an autouse fixture, so the fixture is only in place early enough if it is session scope and autouse.
- **NIT 1: the receive-pack test failed under a person's global `core.hooksPath`**, which hid the remote's own `post-receive` hook (`FileNotFoundError` on `read-by-receive-pack`). The test now sets `core.hooksPath` to the remote's own `hooks` directory in the remote's local config, which outranks a global one. Measured with `GIT_CONFIG_GLOBAL` naming a file that holds `core.hooksPath`: before, `1 failed, 4 passed, 2 skipped`; after, `9 passed, 2 skipped` (11 tests).
- **NIT 2: an empty `GIT_CONFIG_GLOBAL=` was read as unset.** Git 2.43 reads it as "no global config" (checked here: with `HOME` holding a `.gitconfig` probe, `GIT_CONFIG_GLOBAL=` does not read it). The fixture now includes nothing for an empty value: `if named is not None: included = [...] if named else []`. A new test, `test_an_empty_GIT_CONFIG_GLOBAL_is_no_global_config_and_includes_nothing`, fails on the old `if named:`.
- **NITs 3 to 5: no code change.**
  - NIT 3: `test_family_dry_run.py`'s own `NO_MAINTENANCE` is redundant now. It lives in another test file that this PR leaves alone, and it is harmless.
  - NIT 4: a `git config --global` read without `--includes` sees only the session's two keys. The only reader is `setup-project.py:1105`, behind `os.name == "nt"`, and no test reads it. `_credential_helper_configured` uses `--get-all` with no scope, so it still sees the person's config. The conftest comment already says so.
  - NIT 5: the version facts in the conftest comment ("2.47 detaches", "2.54 default geometric") are from git's source tags and are not run here: only git 2.43.0 is installed. The comment is unchanged.

**Tests** (`run9.sh`: gh stub first on PATH, identity blanked; git 2.43.0, Python 3.12, pytest 9.1.1).

- On the fix round (8cfc8eb; 979bb39 differs from it in the new test file alone, and the batches that read that file were run on it):
  - `tests/test_no_auto_maintenance.py`: 11 passed (7 from the first commit, 4 new: the `HOME` test twice, the empty value, the scope).
  - `tests/test_no_auto_maintenance.py`, `tests/test_clone_just_pushed.py`, `tests/test_repo_hygiene.py` and `tests/test_git_helper_identity.py` together, on 979bb39: 105 passed. With `tests/test_family_dry_run.py` added, on 8cfc8eb: 166 passed.
  - `tests/test_adopt_git_reads.py`, `tests/test_tracking_branch_ref.py`, `tests/test_adopt_late_failure.py`, `tests/test_adopt_github_exits.py` and `tests/test_family_add_excludes.py` (the tests that read or set a global config or a hooks path): 314 passed.
- On the first two commits (e2110f8 for the first four, accc7d2 for the rest; the second commit changes comment lines only). The fix round changes nothing where `HOME` is set and `GIT_CONFIG_GLOBAL` is not empty (the byte comparison above), so these were not run again:
  - Family, `tests/test_family*.py` (20 files): 823 passed, 19 skipped.
  - Adopt, `tests/test_adopt*.py` (46 files): 3463 passed, 2 skipped.
  - Doctor, `tests/test_shape_doctor.py` and `tests/test_doctor*.py` (12 files): 741 passed, 3 skipped.
  - Scaffold, `tests/test_scaffold*.py`: 634 passed.
  - Update-shape and bump-leg, `tests/test_update_shape*.py` and `tests/test_bump_leg*.py` (24 files): 857 passed, 4 skipped.
  - The rest of the files that copy a repository (`test_bootstrap_after_bump.py`, `test_non_list_project_fields.py`, `test_pin_source_env_collisions.py`, `test_pin_source_unreadable.py`, `test_shape_pin_files_not_a_list.py`, `test_validator_child_encoding.py`, `test_siblings_family_name.py`, `test_bootstrap_spelling.py`, `test_non_utf8_git_reads.py`), with `test_repo_hygiene.py`, `test_git_helper_identity.py`, `test_clone_just_pushed.py` and the new file: 924 passed.
  - On a merge of accc7d2 with main c6122e8: `test_no_auto_maintenance.py`, `test_git_helper_identity.py`, `test_repo_hygiene.py`, `test_clone_just_pushed.py` and `test_family_member_name.py`, 114 passed.

**Reproductions.**

- *The mechanism, on git 2.43.* A repository of 300 files plus two loose objects named `17…`, committed under `gc.auto=1`, then copied at once with `shutil.copytree`. This is what git 2.54 does to such a repository by default. With the base's settings, 10 of 10 copies failed, then 9 of 10 (`[Errno 2]` on a loose object directory or file). With the fix's two entries appended after it, 0 of 10 failed.
- *The emulation of the race* (rv645's recipe, run on 8cfc8eb: a global `gc.auto=1` that includes the person's `~/.gitconfig`, plus a `core.hooksPath` pre-commit hook that leaves two `17…` objects and drops `gc.log`, so git 2.43 repacks in the background after each commit; `GIT_TRACE2_EVENT` over the whole run). Base figures are rv645's, on a524542; head figures are the fix round's own, on 8cfc8eb:

  | file | git | auto-maintenance children | repack runs | result |
  |---|---|---|---|---|
  | `test_doctor_pin_restore_merges.py` | base | 78 `maintenance run --auto`, 77 `gc --auto` | 65 | 1 failed, 14 passed, 1 error |
  | | head | 4 `gc --auto` (from `receive-pack`, inert under `gc.auto=0`) | 0 | 16 passed |
  | `test_family_member_name.py` | base | 19 `maintenance run --auto`, 29 `gc --auto` | 15 | 13 passed |
  | | head | 14 `gc --auto` (inert) | 0 | 13 passed |

  On the base, rv645 reproduced the symptom itself: `shutil.Error ... moved/openRepoShape/.git/objects/pack/tmp_rev_...`, at `the_standard_moved`'s copy of `moved` (`tests/test_doctor_pin_restore_merges.py:102`). On head, 0 maintenance runs and 0 repacks in both files.
- *The first writer's runs on the issue's file* (`test_family_member_name.py`, base then head, 19 maintenance and 15 repacks against 0 and 0) agree.

**Mutation probes.** Each reverts one hunk of the fix in a throwaway clone; the new file then fails. First round (against the first seven tests):

| probe | fails |
|---|---|
| fixture sets nothing | 6 of 7 (all but the append test) |
| no command scope | `test_a_commit_a_fetch_and_a_merge_start_no_maintenance[a-tests-own-global]` |
| no global file | the receive-pack test, both person's-config cases |
| count overwritten, not appended | the append test |
| global file without the includes | both person's-config cases |
| the two keys before the includes | both person's-config cases |
| no `maintenance.auto` | 5 tests |
| no `gc.auto` | the receive-pack test, the append test |

Fix round (against the eleven tests):

| probe | result | what fails |
|---|---|---|
| S1, `no_auto_maintenance` at function scope | 1 failed, 10 passed | the scope test, alone (the first seven passed in rv645's run of the same mutant) |
| S2, not autouse | 4 failed, 7 passed | the two commit/fetch/merge cases, the tool test, the receive-pack test |
| H1, `home_is_known()` always true | 2 failed | `test_where_home_is_unset_the_file_is_one_git_can_read`, both variants |
| H2, the XDG include needs `HOME` | 1 failed | the same test, the `xdg` variant |
| E1, an empty value is read as unset (`if named:`) | 1 failed | the empty-value test |
| M5, the file includes nothing (the first round's probe, again) | 3 failed, 8 passed | both `test_a_persons_own_global_config...` cases, and the `xdg` variant of the `HOME` test |
| K1, the receive-pack test without its own `core.hooksPath`, under a global `core.hooksPath` | 1 failed, 8 passed, 2 skipped | the receive-pack test |

S2 is caught by tests that ask for the setting by itself; the scope test does not catch it, because earlier tests in the file build the fixture on request. K1 only fails where a global `core.hooksPath` is set, as NIT 1 says.

On the base itself the new file does not import (`cannot import name 'NO_AUTO_MAINTENANCE'`).

**Merge checks** (`git merge-tree --write-tree` on 979bb39): clean against origin/main 2fac979 and against lane 5's `fix/662-insteadof-helper` (4149cbb; #662 is stacked on accc7d2 and also edits `tests/conftest.py`: the fix round's hunks are in `session_global_config`, its helper `home_is_known`, and the comment above them). Earlier, on e2110f8 and accc7d2: clean against origin/main d4a7fab and c6122e8, #621, #622, #630, #631, #634, #635, #636, #637, #638 and #639, and the sibling branches for #646, #647, #648 and #649.

**What it leaves.**

- A git whose environment drops both scopes (a `GIT_CONFIG_COUNT` overwritten, not appended to, together with a test's own `GIT_CONFIG_GLOBAL`, or an environment built from nothing), and a push made under a test's own global file. No repository the suite copies is made or pushed to that way today.
- A git older than 2.32 ignores `GIT_CONFIG_GLOBAL`. Its `receive-pack` runs `gc --auto`, which does not detach below 6700 loose objects.
- `clone_just_pushed` keeps reading through git. A test with its own global file still needs it.
- `test_family_dry_run.py`'s own `NO_MAINTENANCE` is now redundant, and is left alone (NIT 3).
- Not run: a real Windows job (none runs the full suite since #597, and the `HOME` test is skipped on Windows), and a git newer than 2.43.

**Copies found** (`git grep -n 'shutil.copytree(' -- tests`, 152 calls):

- 127 copy a repository the suite built (non-bare, many holding `.git/modules`): `conftest.py` 1, `test_family.py` 18, `test_shape_doctor.py` 32, `test_doctor_legs_members_next.py` 8, the `test_update_shape*` files 30, and 38 more in 28 files. Four of them copy a subtree that holds no store: `standard / name` twice, and a member's work tree twice.
- 10 copy a bare remote, or a folder of them: `test_bump_leg_paste_lines.py:95`, `test_doctor_unfinished_merge.py:166`, `test_family_add_member_id.py:239`, `test_family_add_refusals.py:487`, `:788` and `:1983`, `test_family_dry_run.py:1446`, `test_family_tracking_branch.py:279`, `test_update_shape_landing.py:156` and `test_update_shape_next_quoting.py:116`.
- 2 copy a `.git` directory: `test_adopt_clone_template.py:190` and `test_scaffold_mount_ignored.py:381`.
- 13 hold no repository: a template, or this checkout with `.git` ignored.
- `test_adopt_plan.py:449` copies a clone named by `SHAPE_MEDXEHR_CLONE`. It is outside the suite and skipped without the variable.

No shipped script copies a repository.

**Checklist candidates** (rv645's; the coordinator places them):

- The import-time git probe at `tests/test_family_refuse_up_front.py:75-96` runs in a throwaway directory before any session setting. It is harmless today.
- Tests that overwrite `GIT_CONFIG_COUNT` (14 files) lose command scope and rely on the global file alone. That holds today, and nothing guards it.

Review: rv645, lane 5's independent review, found no MUST, two SHOULDs and five NITs on accc7d2; the fix rounds above take them, and its re-verification approved 979bb39. CI (Brett Heap's ruling 27): H0 979bb39 passed tests, tests-git-bash and SonarCloud; the head a9f36ce is a clean update-branch merge of main bf1f099, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 760892f over main 2000488: every test file that #706, #703 and lane 5's six round-1 PRs change, run under #713's conftest, with the two copytree-race files and the hygiene trio, 1332 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #645

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…efusals and `family-operation-in-progress` name a path as `shown_source` names it, so an ESC or a line break in a path no longer reaches the terminal (#692) (#731)

#622's folder refusals printed a path as it was. `family.py init --into <a file named wd-ESC[31mRED> --dry-run` gave `REFUSED family-folder-cannot-be-made: the family folder .../wd-^[[31mRED/Orbit cannot be made: ...` through `cat -v`. A name with a line break and then `WARNING forged-line: pwned` printed that line as a line of its own. R612's `family-operation-in-progress` printed the mark's path across two lines for a holder whose path holds a newline.

Each path is now spelled as `shape_materialize.shown_source` spells a `--pin-source`: as it is, and as Python's `repr` spells it where a character in it is not printable.

- `scripts/shape_materialize.py`: both answers of `folder_cannot_be_made` (a file or a bad stat in the way, and a folder this user cannot write in), the folder in `refuse_unmakeable_folder`, and the folder in `make_folder`.
- `scaffold-project.py`: `scaffold-target-exists`.
- `scripts/family.py`: `family-target-exists`, and the mark's path in `family-operation-in-progress` (`where.as_posix()`).

A printable path is named byte for byte, so no existing row changes and `why.endswith(CANNOT_WRITE_IN)` in `refuse_unmakeable_folder` still holds. No help text changed, so `docs/cli.md` is untouched. No README, handbook or template sentence says how a refusal names a path (`git grep` of `shown_source`, `target-exists` and `folder-cannot-be-made` in AGENTS.md, README.md, `docs/handbook.html` and the templates found none), so no doc changes.

- **`shown_source`, not `shown_path`.** Since #634, `shape_materialize.shown_source` returns `repo_shape.shown_path(source)` (`scripts/shape_materialize.py`: `return shown_path(source)`), so the seven call sites this change adds (four in `shape_materialize.py`, one in `scaffold-project.py` and two in `family.py`) spell a path as `shown_path` does. Renaming them to `shown_path` stays out of this PR: it would only move imports in `scaffold-project.py` and `family.py` and reword docstrings in `family.py`, `shape_materialize.py` and the test file, and the review's N1 left it optional. I did not take it. The predicted merge with main works with the alias: `family.py --help` and `scaffold-project.py --help` exit 0 on it, and `shown_source` and `shown_path` return the same string for an ESC name, a line-break name and a plain path.
- **`shown_source` is imported by name into each file's existing `from shape_materialize import (...)` block.** The first commit put it on a line of its own after the block; lane 4's #660, #688 and #693 add an import at that same place, and `merge-tree` reported add/add conflicts in `scripts/family.py` and `scaffold-project.py`. The second commit moves the name into the block, and those conflicts are gone.
- **"The holder in the other refusals of the same block" is one refusal.** The `# What the holder is in the middle of, and what its sparse checkout leaves out` block holds `family-operation-in-progress`, `family-sparse-excluded` and `member-path-unaddable`. Only the first prints a path of the holder's. `family-sparse-excluded` names `members/<Project>` and `.gitmodules`, and `member-path-unaddable` names `members/<Project>`.
- **No scaffold row for `--local-remote-dir`.** A control character there is refused earlier as `scaffold-unwritable-value`, which already names the path with `repr`. The unit test covers `make_folder`, which only a race reaches by the command line.

`tests/test_dry_run_parity.py`:
- `SCAFFOLD_CASES` gains five rows: ESC and line break in the `--work-dir` name, ESC in a folder name above it, and ESC and line break in a work dir that holds a file named `Atlas` (`scaffold-target-exists`).
- `FAMILY_CASES` gains five rows: ESC and line break in `--work-dir`, ESC in `--into` (`the family folder`), and ESC and line break in a work dir that holds a file named `Orbit` (`family-target-exists`).
- Two rows join `UNWRITABLE_FOLDER_AT` (ESC in the scaffold's and in `init`'s `--work-dir`, a folder this user cannot write in).
- Rows named `ESC-...` and `line-break-...` are skipped on Windows, whose file names cannot hold them (`POSIX_NAMES`, `case_params`). Every expectation spells the path with `repr`, and not with `shown_source`.
- `assert_refused` now fails any stderr line that starts with `WARNING forged-line`.
- Three unit tests: `test_each_folder_question_spells_a_name_that_is_not_printable` (ESC, line break; `folder_cannot_be_made`, `refuse_unmakeable_folder` and `make_folder` by themselves), `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`, and `test_a_printable_name_is_named_byte_for_byte` (a name with a space and an accented letter stays unquoted).

`tests/test_family_refuse_up_front.py`: `test_a_holder_path_with_a_newline_still_shows_its_merge` asserted the raw newline in the refusal (`"Ink\nRouter/.git/worktrees/" in ran.stderr`). It is now `test_a_holder_path_with_a_control_character_still_shows_its_merge`, over a line break, an ESC and a forged line, and asserts the one-line `repr` form (`refusal()` fails unless the stderr is two lines). It is the one existing test this change had to edit.

Results at head `ab55c29f` (Python 3.12.3, Linux):
- `tests/test_dry_run_parity.py tests/test_dry_run_asks_remotes.py tests/test_family_dry_run.py tests/test_family_init_landing.py tests/test_repo_hygiene.py tests/test_pin_source_unreadable.py tests/test_scaffold_leg_path_collisions.py tests/test_scaffold_reuse_seeded_legs.py`: **494 passed**.
- `tests/test_family.py tests/test_family_refuse_up_front.py tests/test_family_init_values.py tests/test_family_add_refusals.py`: **356 passed, 19 skipped** (the skips are `test_family_init_values.py:219` and `:291`, template-quoting premises).
- Not run: the full suite (the coordinator's gate), and macOS and Windows (CI is Linux-only since #597). The new hostile-name rows are POSIX only by design.

On the base code: the review overlaid this head's two test files on main `2fac9790` and ran `tests/test_dry_run_parity.py` and `tests/test_family_refuse_up_front.py` with `-k "dry_run_parity or control_character"`. The result is **20 failed, 87 passed** (76 deselected, 43.5 s). The 20 are exactly the new and edited tests: the 5 scaffold and 5 `init` rows, the 4 unwritable-folder rows, the 3 unit tests (`test_each_folder_question_spells_a_name_that_is_not_printable` in two cases, and `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`), and the 3 cases of the edited `test_a_holder_path_with_a_control_character_still_shows_its_merge`. The one new test that passes on main's code is `test_a_printable_name_is_named_byte_for_byte`, by design: a printable name is named byte for byte on both. An earlier draft of this body said 21 failed, 2 passed. That run was on `8062810d` with a wider selection, and it held a scaffold row for ESC in the local remote dir's name (`ESC-in-the-local-remote-dir-name`) that this head's tests no longer have; its second pass was the existing `test_the_refusal_quotes_gh_s_first_line_and_escapes_what_is_unprintable`, which also passes in the new run, one of the 87.

`family.py init` and `scaffold-project.py --dry-run` against a file named `wd-ESC[31mRED`, a file named `wd-\nWARNING forged-line: pwned`, and a work dir with a file `Atlas` or `Orbit` in it, run with a stub `gh` and no real `gh`:
- On main, through `cat -v`: `... the family folder /.../wd-^[[31mRED/Orbit cannot be made: /.../wd-^[[31mRED is a file, not a folder`, and a stderr line `WARNING forged-line: pwned cannot be made: ...` of its own.
- On this head: `REFUSED family-folder-cannot-be-made: the family folder '.../wd-\x1b[31mRED/Orbit' cannot be made: '.../wd-\x1b[31mRED' is a file, not a folder`, and one line for the forged name: `--work-dir '.../wd-\nWARNING forged-line: pwned' cannot be made: '...' is a file, not a folder`. `scaffold-target-exists` and `family-target-exists` are one line each in the same form.

Each spelled site reverted alone in a throwaway clone at `19d126cd` (the call sites are the same at `ab55c29f`); the new tests that fail for each:
- `folder_cannot_be_made`, the `_not_a_folder` answer: 8 failed (six CLI rows and the two unit tests).
- `folder_cannot_be_made`, the `CANNOT_WRITE_IN` answer: 5 failed (the four unwritable rows and the unit test).
- `refuse_unmakeable_folder`: 12 failed.
- `make_folder`: 2 failed (the unit test only).
- `scaffold-target-exists`: 2 failed (its two rows).
- `family-target-exists`: 2 failed (its two rows).
- `family-operation-in-progress`: 3 failed (line break, ESC, forged line).

`git merge-tree --write-tree` of this head `ab55c29f` against origin/main `20c8873b` and against every ref on origin that could meet it, fetched 2026-10-16: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. (The same list was run for #730's head `7f2bb36b`.)
- **origin/main `20c8873b`: clean** (the predicted tree `00b5b24c`, which differs from main in these five files only: 216 lines added, 25 removed).
- **#630's head `6fd33a93`: clean.** It conflicted with main in `docs/handbook.html` when this body was first drafted, and no longer does, with or without this change. This change touches no README or handbook.
- **The refs that edit one of this change's five files**, and so could meet it, all merge clean with this head: `fix/646`, `fix/651`, `fix/654`, `fix/660`, `fix/661`, `fix/684`, `fix/686`, `fix/688`, `fix/689` and `fix/693`, the heads of #670, #725, #728, #743 and #744, and the heads of the landed #634, #638, #639, #640, #643 and #671. #634 has landed (`30676c90`), so the `fix/660` note about a conflict with it is gone.
- **Four refs conflict with origin/main on their own**, and so with this head: `docs/release-notes-v1.0.0` and `release/v1.0`, and the pre-squash heads of the landed #637 and #641. Their conflicts do not come from this change. Every other ref, 92 of the 96 in all, is clean with this head, including the refs that conflict with main in `templates/family-root/scripts/validate-family.py` (#682, #745, #747, `fix/648`, `fix/681`, `fix/704`) or in `shape-doctor.py` (#742, `l3-683`) on their own. So do the old heads of the landed #638 and #639, and lane 3's `l3-677-pins-hand-step-wip` branch, and each of them merges clean with this head too.
- **Tests on the predicted merge**, run by the review on the merge with main `2fac9790`: `test_dry_run_parity`, `test_family_refuse_up_front`, `test_pinned_copy_text`, `test_pin_source_unreadable`, `test_dry_run_asks_remotes`, `test_family_dry_run` and `test_family_init_landing`: 445 passed in 215 s. `test_family`, `test_family_init_values`, `test_family_add_refusals`, `test_derived_id_refusals`, `test_repo_hygiene`, `test_naming_policy`, `test_scaffold_leg_path_collisions` and `test_scaffold_reuse_seeded_legs`: 758 passed, 19 skipped in 243 s. I did not re-run them on `20c8873b`; the merge is clean, and the two commits since `2fac9790` (#700 and #702) change only `shape-doctor.py`, `templates/assembly-root/scripts/validate-pins.py` and four test files, none of them a file of this change.
- Before the second commit, `fix/660`, `fix/688` and `fix/693` each conflicted with this head in the import region of `scripts/family.py` or `scaffold-project.py` (a separate import line each side added at the same place). That is why the import joins the existing block.

#634 is on main, and this change does not edit `shown_source` or its docstring in `scripts/shape_materialize.py`.

The review approved with nits. Its S1, the stale base-run numbers and merge lines, is taken here, in this body: no code changed, and the head is still `ab55c29f`. Its N1, renaming the call sites to `shown_path`, is not taken (see "Decisions and their evidence"). It also re-ran 5 of the 7 mutation probes on `ab55c29f` and got the same failure counts as above (12, 2, 3, 2 and 2), and ran 144 hostile-name dry runs (16 names in 9 scenarios): on main 69 rows put a control character on stderr and 6 print a forged line of their own; on this head all 144 exit 2 with no traceback, no control character and no forged line.

- `scripts/family.py` `init`'s plan prints `folder` and `landing` lines with the path raw (`print(f"landing      {holder}   ...")`, `print(f"folder       {folder}   ...")`). On a dry run or a refused run the plan is on stdout before the refusal, so `WARNING forged-line: pwned/Orbit   (--work-dir: no family folder)` still appears there. It is the plan, not a refusal, and not one of the issue's lines.
- Other refusals that name a holder or folder path raw, none of them in the issue's lines: `family-holder-detached` and `family-on-tracking-branch` (`{family.root}`), `family-index-locked` (`lock.as_posix()` in the detail), `family-landing-in-standard` (`{holder}`, `{checkout}`, `{beside / family / family}`), `family-holder-already-here` (`{into}`), and `family-remote-exists` and `scaffold-remote-exists` (`{target}`, a local remote path).

1. **family.py `init`'s plan prints the holder and folder paths raw.** `scripts/family.py` `landing` and `folder` rows (about :1756-1759) print `holder` and `folder` as they are, so a forged line or an ESC reaches stdout in a `--dry-run` and in a run refused after the plan. Direction: spell them with `shown_source`.
2. **The same raw spelling in the refusals listed above.** `family-holder-detached`, `family-on-tracking-branch`, `family-index-locked`, `family-landing-in-standard`, `family-holder-already-here`, `family-remote-exists` and `scaffold-remote-exists` each print a path the person supplied or git keeps. Direction: the spelling this change gives its sites.

Review: rv692, lane 5's independent review, approved ab55c29 with nits, and its body fix (S1) is published. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 ab55c29 passed tests, tests-git-bash and SonarCloud; the head 13f643d is a clean update-branch merge of main 431c8cf, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4327947 over main 431c8cf: the dry_run_parity, family_refuse_up_front, repo_hygiene, agents_md_codes, cli_reference sets, 330 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #692

Lane: openreposhape-5 (openRepoShape-5)
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.

scaffold-project.py and family.py init: the dry runs skip the target-exists and folder checks, and any gh repo view failure reads as "does not exist"

1 participant