Skip to content

adopt-project.py, shape-doctor.py, validate-manifest.py: execute records the plan's answers under answered_paths: and a moved submodule names its row, so an answered examples/ is not asked again and a deliberate move names no plan (#525) - #617

Merged
brettheap merged 9 commits into
mainfrom
fix/525-adopt-answers-and-submodule-rows
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-1 (openRepoShape-1)
Claim: #525 (comment)

What

After an adoption, the doctor asked again about paths the adoption plan had already answered. It also gave no way out for a submodule moved on purpose, and said nothing about the row of a submodule that had been removed. This PR closes #525, which holds two lines of #457: N7 and L1-08.

  • N7: execute now records the plan's answers to the path policy's own questions, and the doctor stops asking them.
  • L1-08: a submodule moved or removed since the adoption.
    • Moved. A submodule sits in a leg its row does not name, and the leg the row does name holds no submodule at that path. It is still MISPLACED, and the row now says it moved. Where every path the row lists is such a move, or a leg's .gitmodules that goes with it, the NEXT is the doctor itself again instead of --placement-plan, and the WHY gives both ways out: edit the row by hand in the pull request that bumps the pins, or move the submodule back. A .gitmodules goes with the move in the leg the submodule went into, and also in the leg it left when a plain git rm left that file tracked and empty there.
    • Removed. A row whose path no leg holds a submodule at is a note (detail.unheld), never a finding.
    • AGENTS.md now says that a move or removal the human decided is recorded by editing its row by hand, in that pull request, and that this is not the manifest edit rule 3 forbids.

Why (repro on main)

All three were measured on main 2247dbff with a local source, --local-remote-dir, and a stub gh first on PATH.

The setup. The source holds README.md, src/app/util.py, docs/index.md, examples/golden-run/*.json and a submodule at vendor/rules. The plan answered examples/ as spec, and vendor/ and .gitmodules as code, each with a resolution:. check exits 0, execute says adoption verified, and the recursive clone and bootstrap both exit 0. The split's project.yaml gets one submodules: row for vendor/rules and nothing about examples/.

case doctor placement row NEXT validate-manifest.py
N7: the adopted assembly COMPLIANT, exit 0 note: "1 path(s) the policy will not call: spec/examples/: ambiguous by rule ambiguous-examples" --placement-plan manifest ok
L1-08 (a): vendor/rules moved from code to spec. The code leg drops it and its emptied .gitmodules, the spec leg adds it, each by its own pull request, and bump-leg.py runs for each pin (both exit 0) MISPLACED (1 path), exit 1 FINDING: "1 submodule(s) sit in a leg other than the one project.yaml's submodules: records for them (#189): spec/vendor/rules (recorded in the code leg); 2 more path(s) need a human's reading" --placement-plan, whose WHY says "Moving one is a pull request on each leg and a pin bump in the root, so this command makes neither" manifest ok
L1-08 (b): vendor/rules removed from the code leg, then bump-leg.py COMPLIANT, exit 0 the same note as N7; nothing about the row --placement-plan manifest ok, with the row still in project.yaml and neither leg holding the path

On this branch, the same script gives:

How

  • adopt-project.py.

    • ANSWER_RECORD = "answered_paths" is the key. The doctor reads it through the module, as it reads SUBMODULE_RECORD.
    • answered_path_record(policy, entries, tree, commit) runs the policy's own walk again over the source's paths that are not gitlinks. It picks out the paths the walk leaves leg: null, and writes one row for each entry _answered_legs reads as answered spec or code that covers one of them, except .gitmodules.
    • The rows come from that walk, not from the plan's hand-edited review_required:. So an entry a person split by hand gets a row for each half.
    • cmd_execute computes the block before _create_leg_remotes, so a policy file that does not load refuses before any leg exists. _commit_the_split appends the block after the submodules: block.
    • submodule_record and the new function share one row writer, _record_block. A plan with no such entry gets "", and the manifest is byte for byte what it was.
  • templates/assembly-root/scripts/validate-manifest.py.

    • The submodules: row checker is generalised to a _Record (key, finding-code stem, wording, and whether a directory row may end in /), and both records go through it.
    • The new codes are manifest-answered-paths, manifest-answered-path, manifest-answered-leg, manifest-answered-resolution and manifest-answered-commit.
    • Two rules are new for answered_paths:. A directory's row keeps its one /. And no row may lie under another (path 'examples/golden/a.json' overlaps the row for 'examples/': a path is answered once), because check refuses a path that two entries cover.
    • The submodules: findings are word for word what they were.
    • Neither record is checked against the legs: CI runs this validator whether or not the legs were fetched.
  • shape-doctor.py, placement row.

    • recorded_answers() reads the rows through the same recorded_rows() as recorded_submodules().
    • answered_questions() settles a walked question when every tracked file under it is covered by exactly one row (using adopt-project.py's _covering) and that row names this leg. A row naming the other leg settles nothing and is never MISPLACED. .gitmodules is never settled this way. In a project with no answered_paths: it returns the questions as they are, without holding any of them against the leg's files.
    • moved_and_unheld() does two things. It marks a misplaced submodule moved when the leg its row names was read and holds no gitlink at that path. And, only when every leg was read, it lists the submodules: rows no leg holds.
    • moved_alone() decides whether every listed path is a move. That includes a leg's .gitmodules whose only unrecorded submodules moved into that leg, and the .gitmodules a plain git rm leaves tracked and empty in a leg a moved submodule left, where that leg now keeps no submodule at all. When every one is, check_placement gives the NEXT as the doctor's own line, built exactly as unreadable_next builds it for a file only a person can mend, and the WHY MOVED_BY_HAND.
    • New --json keys appear only when there is something to put in them: detail.answered_paths with counts.answered_paths, detail.unheld with counts.unheld, and moved on a misplaced entry. The new key is not An adopted assembly records nothing about which leg a human chose for a source submodule, so shape-doctor.py cannot verify a kept gitlink's placement and can only note it; adopt should write the plan's submodule answers into the assembly #189's per-leg answered. Every other report stays byte for byte what it was.
    • The rules for the other leg and for stale answered rows are in the module docstring.
  • AGENTS.md. Two 4-line passages are rewritten as 4 lines each, so the file stays at 490 of 490 lines:

    • under "Adopting", both records and "no command rewrites them";
    • in Checking point 4, that a move or removal the human decided is recorded by editing the row by hand, in the pull request that bumps the pins, which is not the edit point 3 forbids.

    README.md and docs/handbook.html are not touched.

Tests

New: tests/test_doctor_recorded_answers.py, 27 tests. It imports the doctor suite's own fixtures and helpers instead of copying them; its record_answers writes answered_paths: through record_submodules.

On main's source, 18 of the 27 fail. The other 9 pass on main by design: each guards a rule that main already keeps, and each fails under a revert below. All of them were run against main's code (the revert table's R0).

Changed: tests/test_manifest_validator.py.

Changed: one #189 test in tests/test_shape_doctor.py. test_a_submodule_in_the_other_leg_from_its_record_is_misplaced stages .cursor in the code leg with a record naming the spec leg, which holds none. That is now a move already made: the entry gains moved: True, the reason is the "moved out of" clause, and the NEXT is no plan. Its other assertions, including the placement plan it writes, are unchanged.

Changed: tests/test_doctor_next_is_a_command.py. Its CASES gains check_placement-moved, a submodule staged in the spec leg with its row naming the code leg. The property test holds its NEXT to one command and nothing else, and its WHY to MOVED_BY_HAND's opening words.

Revert table. Each change was reverted alone in a scratch clone of the branch head 42572caa (the merge below), and 131 tests were run there with git filter-repo: the new file, tests/test_manifest_validator.py, the changed #189 test and the new CASES entry. Every one of the 27 new tests fails under at least one revert from R1 to R18.

revert decision tests that fail
R0: every source file as on main all 33 (18 new doctor/adopt tests, 13 validator tests, the #189 test, the CASES entry)
R1: execute writes no answered_paths: key 3: the N7, move and removal end-to-end tests
R2: .gitmodules recorded too rows by the walk, .gitmodules left out 8: the record unit tests, the byte-for-byte test, N7 end to end
R3: every entry kept in a leg recorded (no question filter) only the policy's own questions 8 (the same 8, including the confident-rule override case)
R4: the validator reads no answered_paths: the validator's check 13 validator tests
R5: no directory rows (examples/ refused) the row shape 8
R6: no overlap check no row under another 2
R7: the doctor takes no answer N7 6
R8: a row for either leg answers the other leg stays a question 2
R9: answered_paths: answers .gitmodules .gitmodules is submodules:'s 1
R16: any covered file answers a question file by file 2
R10: a finished move keeps --placement-plan no plan for a finished move 6 (with the #189 test and the CASES entry)
R11: no move marked a move is said 7 (with the #189 test and the CASES entry)
R17: the emptied .gitmodules of the leg a move left keeps the plan a plain git rm move names no plan 1
R18: a move called where the recorded leg still holds it a move half made is not a move 1
R15: a move called from an unread leg nothing said where a leg is unread 1
R12: no row-no-leg-holds note the removal note 2
R14: unheld named with a leg unread nothing said where a leg is unread 1
R13: AGENTS.md as on main who edits a row 1

Three things have no row of their own. "A stale answer is silent" has no code to revert: its test fails only under R5, and it pins the report word for word. The confident-rule override is out of scope: its unit case fails under R2 and R3. And answered_questions returning at once where there is no answered_paths: changes no output, only the time: in a probe at 60,000 files and 3,000 questions, holding each question against every file had taken 48.8 s, where the walk itself takes about 6 s.

Runs. Every run used /usr/bin/python3 -m pytest with a stub gh first on PATH, GH_TOKEN bogus, an empty GH_CONFIG_DIR and GIT_CONFIG_GLOBAL=/dev/null. Each ran in six groups per mode, and every log names its head, has dirty 0 and ends EXIT=0. The file set is every file that runs or imports adopt-project.py, shape-doctor.py, bump-leg.py or validate-manifest.py, plus tests/test_shape_doctor.py, tests/test_scaffold_leg_path_collisions.py, tests/test_repo_hygiene.py and the new file.

At the branch head, 42572caa. It is the merge of main 3f768a66 into the review round below. The merge was clean, with no conflicted path: git auto-merged AGENTS.md and adopt-project.py, and also shape-doctor.py, tests/test_doctor_next_is_a_command.py and tests/test_shape_doctor.py, which #555 had rewritten. Its tree is the one git merge-tree predicted, this branch's own changed lines against main are the same 1,289 they were against its base, and AGENTS.md stays at 490 of 490 lines. 110 files:

mode passed skipped failed
git filter-repo on PATH 7,665 17 0
hidden (PATH=/usr/local/bin:/usr/bin:/bin, stub first) 7,347 335 0

45d080ae changes only three comments, so that they cite #525 rather than a reviewer's working name, and tests/test_doctor_recorded_answers.py and tests/test_shape_doctor.py pass there: 427 passed, 3 skipped, 0 failed.

At b640d34e, the review round before the merge, 105 files: 7,308 passed and 17 skipped with git filter-repo; 6,991 passed and 334 skipped hidden; none failed.

Windows

CI runs on Linux only since #597, so nothing here runs on Windows. The tests still keep to what Windows needs, which costs nothing:

  • No test counts "\n" in bytes a helper wrote. Manifests and records are written as bytes.
  • Every path the tests create is legal on NTFS.

The new file, tests/test_manifest_validator.py, the changed #189 test and the new CASES entry were also run at 42572caa under a Windows newline emulation: a pytest plugin, kept outside the repository, that makes text-mode Path.write_text and text-mode stdin write \r\n. 131 passed, EXIT=0.

Not in this PR

Known limits

  • Older projects. A project adopted before this PR has no answered_paths:, and its doctor asks as before. Nothing here says how to answer an old project's questions in its manifest.
  • The wording of a move's .gitmodules. In the move case, a .gitmodules that goes with the move (the spec leg's, or the empty one a plain git rm leaves in the code leg) is still counted among the "more path(s) need a human's reading" until the row is edited, although it does not change the NEXT.
  • The empty .gitmodules a plain git rm leaves. Once such a move is finished by editing the row, that empty file is still the policy's question in the leg the submodule left: a note, with --placement-plan as the NEXT, until it is deleted there. No record answers an empty .gitmodules, and main asks it the same way, so it is noted for Tracking: adopt-project.py, converting an existing repository in place #457.
  • Each project's copy of validate-manifest.py. It reads upstream-changed to update-shape.py check until apply is run. That is how an upstream validator fix reaches a project.

This PR answers two lines of #457, N7 and L1-08, and #457 itself stays open.

Closes #525

🤖 Generated with Claude Code

Summary by Sourcery

Persist adoption answers and improve doctor handling of intentionally moved or removed submodules.

New Features:

  • Record adoption answers to policy questions in answered_paths: so the doctor does not ask them again.
  • Report intentionally moved or removed submodules with actionable placement guidance.

Bug Fixes:

  • Prevent previously answered ambiguous paths from being reported as unresolved after adoption.
  • Distinguish completed submodule moves from ordinary misplaced submodules and surface removed submodule records as notes.

Enhancements:

  • Extend manifest validation to cover answered_paths: records, including directory paths and overlapping-row checks.
  • Update doctor JSON and placement output with answered-path, moved, and unheld details.
  • Clarify ownership and hand-edit procedures for changed submodule records in AGENTS.md.

Tests:

  • Add comprehensive unit and end-to-end coverage for recorded answers, moved and removed submodules, manifest validation, and doctor command guidance.

Brett Heap and others added 4 commits October 15, 2026 11:52
…cords the plan's answer to each question the path policy asked under `answered_paths:`, the doctor takes it as the answer in that leg, and a submodule moved since the adoption names its row instead of a plan, so an answered `examples/` is no longer asked again, a deliberate move no longer points at `--placement-plan`, and a removed submodule's row no longer goes unsaid (#525)

N7 (#457): `execute` recorded the plan's answers about submodules only
(#189), so the doctor's `placement` row asked `spec/examples/` (rule
`ambiguous-examples`) again on every run after an adoption that had
answered it. `execute` now appends `answered_paths:` after `submodules:`:
one row per plan entry answered `spec` or `code` that covers a path the
policy's own walk, re-run over the source at the plan's commit, leaves
`leg: null`, with its path in the leg, `leg`, `resolution:` and
`source_commit`. `.gitmodules` is left to `submodules:`. A plan that
answered no such question into a leg appends nothing, so its manifest is
byte for byte what it was. The doctor settles a question whose every file
one row answers into the leg it is in; a row naming the other leg settles
nothing and asserts nothing, and a row no leg holds is silent. The
project's `validate-manifest.py` checks the rows with the `submodules:`
row checker, generalised by key and finding-code prefix: a directory's
row keeps its one `/`, and no row lies under another.

L1-08 (#457): nothing said how a `submodules:` row changes. A submodule in
a leg its row does not name, where the leg the row names was read and
holds no gitlink there, is still MISPLACED and now says it moved; where
every listed path is such a move, or the `.gitmodules` of the leg it moved
into, the NEXT is this doctor again, as `unreadable_next` gives for a file
only a person can mend, and the WHY gives both ways out: the row edited by
hand in the pull request that bumps the pins (`leg:`, `resolution:`,
`source_commit:` deleted), or the move undone. A row whose path no leg
holds a submodule at, every leg read, is a `note` (`detail.unheld`), never
a finding. AGENTS.md says who edits a row and when, line for line within
its 490-line cap. #189's test of a submodule against its record in
tests/test_shape_doctor.py, whose recorded leg holds no such submodule,
now reads that case as the move it is.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tracked in the leg the submodule left names its row and not a plan, a project with no `answered_paths:` no longer holds each question against every file, and a row no leg holds names the pull request that bumps the pins (#525)

Review r525 on 3b78fae.

F1: `git rm` of a leg's only submodule leaves `.gitmodules` tracked and
0 bytes long, and that file is the policy's question. `moved_alone` let a
`.gitmodules` go with the move only in the leg the submodule moved INTO,
so the most natural move still named `--placement-plan`. It now also lets
it go in a leg a moved submodule LEFT, where that leg keeps no submodule
at all. Test first: a third case of
`test_a_submodule_moved_in_both_legs_names_its_row_not_a_plan` stages the
emptied file in the code leg; it failed at 3b78fae on the NEXT.

F2: `answered_questions` returns the questions as they are when there is
no `answered_paths:`, its first statement, instead of holding each one
against every file of its leg.

F3: the unheld clause says the row is deleted in the pull request that
bumps the pins, where `project.yaml` is, as AGENTS.md and `MOVED_BY_HAND`
say.

F4: the keep-the-plan test asserts #189's own MISPLACED clause word for
word where the submodule did not move, and its absence where it did; and
tests/test_doctor_next_is_a_command.py's CASES has the moved NEXT.

F5: adopt-project.py spells the manifest `SHAPE_MANIFEST` in the new line.

F6: the new `--json` key is `answered_paths` (under `detail` and
`counts`), apart from the per-leg `answered` of #189.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lean merge after #555 and #597

A merge of main 3f768a6. The brief named
654c476 (#597); main has since moved one commit, to siblings.py and
family.py, which touches AGENTS.md among this branch's files.

No hand resolution: git auto-merged AGENTS.md, adopt-project.py,
shape-doctor.py, tests/test_doctor_next_is_a_command.py and
tests/test_shape_doctor.py, and no path was conflicted, although #555
(2bf8fd4) rewrote shape-doctor.py and both of those tests.

- The merge's tree, 6ad6724, is the tree
  `git merge-tree --write-tree b640d34 origin/main` prints.
- This branch's own changed lines against main are the same 1,289 `+`/`-`
  lines they were against its base 2247dbf, in the same 8 files.
- #555's `placement_policy` refusal (no NEXT, its remedy in the `why`) is
  as main has it; this branch's moved NEXT in `check_placement` stays a
  command, the doctor again, as `unreadable_next`'s is.
- AGENTS.md stays at 490 of its 490 lines.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r's working name (#525)

Lane: openreposhape-1 (openRepoShape-1)
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 9 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 records adoption answers to policy-owned path questions in project.yaml, teaches the doctor to honor those answers only in the named leg, and adds explicit handling for intentional submodule moves and removals, backed by shared validation, documentation, and broad regression coverage.

Sequence diagram for recording adoption answers during execute

sequenceDiagram
    participant User
    participant Execute as adopt-project.py execute
    participant Policy as PathPolicy
    participant Manifest as project.yaml

    User->>Execute: cmd_execute()
    Execute->>Policy: PathPolicy.load(PATH_POLICY)
    Execute->>Policy: walk(source paths)
    Policy-->>Execute: policy questions with leg: null
    Execute->>Execute: answered_path_record(entries, tree, commit)
    Execute->>Manifest: append submodules: and answered_paths:
    Execute-->>User: adoption verified
Loading

Sequence diagram for doctoring recorded answers and submodule placement

sequenceDiagram
    participant Doctor as shape-doctor.py
    participant Manifest as project.yaml
    participant Policy as PathPolicy
    participant Legs as Adopted legs

    Doctor->>Manifest: recorded_submodules()
    Doctor->>Manifest: recorded_answers()
    Doctor->>Legs: audit_leg(spec and code)
    Legs-->>Doctor: tracked files, gitlinks, .gitmodules
    Doctor->>Policy: walk(each leg)
    Policy-->>Doctor: placement questions
    Doctor->>Doctor: answered_questions()
    Doctor->>Doctor: moved_and_unheld()
    alt answered_paths row names this leg
        Doctor-->>Doctor: mark question answered
    else recorded submodule moved or is unheld
        Doctor-->>Doctor: mark moved or emit removal note
    end
    Doctor-->>Doctor: placement verdict and next command
Loading

Entity relationship diagram for adoption manifest records

erDiagram
    PROJECT_MANIFEST ||--o{ SUBMODULE_RECORD : contains
    PROJECT_MANIFEST ||--o{ ANSWERED_PATH_RECORD : contains

    PROJECT_MANIFEST {
        yaml submodules
        yaml answered_paths
    }
    SUBMODULE_RECORD {
        string path
        string leg
        string resolution
        string source_commit
    }
    ANSWERED_PATH_RECORD {
        string path
        string leg
        string resolution
        string source_commit
    }
Loading

Flow diagram for intentional submodule move handling

flowchart TD
    A[Doctor audits both legs] --> B{Submodule differs from recorded leg?}
    B -->|No| C[Keep existing placement result]
    B -->|Yes| D{Recorded leg was read and holds no gitlink?}
    D -->|No| E[Report ordinary MISPLACED result]
    D -->|Yes| F[Mark submodule moved]
    F --> G{Every misplaced path is a completed move?}
    G -->|Yes| H[Next: run shape-doctor.py again]
    H --> I[Human edits submodules row or moves it back]
    G -->|No| J[Next: --placement-plan]
Loading

File-Level Changes

Change Details Files
Persist adoption-plan answers for policy questions in the manifest and use them to suppress duplicate doctor questions.
  • Add answered_paths: rows derived from the policy walk, excluding gitlinks and .gitmodules, while preserving the existing manifest when no such answers exist.
  • Refactor manifest record writing and validation to share row handling, including directory paths, overlap detection, field validation, and dedicated findings.
  • Teach the doctor to resolve questions file-by-file only when rows name the current leg, and expose answered paths in human and JSON reports.
adopt-project.py
shape-doctor.py
templates/assembly-root/scripts/validate-manifest.py
AGENTS.md
tests/test_doctor_recorded_answers.py
tests/test_manifest_validator.py
Make intentional submodule moves and removals explicit without generating an obsolete placement plan.
  • Mark a misplaced submodule as moved when its recorded leg no longer holds it and the destination leg does.
  • Use the doctor itself as the next action for completed moves, with guidance to edit the row by hand or move the submodule back.
  • Report rows for removed submodules as compliant notes with detail.unheld, while avoiding conclusions when legs are unreadable.
  • Document the required human row edits and preserve prior placement behavior for incomplete or unrelated moves.
shape-doctor.py
AGENTS.md
tests/test_doctor_recorded_answers.py
tests/test_shape_doctor.py
tests/test_doctor_next_is_a_command.py
Expand regression coverage across unit, validator, doctor, and end-to-end adoption scenarios.
  • Cover answered directories, split answers, wrong-leg answers, .gitmodules exclusion, stale rows, moves, removals, unreadable legs, and manifest preservation.
  • Validate accepted and rejected answered_paths: schemas, including malformed paths, overlaps, invalid legs, resolutions, commits, and non-list records.
  • Add command-property coverage for the move-specific NEXT and verify end-to-end manifest, doctor, and validator outcomes.
tests/test_doctor_recorded_answers.py
tests/test_manifest_validator.py
tests/test_shape_doctor.py
tests/test_doctor_next_is_a_command.py

Assessment against linked issues

Issue Objective Addressed Explanation
#525 Record adoption-plan answers to paths the policy left undecided in project.yaml, and have the doctor treat those answers as resolved in the specified leg rather than asking again. ✅
#525 Make deliberate submodule moves actionable by identifying completed moves, explaining how to update the recorded row or move the submodule back, and documenting that the row is edited by hand in the pin-bumping pull request. ✅
#525 Report rows for removed submodules as informational notes, not findings, and validate the new answered_paths: records. ✅

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 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>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 59e5a07 into main Oct 16, 2026
4 checks passed
@brettheap
brettheap deleted the fix/525-adopt-answers-and-submodule-rows branch October 16, 2026 01:15
brettheap pushed a commit that referenced this pull request Oct 16, 2026
main gained #583 (#520), #596 (#585), #614 (#527), #619 (#530), #594
(#526), #617 (#525) and #618 (#418). Three files conflicted:

- .github/workflows/tests.yml: main's file (with #596's tests-git-bash
  job) plus this branch's three additions, byte-identical to theirs: the
  header paragraph on the `package` job (#590), after main's Git Bash
  paragraph; #599's actionlint line for release.yml; and #590's `package`
  job, now after tests-git-bash. actionlint 1.7.12 finds nothing in it.
- README.md, the native Windows block's closing paragraph: #596 says the
  two-liner IS the Windows way in from PowerShell, with no twin of the
  `openRepoShape` command, and that Git Bash runs that command and the one
  line, tested in CI but for a typed `yes`; this branch says the two-liner
  is the way in that installs nothing and pipx's `openRepoShape` runs the
  same flow. The merge keeps both facts in main's seven lines: the two-liner
  installs nothing, pipx's command runs the same flow, and Git Bash runs
  step 3's bash command and its one line, tested in CI but for a typed `yes`.
- docs/handbook.html: the same two sentences in the masthead's second-way
  paragraph and the Windows panel, each combined the same way, and the
  seven `Starting a project in a new organisation` markers, which now read
  the merged README (62737be2). No other section's marker moves.

README.md is 1477 lines, its cap (1449 on main, +28 for #563).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…ests, 57 of 82 milestone issues closed), with #583, #596, #614, #619, #594, #617 and #618 in their areas, #596's Git Bash facts for its placeholders, release.yml's step 1 whole, and every pull request cited beside its issue (RV630 SHOULD 3, 5, 6, nits 7, 8)

- SHOULD 5: step 1 matches release.yml's header: "always on a
  `release/v<X.Y>` branch, which no push run tests", "A `release/v<X.Y>`
  branch serves while it is still at that commit", and that a tag's
  dispatch running at the tag's commit is GitHub's documented behaviour,
  not yet seen here.
- SHOULD 6: #598 beside #595, #592 beside #593, #460 on #557's line, and
  #589's line says "where that clone is still on the machine and is no
  partial clone" again. The counts and the open-issue list are re-derived
  from `git log --first-parent bfed143..origin/main` (49) and the
  milestone (25 open, 57 closed: #342, #392, #393, #421, #423, #426,
  #432, #433, #434, #443, #444, #454, #455 and #629 joined it). New lines
  under adopt-project.py for #583 (#520, #420), #619 (#530), #618 (#418),
  #614 (#527), #594 (#526) and #617 (#525); #596 (#585) under the one-line
  install, CI, native Windows, the docs and the known limits, where the
  two placeholders for it were.
- SHOULD 3: the known limit about "the installed doctor's temporary
  clone" is gone; the README, the handbook and AGENTS.md no longer say it.
- SHOULD 1: the filter-repo line says install.sh's order, apt and dnf
  included.
- nit 7: the release process's preconditions are said as the process
  this release follows; the workflow checks the environments, and nothing
  checks the rehearsal.
- nit 8: the known limits are wrapped.
- "the first on native Windows, where the one line cannot run" says
  PowerShell cannot run it, since Git Bash does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
Main moved to 4cf5fb0 since 8f453d0: #617 changed validate-manifest.py
(the answered_paths records and the moved-submodule rows) and #618 and others
changed adopt-project.py. The merge is clean, with no resolution, and the
tests ran on the committed merge.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
…recorded and an empty `.gitmodules` asks nothing, so `docs/` answered `code` is not MISPLACED after `adoption verified`, nor a `.gitmodules` emptied by `git rm` asked on every run (#655) (#721)

A plan that answered `docs/` (rule `spec-governance`, spec) as `code` passed `check` and ended `adoption verified`. Then the doctor's first run said MISPLACED for `code/docs/`, because `execute` recorded only the paths the policy asked about (#617). And a `.gitmodules` that `git rm` of a leg's only submodule left at 0 bytes was asked about as `ambiguous-gitmodules` on every run of the doctor. This is #655: #457's L1-56 and L1-57, with the riders L1-58 and L1-59.

Now:
- **L1-57.** `execute` writes an `answered_paths:` row for each plan entry kept in a leg that covers a path the policy's walk puts elsewhere or leaves unasked. This is decision (A) of lane 1's triage, approved by Brett Heap on 2026-10-16T11:21:17Z. An entry answered as its rule says gets no row. Nor does one left as `plan` wrote it, where a gitlink changed how `plan` folded its directory. Nor does one answered `root` or `drop`. The doctor holds a walked path in the wrong leg to the record file by file, as #617 holds a question, and names an answered path in a clause of its own.
- **L1-56.** The doctor no longer asks about a `.gitmodules` that holds no byte. It names the file under `empty_gitmodules`. A `.gitmodules` holding anything, even one blank line, is still asked about.
- **L1-58.** A directory's row without its `/` still answers nothing under the directory. The doctor now says so, under `unslashed`. It is never a finding.
- **L1-59.** The comment `execute` writes above the block, and `validate-manifest.py`'s docstring, now say that a directory's row answers for every file under it, including one added after the adoption.
- **AGENTS.md:** its one line on the record says the same, and it stays at 492 of 492 lines.

Tests:
- tests/test_doctor_plan_overrides.py is new, with 22 tests. 12 fail on main, and 10 guard rules main already keeps.
- tests/test_doctor_recorded_answers.py: #617's `a-confident-rule-overridden` case now expects `docs/` recorded.
- Each change reverted alone fails at least one test. The revert table is in the PR.

Review: the adversarial review found one blocking fix, F1: an entry left as `plan` wrote it beside a gitlink fold must get no row. The fix round made it, with a test. The re-verification found the PR READY with no text replacement. Its edge cases outside #655 go to #457's checklist.

GitHub's update-branch merged main into the branch once, a clean merge, and the branch was never rebased. It lands under the fast-refresh ruling of 2026-10-15T22:48:35Z.
- The earlier head 0e37d02 is green on Linux, Git Bash and SonarCloud.
- Every commit after it is a clean merge of main.
- The head 70d8180 is green on SonarCloud and Git Bash.
- The full suite passed on its tree. Lane 1's gate ran it on that tree, predicted before main reached it, with 194 test files: 11,312 passed and 36 skipped.

Closes #655

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

adopt-project.py: record the plan's answers in project.yaml, so the doctor's placement row and the submodules: rows follow them

1 participant