Skip to content

bootstrap.py: a leg detached off the pin is left where it is and said, so a commit made there is no longer stranded when its branch is already at the pin, the not moving an existing branch line names where the checkout is, and bump-leg.py and AGENTS-shape.md say "or says why not" (#533) - #556

Merged
brettheap merged 5 commits into
mainfrom
fix/bootstrap-detached-leg
Oct 15, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #533 (comment)

R556's review of 35ab229 (one MUST, two SHOULDs, N1 and N2) is addressed in 8885a29, described below. N3 is left as it was; N5 (templates/family-root/AGENTS-shape.md:63) goes on #458's checklist, not this PR. R556's NV1 and NV2 (on 8885a29) are folded in f9b49bf: tests for the create path's twins of the held-commit cases, and the create guard needs a HEAD it can read.

What changed and why

  • place_at_pin reads HEAD first. Step (a) for a leg whose tracking branch IS the pin checked the branch out with no look at HEAD. bump-leg.py --to a branch's own tip, then a commit made on the leg while it is detached there, then make bootstrap: it checked main out and printed on main at <pin> (branch tip == pin), and the commit was on no branch, held by HEAD's reflog alone, with nothing said. HEAD detached at a commit other than the pin is now refused with the two "which is neither" lines fast_forward_to_pin already prints (one shared helper, _say_neither, byte for byte for the behind branch), and nothing moves. HEAD detached AT the pin, or already on the branch, is as before. HEAD on ANOTHER branch is also left as before (that branch holds its commits; fast_forward_to_pin refuses that state for a branch behind the pin, a difference for Tracking: update-shape.py and bump-leg.py, keeping a project's shape copies and leg pins current #458's checklist, not changed here).
  • Behaviour change, now tested and named. A leg detached at ANY commit other than the pin is now left where it is, even one its branch or origin/main holds: an ancestor of the pin, or the tip git submodule update --remote leaves, past the pin. Bootstrap used to check the branch out from both, losing nothing. fast_forward_to_pin already left the same checkout alone for a branch behind the pin, so the rule now has no exception to explain.
  • The create path has the same guard (R556 SHOULD-1). With the tracking branch missing, checkout -b <branch> <pin> ran from a leg detached at a later commit and printed branch <branch> created at <pin>, leaving the commit on no branch. Detached at a commit that is not the pin, no branch is made and HEAD does not move; the line says the branch does not exist, where the checkout is, and to check out the pin (the branch is not there to check out). Detached AT the pin the branch is created, as on a fresh clone. A leg whose gitdir is gone has no HEAD git can read, so the guard does not fire and the line is main's own, could not create branch 'main' at the pin <pin12>; leaving detached, not a claim that the checkout is at ?. The module docstring now says the rule for every state of the branch.
  • Ride-along in _say_after, reworded (R556 MUST-1). HEAD already on the branch at the pin and git exits non-zero (a stale .git/index.lock, a failing post-checkout hook, an unmerged index): nothing was checked out, so checked out, and then git said: was false. It now prints nothing to check out, and then git said:, then git's lines as before, true of every cause. It never says "refused": a failing hook is not a refusal (35ab229 said git refused a checkout that would have changed nothing:, false for a hook). A post-checkout hook that fails after a real move keeps checked out, and then git said:.
  • The not moving an existing branch line keeps that phrase (the scaffold e2e test and shape-doctor.py's comment cite it) and names where the checkout stays: on <branch> at its tip, at the pin, or at <sha> (detached) / at <sha> (on <other branch>). It said The checkout stays at the pin over a HEAD on the branch itself.
  • bump-leg.py:1514-1515 and AGENTS-shape.md promised placement unconditionally. bump-leg now says what AGENTS.md:355-356 and README.md:993-994 say since README.md and AGENTS.md: 28 statements the v1.0.0 accuracy pass found false are corrected to what the tools do on main (#483) #505 (placed AT the new pin, or says why not). AGENTS-shape.md :86-87 reads "puts the legs on their tracking branch AT the pin, or says why not", and :95-96 states the exact rule: placed AT the pin where that loses nothing, and a leg detached at any other commit is left, saying why. The file keeps its 162 lines.
  • Not touched (by the brief): README.md, docs/handbook.html, AGENTS.md, shape-doctor.py. The doctor's docstrings at :3448-3449 and :3524-3526 and tests/test_doctor_legs_members_next.py:9-10 still say bootstrap "never moves an existing branch whose tip is not the pin"; they belong to shape-doctor.py's legs and members NEXT after #354 moves a detached HEAD that holds the only copy of a commit, with no reflog note, in three states: an emptied leg mount whose kept gitdir holds the commit, a holder whose own scripts/bootstrap.py is the NEXT (any commit made in the pinned members/<P>), and a commit in a member's own leg that --recursive moves, so after the paste the commit is held by a reflog entry alone #413. Other places that still say placement without a qualifier, outside this issue's list: AGENTS.md:103-104, docs/cli.md:47 (generated from scripts/render-cli-reference.py:182), scaffold-project.py:1527, templates/family-root/AGENTS-shape.md:63.

Tests

tests/test_bootstrap_after_bump.py goes from 23 to 38 tests. New: the issue's case, its control, HEAD on another branch, the stale-lock ride-along, a failing hook after a real move and one over a leg already on its branch (the new line, never "refused"), a leg detached at an ancestor of the pin and at origin/main past it, a missing tracking branch over a detached commit (and created when detached at the pin), the same with the leg detached at an ancestor of the pin and at origin/main past it, a leg whose gitdir is gone, the not moving line for a checkout detached off the pin and on another branch, and one bump-leg run carrying the new sentence. not_moving() takes where the checkout stays, bumped is split into advance and bump_spec, and the failing hook is one helper.

  • pytest tests/test_bootstrap_after_bump.py tests/test_bootstrap_spelling.py tests/test_scaffold_e2e.py tests/test_bump_leg.py tests/test_bump_leg_dry_run_tracking.py tests/test_update_shape_add.py tests/test_repo_hygiene.py tests/test_cli_reference.py tests/test_agents_md_codes.py: 491 passed, 1 skipped (a case-insensitive-disk test).
  • pytest tests/test_doctor_legs_members_next.py tests/test_bump_leg_rollback.py tests/test_template_text_placeholders.py: 228 passed.
  • pytest tests/test_family.py tests/test_adopt_e2e.py tests/test_scaffold_mount_ignored.py tests/test_park_resume_targets.py tests/test_family_makefile_siblings.py (bootstrap runs in their legs): 249 passed.
  • Mutants, each run over tests/test_bootstrap_after_bump.py, each killed:
    • HEAD check removed from place_at_pin (M1): the issue's case and both held-commit cases fail.
    • _stays without the on-the-branch case (M4): test_a_branch_ahead_of_the_pin_is_not_moved and the HEAD on main case fail.
    • An ancestor of the pin placed again, and not _is_ancestor(sub, head, pin) (MR1): the ancestor of the pin case fails.
    • A HEAD at origin/main placed again (MR2): both held-commit cases fail.
    • git refused a checkout that would have changed nothing: back in _say_after, and checked out, and then git said: back for a no-op (G1, G1b): the stale-lock test and the failing-hook test fail.
    • The create guard disabled (G2): the three create-path tests fail.
    • The create guard with and not _is_ancestor(sub, head, pin) (MV4): the ancestor of the pin create-path case fails. With origin/main let through (MV5), both create-path cases fail. Without head is not None (NV2), the gitdir-gone test fails.

CI's three OSes are the gate; no full suite was run locally.

Shared files

bump-leg.py:1514-1515 sits next to #532's :1516 (the NEXT git -C {root} push line), which is authored after this PR lands; P7 (#534) edits bump-leg.py elsewhere.

A pinned-copy change: every project takes it as one upstream-changed re-pin of scripts/bootstrap.py and AGENTS-shape.md

v1.1.0: lands only after the v1.0.0 tag

Closes #533

🤖 Generated with Claude Code

Summary by Sourcery

Keep detached work safe during bootstrap by leaving legs off the pin in place and clearly explaining when placement is skipped.

Bug Fixes:

  • Preserve detached legs at any commit other than the pin, including commits made after a branch bump, instead of checking out the tracking branch and stranding those commits.
  • Apply the same preservation guard when creating a missing tracking branch.
  • Report checkout failures accurately when no checkout occurred, while retaining the existing wording for failures after a real move.

Enhancements:

  • Make bootstrap diagnostics identify the checkout's actual location when it declines to move an existing branch.
  • Update bootstrap guidance to state that legs are placed at the pin where safe or explain why they remain unchanged.

Documentation:

  • Clarify the bootstrap behavior in the shape guidance and bump-leg output.

Tests:

  • Expand bootstrap coverage for detached commits, missing branches, checkout failures, diagnostic wording, and the updated bump-leg message.

…, so a commit made there is no longer stranded when its branch is already at the pin, the `not moving an existing branch` line names where the checkout is, and bump-leg.py and AGENTS-shape.md say "or says why not" (#533)

`place_at_pin`, step (a) for a leg whose tracking branch IS the pin, checked
the branch out without looking at where HEAD was. `bump-leg.py --to` a
branch's own tip leaves the leg detached at the branch, and a commit made
there is on no branch; the next `make bootstrap` checked `main` out and
printed `on main at <pin> (branch tip == pin)`, and the commit was held by
HEAD's reflog alone with nothing said. `fast_forward_to_pin` refuses the same
state, "which is neither", and so does every other branch of step (a).

`place_at_pin` now reads `_head_of(sub)` first. HEAD detached at a commit
other than the pin is refused with the same two lines `fast_forward_to_pin`
prints (now one helper, `_say_neither`, byte for byte for the behind
branch) and nothing moves. Detached AT the pin, or already on the branch, is
as it was. On ANOTHER branch is as it was too: that branch holds its commits,
and `fast_forward_to_pin` refusing it is a difference left to #458's
checklist, not changed here.

Ride-along: `_say_after` said `checked out, and then git said:` after a
checkout git refused while HEAD was already on the branch at the pin (a stale
`.git/index.lock`), so nothing was checked out. That path now says `git
refused a checkout that would have changed nothing:` and prints git's lines
as before, because the same lock blocks the person's next commit. A
post-checkout hook that fails after a real move keeps the old wording. A hook
that fails on a checkout that moved nothing gets the new line too: the exit
status is all bootstrap can read.

The `not moving an existing branch` line, whose phrase stays (the scaffold
e2e test and shape-doctor.py cite it), said "The checkout stays at the pin"
over a HEAD on the branch itself. `_stays` names where the checkout is: `on
<branch> at its tip`, `at the pin`, or `at <sha> (detached)` / `at <sha> (on
<other branch>)`, the place read as `fast_forward_to_pin` builds it.

bump-leg.py printed "`make bootstrap` in the root re-places it on its
tracking branch AT the new pin" after every bump, and AGENTS-shape.md
promised placement "AT the pinned commit ... not a detached HEAD" in two
places. All three now say what AGENTS.md:355-356 and README.md:993-994 say
since #505: the leg is put on its tracking branch AT the pin where that loses
nothing, and otherwise it is left where it is and bootstrap says why.
README.md, docs/handbook.html, AGENTS.md and shape-doctor.py are not touched;
the doctor's two docstrings that still say bootstrap "never moves an existing
branch whose tip is not the pin" belong to #413.

bootstrap.py and AGENTS-shape.md are pinned copies: every project takes this
as one `upstream-changed` re-pin of both.

tests/test_bootstrap_after_bump.py gains the issue's case, its control, HEAD
on another branch, the ride-along (with a failing hook after a real move as
the counter-case), the `not moving` line for each place the checkout can be,
and one bump-leg run carrying the new sentence; `not_moving()` takes where the
checkout stays, and `bumped` is split into `advance` and `bump_spec` so the
bump's output can be read.

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

sourcery-ai Bot commented Oct 14, 2026

Copy link
Copy Markdown

Reviewer's Guide

Bootstrap now avoids stranding commits made on a detached leg by refusing unsafe placement, accurately reports checkout state and failures, and updates documentation and regression tests to describe and enforce the conditional behavior.

Sequence diagram for safe detached-leg bootstrap

sequenceDiagram
    participant Bootstrap
    participant Leg
    participant Git
    Bootstrap->>Leg: _head_of()
    Leg-->>Bootstrap: detached HEAD at commit other than pin
    Bootstrap->>Bootstrap: _say_neither()
    Bootstrap-->>Leg: Leave checkout unchanged
    Note over Bootstrap,Leg: A commit made on the detached leg is not stranded
Loading

Flow diagram for safe bootstrap placement

flowchart TD
    A[Bootstrap inspects HEAD] --> B{Detached and HEAD differs from pin?}
    B -->|Yes| C[Leave checkout unchanged]
    C --> D[Report location and why not]
    B -->|No| E[Attempt tracking-branch checkout]
    E --> F{Checkout changed HEAD?}
    F -->|Yes| G[Report branch at pin]
    F -->|No| H[Report checkout refused with no movement]
    D --> I[Commit remains reachable from its existing location]
Loading

Flow diagram for accurate checkout-state reporting

flowchart TD
    A[Determine checkout state] --> B{Branch tip differs from pin?}
    B -->|Yes| C[Do not move existing branch]
    C --> D["_stays() names current location"]
    D --> E[Report branch tip, pin, or SHA and branch state]
    B -->|No| F[Report placement at pin]
    F --> G{Checkout moved?}
    G -->|Yes| H["_say_after(why, moved=True)"]
    G -->|No| I["_say_after(why, moved=False)"]
    I --> J[Report checkout refused without movement]
Loading

File-Level Changes

Change Details Files
Preserve detached work during bootstrap and centralize explanatory refusal output.
  • Read HEAD before placing a branch that is already at the pin.
  • Leave detached HEADs away from the pin untouched and report their location using shared messaging.
  • Reuse the same location-aware refusal helper for behind branches.
templates/assembly-root/scripts/bootstrap.py
Make checkout diagnostics accurately describe whether a move occurred and where the checkout remains.
  • Distinguish a refused no-op checkout from a checkout followed by a failing hook.
  • Report locations as on the branch, at the pin, or detached/on another branch.
  • Update the not-moving message to reflect the actual checkout location.
templates/assembly-root/scripts/bootstrap.py
Align user-facing documentation with conditional bootstrap placement behavior.
  • Explain that bootstrap places a leg at its pin when safe and otherwise leaves it in place while explaining why.
  • Update bump-leg output to use the same conditional promise.
templates/assembly-root/AGENTS-shape.md
scripts/bump-leg.py
Expand regression coverage for detached commits, checkout failures, diagnostics, and messaging.
  • Add issue-case and control tests for detached HEAD at the branch tip.
  • Cover HEAD on another branches, stale index locks, and failing post-checkout hooks.
  • Verify location-aware not-moving output and the revised bump-leg sentence.
tests/test_bootstrap_after_bump.py

Assessment against linked issues

Issue Objective Addressed Explanation
#533 Prevent bootstrap from stranding a commit made while a leg is detached at a commit other than its pin when the tracking branch is already at the pin; leave the leg in place and explain why. ✅
#533 Make bootstrap’s messages accurately identify where the checkout remains when it does not move an existing branch, and accurately describe a refused checkout that would have changed nothing. ✅
#533 Update the shipped AGENTS-shape.md template and bump-leg.py output so they no longer unconditionally promise that bootstrap places every leg on its tracking branch. ✅

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

Brett Heap and others added 2 commits October 14, 2026 17:55
…created at the pin over its commit, a non-zero exit over a checkout that had nothing to do says so and never "refused", and AGENTS-shape.md states the rule exactly (#533)

Fix round 1 for #556, after an independent review (R556) of 35ab229.

A non-zero exit with HEAD already on the tracking branch at the pin printed
`git refused a checkout that would have changed nothing:`. True of a stale
`.git/index.lock`, false of a failing post-checkout hook (a git-lfs hook with
git-lfs missing from PATH is one real cause), which main called `checked out,
and then git said:` and the file's own rule calls "not a refusal". The line is
now `nothing to check out, and then git said:`, true of a lock, a hook and an
unmerged index alike, and git's own lines follow as before.

The create path had the strand place_at_pin was fixed for. With the tracking
branch missing, `checkout -b <branch> <pin>` ran from a leg detached at a
commit made after the pin and printed `branch <branch> created at <pin>`,
leaving that commit on no branch. checkout_tracking_branch now reads HEAD
there too: detached at a commit that is not the pin, no branch is made, HEAD
does not move, and the line says the branch does not exist, where the
checkout is, and that the way out is to check out the pin (the branch is not
there to check out). Detached AT the pin the branch is created, as on a fresh
clone. `_say_keep` is the second line both refusals share.

A behaviour change, now pinned by tests and named in the PR: a leg detached at
ANY commit other than the pin is left where it is, even one its branch or
`origin/main` holds (an ancestor of the pin; the tip a `git submodule update
--remote` leaves, past the pin). Bootstrap used to check the branch out from
both, losing nothing; `fast_forward_to_pin` already left the same checkout
alone for a branch behind the pin, so the rule has no exception to explain.
The module docstring says the same, for every state of the branch.

AGENTS-shape.md :86-87 had a noun phrase and a verb phrase in one entry ("puts
the legs on ..., or says why not"), and :95-96 said "where that loses nothing"
as if that were the whole rule: a leg detached at the ancestor of the pin loses
nothing and is still left. It now says: placed AT the pin where that loses
nothing, and a leg detached at any other commit is left, saying why. Same
lines, the file still 162.

Tests (tests/test_bootstrap_after_bump.py, 31 -> 35): HEAD on main at the pin
with a failing post-checkout hook (the new line, never "refused"); the
stale-lock test takes the new line; a leg detached at an ancestor of the pin
and at `origin/main` past it; a missing tracking branch over a detached
commit, and the create when detached at the pin. The hook's fixture is one
helper, `failing_post_checkout`.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ts pin the create path's twins of the held-commit cases (#533)

Fix round 2 for #556: R556's two NITs on 8885a29, folded before the PR leaves
draft so the pinned copy needs one re-pin.

NV2. The create guard in checkout_tracking_branch read `current is None and
head != pin`. A leg whose `.git` file names a gitdir that is gone makes
`_head_of` answer (None, None); that satisfied the guard and printed `the
checkout is at ? (detached), which is not the pin`, advising a checkout of the
pin in a leg git cannot read. It is `head is not None and ...` now: with no
HEAD there is no commit to keep, the `checkout -b` below fails, and the line
is main's own, `could not create branch 'main' at the pin <pin12>; leaving
detached`. place_at_pin needs no such guard: it runs only for a branch git
just read.

NV1. Tests for the create path's twins of the held-commit cases: no tracking
branch, and the leg detached at an ancestor of the pin (V4) or at `origin/main`
past it (V5). Each asserts that no branch is made and HEAD stays. The helpers
`detached_at_a_held_commit`, `no_branch` and `has_no_branch` are shared with
the earlier tests, and the gitdir-gone leg has a test of its own.

Mutants, all killed: `and not _is_ancestor(sub, head, pin)` on the create guard
(V4 fails), the guard letting `origin/main` through (V4 and V5 fail), the guard
without `head is not None` (the gitdir test fails), the guard off (the three
create-path tests fail).

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s gone now deletes the leg's gitfile before rewriting it, because Git for Windows marks `.git` hidden and Windows refuses to overwrite a hidden file, which failed #556's tests-windows job with `PermissionError` (#533)

Run 38613137751's tests-windows job failed one test,
`test_a_leg_whose_gitdir_is_gone_is_not_said_to_be_detached_off_the_pin`,
at `(leg / ".git").write_bytes(b"gitdir: ../.git/modules/gone\n")`:
`PermissionError: [Errno 13] Permission denied: '...\Atlas\spec\.git'`.

The mechanism: the leg's `.git` is a gitfile that git itself wrote when the
fixture's clone put the submodule in place, and Git for Windows marks `.git`
HIDDEN (`core.hideDotFiles=dotGitOnly`), a gitfile as much as a directory.
`Path.write_bytes` opens `"wb"`, which the C runtime turns into `CreateFile`
with `CREATE_ALWAYS`, and that call fails with ACCESS_DENIED over an existing
hidden file. The `root` fixture's `copytree` copies the attribute with the
file, so the private copy is hidden too. Linux and macOS have no such
attribute and passed.

The test now `unlink()`s the gitfile first, which works on a hidden file
(`tests/test_doctor_gitlink_repairs.py` and `tests/test_pin_validator.py`
already unlink a leg's `.git` the same way), and writes the same bytes into
the new file. The state the test builds, and everything it asserts, are
unchanged. It is the only write in this pull request's tests over an existing
`.git`; production code only asks whether `.git` exists.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…main, for ruling 27, with no hand resolution (#533)

Main moved from 5ed3e5d to bbcfd34 (#473, #551, #552, #554, #553, #561,
#562) while this PR waited. It changed two of this PR's files,
scripts/bump-leg.py (#561) and tests/test_bootstrap_after_bump.py (#554),
and both merged without a conflict, so no file was resolved by hand and
this merge is not a new H0 under ruling 27.

The merged tree equals the one `git merge-tree --write-tree e5da3c5
bbcfd34` predicted: f4c0ed1.

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

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 15, 2026 11:51
@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 11 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 64fc9a8 into main Oct 15, 2026
5 checks passed
@brettheap
brettheap deleted the fix/bootstrap-detached-leg branch October 15, 2026 11:52
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…shape-rollback-links (#560), a clean merge for ruling 27

This is a clean merge of main 64fc9a8 for Brett Heap's ruling 27, with no hand resolution: git merged update-shape.py on its own, and the tree it wrote equals the tree `git merge-tree --write-tree f10824b 64fc9a8` printed beforehand, f6454e1.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…y-hand test and #581 among them) into #569, so the PR's CI runs on current main

Merges origin/main fb786c1, read from the
shared store without a fetch. Main moved from 7d9bec9 (#569's last merge) by
#556, #557, #558, #559, #560, #562, #567, #568, #579, #580 and #581. #579
(#550) landed tests/test_setup_by_hand_commands.py, which this PR's
`--no-next-steps` makes red; the next commit answers it. The merge itself is
clean, and its tree is the pm569 prediction's #581 step (53b6d0f7)'s, so the
resolution prepared against the predicted main carries over.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
… fix/540-pin-source-unread

This merges origin/main 2bf8fd4 as the shared store held it at
2026-10-15T16:50Z, thirteen landings past the 7d9bec9 this branch last
merged: #555 (shape-doctor's manifest kinds), #565 (scaffold's
`scaffold-remote-exists`), #581 (adopt's `check` of a kept pin path and
an unwritable work directory), #568, #559, #557, #560, #580, #556, #558,
#567, #579 and #562. It is done so that the PR's CI merge ref and this
round's rewording run on current main.

The merge was clean, with no conflict hunk. `adopt-project.py`,
`scaffold-project.py`, `scripts/shape_materialize.py`, `docs/cli.md` and
`tests/test_adopt_root_symlinks.py` took main's changes beside this PR's
by the textual merge, and `scripts/render-cli-reference.py --check`
passes on the result.

Main: 2bf8fd4.

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 not have as the index has it (missing, unstaged, untracked, or behind assume-unchanged or a sparse checkout), so neither commits a change to it nobody staged (#407) (#571)

`family.py remove` commits `.gitmodules` as `git rm` leaves it in the work tree, and `git rm` edits only a `.gitmodules` the work tree has. With the work-tree copy deleted and the deletion not committed, `git rm` edited nothing. `_commit_one` then staged the deletion, and the commit took `.gitmodules` out of HEAD, with every other member's section in it. `remove` exited 0, and the holder it ran in passed its own gate. Once the commit was pushed, a recursive clone left every remaining member empty.

The issue's reproduction, run on bfed143 (I added one `git switch -c`, because #487 landed after the issue was filed):

```
registration keys before: Atlas 2, Nova 2
dry run: exit 0
gitmodules   none: git reads no section of .gitmodules in the work tree as giving members/Nova, so none is removed
remove: exit 0
  committed 766cfa532913: .gitmodules, members/Nova, family.yaml
 .gitmodules  |  6 ------
HEAD holds .gitmodules: no
registration keys after: Atlas 2, Nova 0
family ok: Fam (fam), 1 member(s)
recursive clone: exit 0
the clone's members/Atlas holds 0 entries
REFUSED member-uninitialized: members/Atlas is not an initialized submodule checkout, ...
fatal: No url found for submodule path 'members/Atlas' in .gitmodules
```

One check, `_checked_gitmodules_as_staged`, runs before any git command that writes. The dry run makes it too, in the same words. It reads the index's records for `.gitmodules` (stage, blob and git's `-v` flag letter) in one `git ls-files -s -v -z`, which writes nothing.

- **`gitmodules-missing`** (`remove`): the work tree has no `.gitmodules`, while the index (at any stage) or HEAD has one. The remediation names the restore git takes in that state:
  - `git checkout -- .gitmodules` (the index's copy);
  - `git checkout HEAD -- .gitmodules`, where the deletion is staged;
  - for an unmerged file, by the stages the index holds: `git checkout --merge -- .gitmodules` where it has both sides' copies; `git checkout --ours` where only this side's is left (the other side deleted it); `git checkout --theirs` where only the other side's is; `git checkout-index -f --stage=1` where both sides deleted it. Each is followed by `git add .gitmodules`.
- **`gitmodules-unstaged`** (`remove` and `add`): the work tree's copy is not the index's. That means one of:
  - git lists it as modified (`git ls-files -m`). Where `git add` would still stage the index's own blob from it (`git hash-object --path=.gitmodules` says so, and it is a regular file), the refusal adds that the difference may be its line endings alone;
  - it is unmerged;
  - its deletion is staged while the file is still on disk;
  - the index marks it assume-unchanged, which `ls-files -m` does not look past, and its content is not the index's blob;
  - it is untracked: neither the index nor HEAD holds one. This includes the first member's `add` in a new holder.

  The remediation offers both ways out: stage the work tree's copy, or put back the index's (or HEAD's) copy. Behind assume-unchanged, staging first takes the mark off (`git update-index --no-assume-unchanged -- .gitmodules`), because `git add` stages nothing while it is on. An untracked file is staged with `git add -f .gitmodules`, since an operator's global excludes may match it (and, if wanted, committed on its own first), or moved out of the holder.
- **`gitmodules-sparse`**: the index marks `.gitmodules` skip-worktree, as a sparse checkout that leaves it out does. git neither compares nor stages that file, and `git checkout -- .gitmodules` matches nothing. The remediation is `git sparse-checkout disable`.
  - `add` refuses it whatever the file's state, since `git submodule add` cannot stage it.
  - `remove` refuses it where the file is missing or is not the index's copy, and commits a clean copy on disk rightly, as it did on bfed143.

Every refusal ends with "No git command that writes was run and nothing was written.", and every remediation ends with "run the same command again". Typed as printed, each one leads to the right commit (the tests do exactly that).

`remove` asks this first, before #360's `member-path-registered-twice`. `add` asks it after its mount-path refusals (#242, #300), which already name what they find in the same file. Two of those, `member-name-taken` and `gitmodules-unreadable`, have the person mend the file by hand and run `add` again. They now say "then `git add .gitmodules`", as #300's and #360's remediations already did. Without that step, the run again was this PR's own `gitmodules-unstaged`. The holder's `validate-family.py` keeps its words: it reads the work tree's copy, so re-running it needs no stage.

| Point | What changed |
|---|---|
| S1 | `member-name-taken` and `GITMODULES_UNREADABLE` say "then `git add .gitmodules`". New test: each one's state, mended by hand, the printed stage typed, and `add` then mounts the member. The text tests are updated (`test_family_add_excludes.py`'s `member-name-taken` test, and `test_refusal_default_remediation.py`, which now holds `add`'s remedy equal to the gate's but for the one step). `test_family_add_refusals.py::test_a_gitmodules_git_cannot_read_is_a_refusal_not_a_pass` pins the validator's text, which is unchanged, so it is untouched, and so is the `:518` test, which asserts only the REFUSED line. |
| N1 | The line-endings clause, only where `hash-object` gives the index's blob from a regular file. Tested with a CRLF copy (clause) and a link to the very same bytes (no clause: `hash-object` reads through the link, but git would stage the link). |
| N2, sparse | `gitmodules-sparse`, with `git sparse-checkout disable`. Tested by a real `git sparse-checkout set --no-cone '/*' '!/.gitmodules'`, for both verbs, typed as printed. |
| N2, modify/delete | The per-stage restores above. Tested for stages 1+2 (`--ours`), 1+3 (`--theirs`) and 1 alone (`checkout-index`), each typed as printed. |
| N3 | `test_a_holder_whose_head_has_no_gitmodules_is_not_asked` asserts only that no `gitmodules-` refusal is made. Its docstring says the real run fails there, a known case from before #407 that goes to #459. |
| N4 | The base split below is corrected. |
| N5 | `unmerged()` and `typed_as_printed()` run git through `raw(root, env, ...)`, under the module's isolated environment. `raw()` gained a `stdin` argument for `update-index --index-info`. |
| N6 | `held()` and `assert_held()` are gone. Refusals are read with `test_family_dry_run.state()` and `assert_as_it_was()`; `state()` now reads a missing `.gitmodules` as None (three lines). |
| Codex, `:4476` | The untracked `.gitmodules` is `gitmodules-unstaged`, for both verbs, dry run alike. Tested: the first `add` in a holder with no member, and `remove` in the holder #407 leaves behind with the old bytes put back untracked. Staged as printed, each commits. |
| Codex, `:4519` | assume-unchanged: compared by content (`hash-object` against the stage-0 blob) and refused. skip-worktree: `gitmodules-sparse`, as for N2. Tested for both verbs: the assume-unchanged edit kept through the printed `update-index` and `add`, and the skip-worktree edit through `git sparse-checkout disable`, then the restore the next refusal prints. |

| Nit | What changed |
|---|---|
| 1 | `remove` again commits a clean `.gitmodules` the index marks skip-worktree, as bfed143 and b1120c3 did: `gitmodules-sparse` is asked for `add` whatever the file's state, and for `remove` only where the file is missing or is not the index's copy. `add`, an edited file and a missing one stay refused. The function's and the module's docstrings say which verb refuses what. New test: `remove` with the mark on and the file clean commits exactly `.gitmodules`, `family.yaml` and the gitlink. |
| 2 | Two cases the round-2 tests left open. X1: `add` over a file marked both skip-worktree and assume-unchanged (two `update-index` calls, `ls-files -v` tag `s`) is `gitmodules-sparse`. X2: a clean assume-unchanged file passes, and both verbs commit the right tree. Each set-up asserts the `ls-files -v` letter it expects. |
| 3 | The untracked branch prints `git add -f .gitmodules`. The untracked `add` test also runs under an operator's `core.excludesFile` holding `*` (set as #242's tests set it), asserts that the file is ignored there, types the remedy, and `add` commits. |
| 4 | Decision 5's wording. Round 2's commit subject said "no printed command leads into another refusal", which is inexact: for an edit hidden by skip-worktree, `git sparse-checkout disable` leads, by design, into `gitmodules-unstaged`, and `test_an_edit_skip_worktree_hides_is_refused_then_named_as_modified` pins that chain. This round's commit subject says only what it does. |

1. **Refuse, not rebuild (the issue's first option).** The holder is left byte for byte as it was, and the person picks the copy they mean. Building this member's section out of the index's blob would commit a `.gitmodules` the work tree does not have. The holder would then show `.gitmodules` as deleted, and the person's next `git commit -a` would delete it after all. For a modified copy it would also have to guess which copy the person means.
2. **A staged change still goes in.** The remediations of #360 and #300 tell the person to edit `.gitmodules`, `git add` it, and run the same command again. So the rule is "the work tree's copy must be the index's", not "must be HEAD's".
3. **"Modified" is git's own answer.** It comes from `git ls-files -m`, the comparison `git status` uses and the one `git rm` refuses an unstaged change with. On bfed143, an unstaged edit, a CRLF-only copy and a symlink were each refused by `git rm` only after `git submodule deinit -f` had emptied the member (then rolled back), and each dry run exited 0. The CRLF case is still refused (as `git rm` refuses it), and now says why it may look like no change: its `hash-object` is the index's blob (probe: both `26f54704`).
4. **Assume-unchanged is compared by content; skip-worktree is refused outright.** Probes against round 1's check (`states-wip.log`):
   - assume-unchanged, clean: both verbs commit correctly, so it is not refused. Edited: `ls-files -m` is empty and both verbs committed the edit. `git add .gitmodules` with the mark on exits 0 and stages nothing (the hash stays `a2d4d491` against the index's `26f54704`), so the keep-remedy takes the mark off first. `git checkout -- .gitmodules` restores it with the mark on.
   - skip-worktree, file on disk: `remove` committed a hidden edit; `add` failed at its mount even for a clean file (`git add` refuses a path outside the sparse-checkout definition, then rollback). `git add` and `git checkout --` both fail while the mark is on. So `add` refuses any skip-worktree `.gitmodules`, on disk or not. `remove` refuses one that is missing or is not the index's copy, and commits a clean one on disk rightly, as it did on bfed143 and b1120c3 (round 3; round 2 refused that one too).
5. **`git sparse-checkout disable` is the one command that works as typed both in a sparse checkout and where only the mark is left.** `git sparse-checkout add /.gitmodules` is narrower in a real non-cone sparse checkout, but fails for a mark set by hand (`fatal: no sparse-checkout to add to`). With sparse checkout on, `git checkout --ignore-skip-worktree-bits -- .gitmodules` and `git update-index --no-skip-worktree` + `git checkout --` both put the file back, but `remove` then failed at its commit's `git add` ("outside of your sparse-checkout definition"). `disable` worked with sparse checkout on and with only the mark left, and keeps a copy already on disk as it is. The refusal names the state in its own words. It is a dozen lines, not a sparse-definition reader.
6. **An untracked file is staged, not committed by the printed command, and staged with `-f`.** `add`'s branch check (#487) comes after this refusal, so a printed `git commit` could land on the tracking branch. Staged, the file goes into the verb's commit; the remediation says to commit it on its own first if that is not wanted. An operator's global excludes may match the untracked file (#242's `*`), and a plain `git add` then refuses it, so this branch prints `git add -f .gitmodules`. The tracked branches keep the plain `git add`, which stages a tracked file under the same excludes.
7. **The dry run (N9).** In the issue's state, `remove --dry-run` prints the same refusal, exit 2. Past the refusal, the work tree's file is the index's copy, or no copy exists anywhere. So `deinit` reads the same name the dry run reads.
8. **`bump` is not affected.** Its commit is `[path, MANIFEST]` and never stages `.gitmodules`. Probe: `bump` with the work-tree `.gitmodules` deleted committed `family.yaml` and `members/Atlas` only.

`tests/test_family_remove_gitmodules.py`, 35 tests:
- `remove` over each state the work tree lacks (6): deleted (the issue), deletion staged, and unmerged with the file deleted at stages 1+2+3, 1+2, 1+3 and 1. Both runs refuse alike, exit 2, nothing on stdout, and the holder unchanged: `test_family_dry_run.state()`, which is HEAD, `ls-files -s -z`, `status --porcelain=v1 -z`, `family.yaml`, `.gitmodules`, `.git/config`, the member's HEAD, every work-tree byte and the holder's objects. The remedy is typed as printed; `remove` then exits 0, HEAD's `.gitmodules` gives only the other member's path, and the gate passes;
- the issue's clone: after the restore and `remove`, a push and a fresh `clone --recurse-submodules` populate the other member, and the clone's gate exits 0;
- each work-tree copy that is not the index's, for both verbs (12): edited, CRLF only, a link to the same bytes (skipped on Windows), assume-unchanged and edited, unmerged, deletion staged with the file kept. One printed remedy is typed, and the verb then commits;
- sparse (4): a real sparse checkout for both verbs, and `add` over a clean file marked skip-worktree by hand and over one marked both ways (`ls-files -v` tag `s`); skip-worktree with an edit (2), both verbs;
- a clean file behind a mark, committed with exactly `.gitmodules`, `family.yaml` and the member's gitlink (3): skip-worktree (`remove`), assume-unchanged (`remove` and `add`);
- untracked (3): `add` in a new holder, with no excludes and under an operator's `*`; `remove` in a holder #407 already damaged;
- `add` over a missing file is still git's own refusal, with its dry run's note;
- a holder whose HEAD lost `.gitmodules`: no `gitmodules-` refusal (N3);
- S1 (2): `member-name-taken` and `gitmodules-unreadable`, mended by hand, the stage typed as printed, `add` then mounts.

Round 2's 29 tests on round 1's head b1120c3: `15 failed, 14 passed in 80.15s`. The 15 are exactly round 2's cases: CRLF's clause (2), sparse (2), skip-worktree (2), untracked (2), S1 (2), the `--ours`, `--theirs` and `checkout-index` restores (3), and assume-unchanged (2). On bfed143 with them: `27 failed, 2 passed in 59.67s`; the 2 that pass pin behaviour the fix keeps (`add` leaves a missing file to git, and N3's holder is not asked).

Round 1's base proof, corrected (the review's N4): on bfed143 with round 1's 14 tests, `12 failed, 2 passed`. Of the 12, 8 failed on "HEAD changed" (the verb committed; the clone test is among them), 3 on ".git/config changed" (`lacks[deletion staged]`, `[edited-remove]` and `[crlf-remove]`: the run failed after `deinit` and the rollback re-wrote the registration) and 1 on "status changed" (`[unmerged-remove]`). In `lacks[deletion staged]` it was not `git rm` that failed after `deinit` but `_commit_one`'s `git --literal-pathspecs add -f -- .gitmodules family.yaml` (`pathspec '.gitmodules' did not match any files`).

On this head (ebe6b51), Linux, `/usr/bin/python3 -m pytest`, the files round 3 touches and the importers of the changed helpers: `tests/test_family_remove_gitmodules.py tests/test_refusal_default_remediation.py tests/test_family_add_excludes.py tests/test_family_dry_run.py tests/test_family_member_path.py`: `182 passed in 224.37s (0:03:44)`. The new file alone: `35 passed in 46.50s`.

Round 2's targeted runs, on cb159b1 (round 3 changes `family.py`'s skip-worktree condition and one remedy, and the new test file):
- `tests/test_family_remove_gitmodules.py tests/test_family_bump_remove_rollback.py tests/test_family_dry_run.py tests/test_family.py tests/test_cli_reference.py tests/test_repo_hygiene.py tests/test_family_member_path.py tests/test_family_tracking_branch.py tests/test_family_printed_quoting.py tests/test_family_member_name.py tests/test_family_bump_to_a_tag.py tests/test_family_init_landing.py tests/test_family_init_values.py tests/test_template_text_placeholders.py`: `706 passed, 19 skipped in 1013.94s (0:16:53)` (the skips are `test_family_init_values.py`'s template-quoting cases, as on main);
- `tests/test_family_add_refusals.py tests/test_validate_family_duplicates.py tests/test_validate_family_member_path_glob.py tests/test_validate_family_unnamed_mounts.py tests/test_validate_family_without_git.py tests/test_family_add_excludes.py tests/test_family_add_member_id.py tests/test_refusal_default_remediation.py tests/test_agents_md_codes.py tests/test_shape_doctor.py`: `632 passed, 3 skipped in 1482.84s (0:24:42)` (the skips are the Windows-only PowerShell line).

Windows: every file the tests write is written with `write_bytes`, git's stdin gets bytes, and nothing compares against a file a helper wrote with `write_text`. The holder's attributes keep `.gitmodules` LF in the work tree (`i/lf w/lf attr/text=auto eol=lf`). The symlink case skips on Windows, where git checks a link out as a plain file. macOS: no case variants are involved.

The issue's script, with local bare remotes, a `gh` that exits 97, no global or system git configuration, and the registrations read from `.git/config` by `grep`:
- on round 2's head (cb159b1): `dry run: exit 2` and `remove: exit 2`, both `REFUSED gitmodules-missing: ...` with the same stderr, and a full snapshot of the holder (every file, `.git` included) identical after each. The registration sections stay 1 and 1. After a push of the unchanged holder, the recursive clone exits 0, `members/Atlas` holds 15 entries, and the clone's gate prints `family ok`.
- round 1 typed the remedy as printed (`git checkout -- .gitmodules`): `remove: exit 0`, a commit with `.gitmodules | 3 ---` (Nova's section only), the holder's gate exits 0, and after a push the recursive clone populates Atlas and its gate exits 0. The lacks and clone tests repeat that on this head.
- round 2's states, on cb159b1: for `remove` and `add`, a real sparse checkout, a skip-worktree edit, an assume-unchanged edit (kept, and dropped), CRLF and a link; for `remove`, an assume-unchanged deletion, the unmerged file deleted at stages 1+2, 1+3, 1 and 2+3, and the untracked file in a damaged holder; for `add`, the untracked file in a new holder. Each is refused alike by both runs (identical stderr), each printed command exits 0 as typed, and the run again commits what the remedy chose. Round 3 changes two of these paths (a clean skip-worktree file for `remove`, and the untracked remedy's `-f`), and its tests on ebe6b51 hold both.

Nothing here needs real GitHub or a cloud tool.

Round 2's code hunks, each reverted alone in a throwaway worktree at cb159b1, with the new file run against it:

| hunk reverted | result | new tests that fail |
|---|---|---|
| M1 S1, `GITMODULES_UNREADABLE`'s stage | 1 failed, 28 passed | S1 `[gitmodules-unreadable]` |
| M2 S1, `member-name-taken`'s stage | 1 failed, 28 passed | S1 `[member-name-taken]` |
| M3 the untracked refusal (Codex, `:4476`) | 2 failed, 27 passed | both untracked tests |
| M4 `gitmodules-sparse` | 4 failed, 25 passed | sparse `[remove]`, `[add]`; skip-worktree `[remove]`, `[add]` |
| M5 the per-stage restores (always `--merge`) | 3 failed, 26 passed | `lacks[our side only, deleted]`, `[their side only, deleted]`, `[neither side, deleted]` |
| M6 the line-endings clause (N1) | 2 failed, 27 passed | `[crlf-remove]`, `[crlf-add]` |
| M7 `_stages_as`'s regular-file guard | 2 failed, 27 passed | `[a link to the same bytes-remove]`, `[...-add]` |
| M8 the assume-unchanged branch (Codex, `:4519`) | 2 failed, 27 passed | `[assume-unchanged, edited-remove]`, `[...-add]` |
| M9 the flags read as `H` (`_gitmodules_in_index`) | 6 failed, 23 passed | the 2 assume-unchanged, 2 sparse and 2 skip-worktree cases |
| M10 the return where nothing holds a `.gitmodules` | 29 errors | every test, at the family fixture's first `add` into a holder with no `.gitmodules` anywhere, which is then refused as untracked |

Round 1's hunks are still held by the same tests (round 1's probes M1 to M8). The other hunks are docstrings and comments.

Round 3's hunks, each reverted alone at ebe6b51, with the new file run against it:

| hunk reverted | result | new tests that fail |
|---|---|---|
| R1 the narrowing (always refuse skip-worktree) | 1 failed, 34 passed | `[skip-worktree-remove]` of the clean-behind-a-mark test |
| R2 its `verb == "add"` arm | 2 failed, 33 passed | sparse `[marked by hand, clean-add]`, `[marked both ways, clean-add]` |
| R3 X1, `tag == "S"` without `.upper()` | 1 failed, 34 passed | sparse `[marked both ways, clean-add]` |
| R4 X2, every assume-unchanged copy refused | 3 failed, 32 passed | `[assume-unchanged-remove]`, `[assume-unchanged-add]`, sparse `[marked both ways, clean-add]` |
| R5 plain `git add` in the untracked remedy | 3 failed, 32 passed | the three untracked tests (the pinned text) |
| R5b plain `git add` in the remedy AND the test's expected text | 1 failed, 2 passed (`-k untracked`) | `[*]`: typing `git add .gitmodules` fails, "The following paths are ignored by one of your .gitignore files ... Use -f" |

`git merge-tree --write-tree` of this head (ebe6b51) with each of these is clean:
- origin/main dd3f23a. Since bfed143, main took #551, #552, #554, #553, #561, #562, #579, #567, #558, #556 and #580; none touches `scripts/family.py` or a test file this branch changes. This branch does not merge main;
- `fix/family-bump-to-checks` 8024f9c (#568), `fix/family-members-case-variants` 8a95345 (#574), `fix/link-source-chain-defaults` a707d4b (#576), `fix/family-siblings-tracking-branch` 2156a01 (#578);
- lane 2's `fix/537-dry-run-asks-remotes` 69c58aa (#573), and #575's head 0680799 (lane 2, which changes `validate-family.py`; its unreadable-file remedy still ends "then run this again.", which `test_refusal_default_remediation.py` expects).

- **(a) `family.yaml` hand edits are committed by `add`, `bump` and `remove`.** Each commits `family.yaml` as the work tree has it, so an unstaged hand edit outside `members:` (a comment line) goes into the commit: the review reproduced `+# a hand note nobody staged` in a commit of each verb. It is #407's rule for a different file, and a staged-only rule there would chain into every remedy that says "edit `family.yaml`, then re-run", as S1 did here. It needs its own issue.
- **(b) A HEAD that already lost `.gitmodules` fails after the first write.** With no `.gitmodules` anywhere, this check asks nothing. `remove --dry-run` exits 0 and says no section is removed; the real `remove` runs `deinit` and `git rm`, then dies at `git --literal-pathspecs add -f -- .gitmodules family.yaml` (`pathspec '.gitmodules' did not match any files`) and puts back only part of the unmount (`members/Nova` is left empty). Same on bfed143. Once the old file is put back in the work tree, this PR now refuses it as untracked, and staged as printed `remove` commits it back into HEAD; but nothing tells the person to do that.
- **(c) The gate passes a row with no `.gitmodules` section (#459, the issue's decision 3).** `validate-family.py` should find a `members:` row whose path no section of HEAD's `.gitmodules` gives. A holder already carrying a #407 commit passes its own gate, and only a fresh recursive clone shows it.
- **(d) A sparse checkout whose rules leave `.gitmodules` out, with the file put back on disk by hand.** git then clears the skip-worktree mark, so this check passes, and `remove`'s commit fails at `git add` ("outside of your sparse-checkout definition") and is rolled back. Nothing wrong is committed; it is a late failure, and reading the sparse rules is not this PR's.

- **(e) A verb run during a merge, cherry-pick or rebase fails after its first writes.** Any `family.py` verb run while one is in progress fails at its commit, `git commit -- <paths>` ("fatal: cannot do a partial commit during a merge."), and is rolled back. It is the same on bfed143. It touches this PR because its `--ours` and `--theirs` restores name modify/delete conflicts, whose commonest source is `git merge`: typed as printed the restore works, then `remove` fails late. The tests build those stages with `update-index --index-info` and no `MERGE_HEAD`, the state a conflicted `git stash pop` leaves. The candidate: refuse an in-progress merge, cherry-pick or rebase before the first write, beside the existing up-front refusals.

Review: rv407 found 1 should and 6 nits on b1120c3; fix round 2 (cb159b1) fixed them and answered Codex's `:4476` and `:4519` threads; re-review rv407b found 4 nits, fixed in round 3 (ebe6b51). CI (Brett Heap's ruling 27): H0 ebe6b51 passed tests, tests-macos, tests-windows and SonarCloud; the head 52c7c88 is a clean update-branch merge of main 2989b4c, with tests and SonarCloud green. Landed on Brett Heap's word of 2026-10-15T13:32:47Z, "All five, once green".

Closes #407

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 15, 2026
…e `--link-source` is the first chain link's tree alone, and a source that names no path is refused (#441, #442) (#576)

**README change.** This PR edits one README paragraph. #551 is on main and this branch has merged it, so the handbook step follows #551's rule: the three page parts that read the README section "The three legs" record its new hash. No page text changes.

### #441: one bare tree answered for two links

`link_pins_from_trees` passed its `default_source` to every link of a recorded chain. That value is the naming gate's bare `--link-source PATH`, and the scaffold's `--pin-source`. In the chain `openXdox → openYdox → openInk`, with one bare tree that is `openXdox`'s, `openYdox` was read from the same `project.yaml`. That file declares `openYdox`, not `openInk`, so the gate said "openYdox declares openYdox, not openInk". For `codexInk`, which holds no direct pin on its referent, that was a FINDING, exit 1, for a chain that holds. The scaffold refused the same chain as `chain-not-declared`.

Now:

- **Which links read a bare value (for #540 and anyone else relying on it):** only the FIRST link of each chain, `links[0]`, the pin the project holds. That is true for a chain of any length, one link included: a chain of one link has only its first link, and that link reads the bare tree as before. A later link is never offered the bare value. It is read from its own `LINK=PATH`, its `SHAPE_PIN_SOURCE_<LINK>` or a checkout beside the project; otherwise it is declared-unverified (a warning, exit 0). For the first link the order is unchanged: keyed, variable, checkout beside the project, then the bare value.
- `link_pins_from_trees` and `resolve_link_source` keep their signatures and return shapes. The only code change in `repo_shape.py` is `None if index else default_source` in the loop.
- The two-link case (`openXdox → openDox` with a bare path) reads exactly as before and prints the same lines. The one difference is inside the returned mapping, and only for a chain of two or more links: the chain's END link no longer gets an entry read from the bare tree. Nothing reads that entry; only holders' declarations are read. In a chain of one link the only link is both the first and the end, and it still reads the bare tree, as before.
- In the naming gate, `LinkSources` finds the first link of every chain a name will READ before any chain is read. `_first_links_read` uses the same `reads_link_declarations` test the lookup uses, and counts a chain only if it has two or more links: a chain of one link has no holder, reads no declaration, and so has no first link to count. The bare value is offered only while there is at most one first link: with a lone one-link chain there are none, and the bare tree is still returned for that chain's link to read, as before (nothing reads what it finds, so the output is unchanged). If a run reads two chains that begin at different links (a name on the command line plus `--project`, or one project with two legs), the bare value is read for neither. Before, one tree answered both, and `used` counted it once.
- A bare value no link took is a NOTE saying why. In a two-link run the sentence is the one it always was. In a longer chain it reads "a bare PATH is the tree of a chain's first link alone, openXdox, which had a more specific source (...)". With two first links it reads "the chains read here begin at openXdox, openYdox: name the link it is, as --link-source <link>=PATH".

### #442: an empty path was the current directory

`--link-source openXdox=` and `--pin-source openXdox=` became `Path("")`, which is `.`. From a project root, the naming gate read the project's own manifest as `openXdox`'s and called the chain broken. `validate-pins.py` asked the project root for the pinned commit, and its finding blamed `.`.

Now both tools refuse, exit 2, naming the key:

```
REFUSED link-source-no-path: `--link-source openXdox=` names no path for openXdox. An empty path would be read as the directory this runs in. Give the tree's path (`--link-source <link>=<path>`), or `.` to mean this directory.
REFUSED pin-source-no-path: `--pin-source openXdox=` names no path for openXdox. An empty path would be read as the directory this runs in.
Remediation: give the checkout's path (`--pin-source <product>=<path>`), or `.` to mean this directory.
```

- An empty bare value (`--link-source ''`, `--pin-source ''`) is refused the same way.
- A typed `.` still means the current directory.
- A value whose key is NOT a chain link or a declared pin (`openZdox=`) is still the bare PATH `openZdox=`, as #338 and #295 decided. It is not empty, so it is not refused.
- **Behaviour change: an empty value is refused even when a later value replaces it.** `--link-source openXdox= --link-source openXdox=<tree>` was a NOTE ("the later `--link-source ...` replaced it"), exit 0. It is now `REFUSED link-source-no-path`, exit 2. `validate-pins.py` changes the same way: `--pin-source openXdox= --pin-source openXdox=<clone>` was `pins ok`, exit 0, and is now `REFUSED pin-source-no-path`, exit 2. A typo is a typo, and the refusal names it when it is typed; a script that passed an empty value first and the real one after must drop the empty one.
- The naming gate refuses in `main`, right after the values are read and before stdin is read or any name is classified, like its own `REFUSED naming-no-target`.
- `validate-pins.py` raises a `Refusal` from `PinSources`. It reaches `main`'s existing handler with its own remedy, not the lockstep one. **It is raised late:** `PinSources` is built inside `_check_neutral_product_pins`, which `_run_all_checks` calls after `_check_leg` and `_check_shape_pin`. So an earlier refusal wins: a damaged `contracts/spec-pin.yaml` plus `--pin-source openXdox=` prints `REFUSED pin-tag-only`, and the empty path is named on the run after that is fixed. The hunk stays inside `PinSources` on purpose, to stay clear of the lines #539 edits in `_run_all_checks`. The refusal also has to wait for the declared pins, because they decide whether `openXdox=` is keyed or a bare PATH. Nothing is printed before the refusal, and the exit is 2 as in every other refusal of that tool.

### Docs

- `--help` for `--link-source`, the naming validator's module docstring (exit code 2 included), `resolve_link_source`'s rule 4, `link_pins_from_trees`'s docstring and the scaffold's `_declaration` docstring say what a bare value means in a longer chain.
- The `--link-source` help also says that a bare PATH is read for neither link when the chains read begin at two different links, and that a later link is read from its own `PRODUCT=PATH`, its `SHAPE_PIN_SOURCE_<PRODUCT>` or a checkout beside the project (it said "needs its own `PRODUCT=PATH`").
- `docs/cli.md` is regenerated.
- README's lookup-order sentence now reads "then a bare `--link-source <path>`, for the first link's tree only: the order `validate-pins.py` uses below for the same checkout." The paragraph keeps its 12 lines, and README keeps its line count.
- The handbook names no `--link-source`. The three parts that read "The three legs" (the masthead, "Reading private legs in CI", "The three legs, and naming") move to that section's new hash, 6ebed796 → 39ec2c23. No other README section changed.
- `validate-pins.py`'s `--help` and EXIT CODES docstring do not name `pin-source-no-path`. Nothing in them is false: the exit-2 list gives examples. I left them, because the module docstring sits near lines #539 edits.

The review of 04c9d77 (APPROVE WITH NITS) found five nits. Each, and what this commit does:

1. **A chain of one link counted as a first link.** `--referent-chain openInk` beside a project whose leg records `openXdox → openQuill`, with a bare `openXdox` tree, gave two first links, so the bare tree went to neither. `codexQuill`, verified through it on bfed143, was declared-unverified, with a NOTE naming `openInk` as a first link. Both places now count `chain[0]` only when the chain has more than one link: `_first_links_read`, and the `firsts` update in `LinkSources.link_pins`. The run is verified again, with no WARNING and no NOTE, as on bfed143. New test `test_a_chain_of_one_link_is_no_first_link_read`.
2. **A test gap: the project half of `_first_links_read`.** Reverting `if data is not None:` to `if False:` (mutation M14) left all 20 tests passing, yet it changes the output for one project whose two legs begin at different links: the first classification pass counted the bare value used, and the NOTE went missing. New test `test_a_project_whose_legs_begin_at_two_links_reads_a_bare_tree_for_neither`: `openXdox → openInk` and `openYdox → openQuill` in one project, a bare `openXdox` tree, two WARNINGs and the two-first-links NOTE. M14 now fails it (see the mutation probes).
3. **`pin-source-no-path` is raised late in `validate-pins.py`.** No code change. The behaviour and the reason are written above, under #442.
4. **The `--link-source` help.** It now says a bare PATH is read for neither link when the chains read begin at two different links, and "needs its own `PRODUCT=PATH`" now names the variable and the checkout beside the project too. `docs/cli.md` is regenerated, and `render-cli-reference.py --check` is ok.
5. **This body.** It now says: #539 is on origin and merges clean (see the merge checks); an empty value is refused even when a later one replaces it; the END-link sentence holds for chains of two or more links; and the checklist candidate below.

The commit changes `scripts/validate-repository-naming.py`, `docs/cli.md` and `tests/test_link_source_chain_defaults.py` only.

The re-review of 31a6f21 (APPROVE WITH NITS) found that all five nits are fixed, and two new ones. This commit changes `tests/test_link_source_chain_defaults.py` only.

- **A test gap at the two-link boundary of `link_pins`.** Changing `if len(target.chain) > 1:` there to `> 2` passed the whole new file and `tests/test_naming_policy.py` (264 passed), yet it changes the output for a name read from stdin whose chain has two links: the first-link list is empty, and the NOTE for an unused bare value reads "a bare PATH is the tree of a chain's first link alone, , which had a more specific source". New test `test_a_two_link_chain_read_from_stdin_counts_its_first_link`: stdin `codexInk`, `--referent-chain openXdox,openInk`, `openXdox=<tree>` keyed and a second tree bare, asserting the whole stderr, the one NOTE "each link whose own declaration is read had a more specific source (...)". The `> 2` mutant fails it (see the mutation probes). The existing three-link stdin test and the new one share a helper, `stdin_naming`.
- **This body.** "exactly one first link" now reads "at most one", and the merge paragraph names the current main.

- **First link only, not a refusal.** The coordinator recommended this rule. Nothing documented or tested depended on a bare value answering a later link: no test passes `default_source` to `link_pins_from_trees`, and every scaffold test with a three-link chain sets `SHAPE_PIN_SOURCE_<LINK>` for each link. A refusal "when more than one link would take it" would depend on what is on the disk (checkouts beside the project, variables). #338 ruled that the reading is decided by the chains, never by the disk.
- **Refuse #442, not note it.** A note would still have to stop the value reading `.`. A refusal names the typo when it is typed, and both tools already exit 2 for a question they cannot ask.
- **The scaffold is fixed too.** It reads its `--pin-source` through the same function, so its three-link refusal goes away (see the reproduction). Its `--pin-source` is the pinned product's checkout, which is the first link's tree.

New file `tests/test_link_source_chain_defaults.py`: 23 tests (22 until round 3). On bfed143, 17 fail and 5 pass (the controls: the one-link rows and the rows that already worked). On 04c9d77, the one-link test fails and the other 21 pass.

Targeted runs (each `run9.sh`: `gh` stub, bogus token, empty `GH_CONFIG_DIR`, git-filter-repo 2.47.0), on the head 31a6f21 unless a row says otherwise. Round 3 (head a707d4b) changes the new test file only, and its own run is the last row:

| set | result |
|---|---|
| new file, naming policy, referent-chain links, pin-source env collisions, pin-source unreadable, CLI reference, repo hygiene, README sections, AGENTS.md codes, validate-pins without git, unreadable workflow | `774 passed in 117.48s` |
| manifest validator, scaffold pin and reuse, YAML flow quotes, neutral pin validator | `205 passed in 91.75s` |
| CLI reference alone | `22 passed in 26.08s` |
| template text placeholders, adopt plan leg-path help | `187 passed in 87.54s` |
| predicted merge with origin/main 7d9bec9 (tree 908b3214): repo hygiene, README sections, CLI reference, new file, naming policy | `415 passed in 53.10s` |
| round 3, head a707d4b: new file, naming policy, CLI reference | `287 passed in 95.29s` |

Round 1 (head 73c4c255, before the merge of #551 and this commit; `docs/handbook.html`, `README.md` and the validators are unchanged by this commit apart from `validate-repository-naming.py`):

| set | result |
|---|---|
| doctor, doctor validator rows, scaffold e2e, setup-project.py, setup.sh, adopt e2e, adopt pins, adopt spec-only, adopt submodules, adopt ignored shape files, adopt plan YAML quoting, adopt root symlinks, scaffold leg-path collisions, update-shape landing | `27 failed, 1796 passed, 3 skipped in 1433.51s`; every failure the same `UnicodeDecodeError` from a bytecode file my own `py_compile` probe had left in `templates/assembly-root/scripts/__pycache__`. With it removed, the two files that held all 27 (`test_adopt_root_symlinks.py`, `test_scaffold_leg_path_collisions.py`) gave `253 passed in 140.50s` |

The three skips are `test_shape_doctor.py:9119`, which runs only on a Windows host. None of those files passes `--link-source`, and the one-link change and the help text cannot change a run that gives no bare value.

**The 27 failures.** `template_values()` and its twin in `test_scaffold_leg_path_collisions.py` read every file under `templates/assembly-root` as UTF-8. A `validate-pins.cpython-312.pyc` had appeared there the second I ran `python3 -m py_compile` over the changed files. Its first byte is `0xcb`, the 3.12 bytecode magic, which is the byte every failure names. CI never runs `py_compile`.

| row | bfed143 | this head |
|---|---|---|
| #441: one bare `--link-source <openXdox tree>`, chain `openXdox → openYdox → openInk`, `codexInk` | `FINDING ... is broken (openYdox declares openYdox, not openInk)`, exit 1 | `domain-descendant/assembly`, one `declared-unverified` WARNING for openYdox naming `--link-source openYdox=<path>`, exit 0 |
| #441: each link keyed | exit 0 | exit 0, unchanged |
| #441: no `--link-source` | two WARNINGs, exit 0 | unchanged |
| #441, scaffold `--dry-run`, same chain, `--pin-source <openXdox clone>` | `REFUSED chain-not-declared`, exit 2 | WARNING declared-unverified for openYdox, plan printed, exit 0 |
| #441, one link: `--referent-chain openInk` beside a leg `openXdox → openQuill`, bare openXdox tree | no warnings, exit 0 | the same: no warnings, exit 0 (04c9d77 printed a WARNING and a NOTE) |
| #441, one project, legs `openXdox → openInk` and `openYdox → openQuill`, bare openXdox tree | `FINDING codexQuill: the declared chain openYdox → openQuill is broken (openYdox declares openInk, not openQuill)`, exit 1: the bare tree answered both first links | two WARNINGs and the two-first-links NOTE, exit 0 |
| #442: `--link-source openXdox=` from the project root | `FINDING ... (openXdox declares openXdox, not openInk)`, exit 1 | `REFUSED link-source-no-path`, exit 2 |
| #442: the same from another directory | WARNING about `.`, exit 0 | `REFUSED link-source-no-path`, exit 2 |
| #442: `--link-source ''` from the project root | FINDING, exit 1 | `REFUSED link-source-no-path`, exit 2 |
| #442: `--link-source openXdox= --link-source openXdox=<tree>` | NOTE "the later ... replaced it", exit 0 | `REFUSED link-source-no-path`, exit 2 (the behaviour change above) |
| #442: `--link-source openXdox=.` from the project root | FINDING (reads the project's own manifest, as typed) | unchanged: a typed `.` is read |
| #442: `--pin-source openXdox=` (scaffolded `codexDox`) | `FINDING neutral-pin-source-unreadable: ... . (--pin-source openXdox=...)`, exit 1 | `REFUSED pin-source-no-path`, exit 2 |
| #442: `--pin-source ''` | the same FINDING, exit 1 | `REFUSED pin-source-no-path`, exit 2 |
| #442: `--pin-source openXdox= --pin-source openXdox=<clone>` | `pins ok`, exit 0 | `REFUSED pin-source-no-path`, exit 2 |
| control: `--pin-source openXdox=<clone>` | `pins ok`, exit 0 | unchanged |

No row needs real GitHub. Round 1 ran every row except the two new ones and the two "a later value replaces it" rows. The one-link and two-legs rows are the new tests: on bfed143 the one-link test passes and the two-legs test fails; on 04c9d77 the one-link test fails and the two-legs test passes. The two "a later value replaces it" rows are the review's reproductions of bfed143 against 04c9d77; this commit does not change them.

Each hunk was reverted alone in a throwaway worktree, and the new file run against it.

Round 1 (20 tests), every hunk fails at least one test:

| hunk reverted | tests that fail |
|---|---|
| `repo_shape.py`: offer `default_source` to every link again | 6: the long-chain row, the later-link NOTE, the stdin NOTE, the lookup `[two]` and `[three]`, the scaffold |
| `LinkSources._bare_path`: drop the two-first-links gate | 1: two chains beginning at two links |
| `_not_asked_why`: drop the two-first-links sentence | 1: the same test |
| `_not_asked_why`: always the old two-link sentence | 2: the later-link NOTE and the stdin NOTE |
| `main`: drop the `link-source-no-path` refusal | 6: every `--link-source` no-path row |
| `PinSources`: drop the `pin-source-no-path` refusal | 3: every `--pin-source` no-path row |
| `link_pins`: drop `firsts.setdefault` (stdin names) | 1: the stdin NOTE |
| `_first_links_read`: return nothing | 1: two chains beginning at two links |
| naming `no_path`: keyed values only | 2: the two empty bare rows |
| pins `no_path`: keyed values only | 1: the empty bare row |

Round 2 (22 tests, head 31a6f21):

| hunk reverted | tests that fail |
|---|---|
| `_first_links_read`: `if data is not None:` → `if False:` (the project's legs; the review's M14, which survived 04c9d77) | 1: `test_a_project_whose_legs_begin_at_two_links_reads_a_bare_tree_for_neither`; stderr has the two WARNINGs and no NOTE |
| `LinkSources.link_pins`: count a one-link chain's first link again | 1: `test_a_chain_of_one_link_is_no_first_link_read` |
| `_first_links_read`: count a one-link chain again | 1: `test_a_chain_of_one_link_is_no_first_link_read` |

Round 3 (23 tests, head a707d4b):

| hunk changed | tests that fail |
|---|---|
| `LinkSources.link_pins`: `if len(target.chain) > 1:` → `> 2` (the re-review's boundary mutant, which survived 31a6f21) | 1: `test_a_two_link_chain_read_from_stdin_counts_its_first_link` (`1 failed, 22 passed in 10.59s`); stderr has "first link alone, , which had" where the test expects "each link whose own declaration is read had" |

The existing `test_a_link_source_no_link_used_is_a_note_and_changes_no_exit` in `tests/test_naming_policy.py` holds the two-link NOTE sentence byte for byte, and it passes.

- origin/main 9f80939 (it holds the README, handbook and `tests/readme-cap.d/550.txt` of f6eaab7): clean. The re-review found it clean at 31a6f21 (tree 71ea0d30), and `test_repo_hygiene.py`, `test_readme_sections.py` and `test_cli_reference.py` gave `151 passed in 58.81s` on that merged tree, with `render-cli-reference.py --check` ok. The README cap there is 1427 = 1422 + 5 from main's `tests/readme-cap.d/550.txt`, and this PR adds 0 README lines; its three handbook markers for "The three legs" are 39ec2c23 on the merged tree. a707d4b changes the test file only: merge-tree of it with 9f80939 is clean (tree dfc650f). Earlier mains were clean too: 7d9bec9 (after #552, #553, #554, #561 and #562; `415 passed` on the merged tree) and bbcfd34 (tree 10acc9c). The two `docs/cli.md` regenerations (this PR's `--link-source` help, #561's `bump-leg.py`) touch different tools' sections.
- Lane 2's `fix/539-validators-lockstep-remedies` (#575, 93b68f6), now on origin: clean. The reviewer ran the lockstep-lines test, the new file, `test_validate_pins_without_git.py` and `test_validate_pins_unreadable_workflow.py` on the merge with 04c9d77: `103 passed in 48.45s`. This commit does not touch `validate-pins.py` or any of those tests. The `validate-pins.py` hunk stays inside `PinSources`, away from #539's regions (`:99-544` and `:1109-1143`).
- Lane 2's `fix/540-pin-source-unread` (#570, 79dfd33): clean. On a merge with 04c9d77, the new file, `test_pin_source_unreadable.py`, `test_scaffold_referent_chain_links.py`, `test_scaffold_leg_path_collisions.py`, `test_cli_reference.py` and `test_adopt_pins.py` gave `371 passed`. #540 counts any `--referent-chain` as a reader of `--pin-source`; that stays true, because every chain's first link reads it. One sentence of #540 needs a word once both are in: `pin-source-unused` says the clone is read for "each --referent-chain link's declaration" (`scripts/shape_materialize.py:2706-2707` and the docstring at `:2681-2683`, and `tests/test_pin_source_unreadable.py:445`, the `UNUSED` text at `:455-457` and `:498`). After this PR it is the FIRST link's alone. Whichever lands second should change "each" to "the first".
- The other PR heads #556 to #582 that git still serves (23 heads; #561 is on main): all clean, except #577 (`install.sh`, d0cdbf4). #577 conflicts with main itself, in `docs/handbook.html` and `tests/test_repo_hygiene.py`; against this head the same two files conflict. This PR is not the cause. Both edit `README.md` and the handbook's hash markers, so they land one at a time. Only #575 touches a file this PR changes (`validate-pins.py`, clean).
- Lane 5's other branches: #568 `fix/family-bump-to-checks` 8024f9c, #571 `fix/family-remove-gitmodules-missing` b1120c3, #574 `fix/family-members-case-variants` 40eda84 and #578 `fix/family-siblings-tracking-branch` 43d32eb: clean.

- The scaffold's and `adopt-project.py`'s own `--pin-source ''` (see the checklist candidates).
- `validate-pins.py`'s `--help` and module docstring: nothing in them became false, and the module docstring sits near lines #539 edits.
- Making `validate-pins.py` refuse the empty path before its leg and shape-pin checks: it would put the hunk in `_run_all_checks`, where #539 edits.
- The v1.0.0 release notes keep #441 and #442 as known limits of 1.0.0, which they are.

- **`scaffold-project.py` and `adopt-project.py`: `--pin-source ''` is `Path("")`, so the current directory is read as the pinned product's clone.** Both take `--pin-source` as `type=Path`, and `shape_materialize.declared_pin_values` computes the digest from it. The scaffold also hands it to `link_pins_from_trees` as the first link's tree. Run from the product's own clone, `scaffold-project.py --pin openXdox@<sha> --pin-source '' --referent-chain openXdox,openDox --dry-run` plans with `local-clone (.)`, exit 0; run from anywhere else it reads whatever directory it stands in. This is #442's shape in the two tools that create a project, and neither was in #442. (The review confirmed it for the scaffold on the head; `adopt-project.py` was not re-run.)
- **The scaffold's `declared-unverified` warning names remedies the scaffold does not have.** It says "(a sibling checkout, SHAPE_PIN_SOURCE_<LINK>, or --link-source <link>=<path> would let it be read)". `scaffold-project.py` has no `--link-source` (none in its `--help`) and reads no sibling checkout: `link_pins_from_trees(chain, None, ...)` at `scaffold-project.py:799` passes no root. The text is `_chain_link_findings`' (`scripts/repo_shape.py:1419`). It was already printed on bfed143 for a two-link scaffold with no manifest. Since this PR, every chain of three or more links scaffolded with `--pin-source` alone prints it for the second link, where only `SHAPE_PIN_SOURCE_<LINK>` helps. The wording should name only the remedies the caller has.
- **`tests/test_cli_reference.py` alone writes `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc`** (proved on 04c9d77 and on two merged trees: `22 passed`, then the file is there). A `.pyc` under `templates/` breaks `template_values()` and its twin in `test_scaffold_leg_path_collisions.py` in any later adoption run in the same tree: round 1's 27 failures were the same kind of break, from a `.pyc` under `templates/assembly-root`. #554's `load_without_bytecode` does not cover it. It is outside #441 and #442, and is named here only.

Review: rv441 found 5 nits on 04c9d77 (APPROVE WITH NITS), fixed in 31a6f21; re-review rv441b found all five fixed or answered and 2 new nits (a test gap at the two-link boundary of `link_pins`, and this body), fixed in a707d4b. CI (Brett Heap's ruling 27): H0 a707d4b passed tests, tests-macos, tests-windows and SonarCloud; the head 8971ad5 is a clean update-branch merge of main bcfa364, with tests and SonarCloud green, and the local gate passed on its predicted squash: the scaffold, pins, naming, link-source, setup-by-hand and hygiene sets, 1174 passed, over bcfa364; then the hygiene, AGENTS.md-code, README-section, CLI-reference, link-source and naming sets, 468 passed, over 526cd2c; and 334 passed over b9f65be. Landed on Brett Heap's word of 2026-10-15T13:32:47Z, "All five, once green".

Closes #441
Closes #442

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…rward_to_pin stays within Sonar's complexity limit, and both AGENTS-shape.md copies and bump-leg.py say bootstrap places a leg where that loses no commit (#603)

Fix round 2 of PR #615, on R603's verification of 088c772 and the
coordinator's rulings on it.

S3776. Round 1's `if other_branch:` block, with the ternary in it, took
`fast_forward_to_pin`'s cognitive complexity to 16 against Sonar's 15. The
print is now `_say_left(head, current, named)`, next to `_place_of`, with a
docstring that says why it is a function of its own. The printed bytes do not
change: the tests that pin them are untouched. The caller is 14 by the same
count that gave the helper-less version 16.

"LOSES NO COMMIT". Round 1 measured two states where placement loses a file or
a merge but no commit (an ignored file at a path the pin tracks, and a merge in
progress on the branch HEAD leaves), so "where that loses nothing" is false of
them. `templates/assembly-root/AGENTS-shape.md` (#556's sentence, which this PR
now changes), `templates/family-root/AGENTS-shape.md` and `leg_placement`'s
docstring in `scripts/bump-leg.py` say "where that loses no commit". Both
AGENTS-shape.md lines grow from 75 to 77 characters and keep their line counts;
the docstring is re-wrapped in the same three lines. Left alone: the
docstrings in bootstrap.py about moving the branch REF, which does lose
nothing, the move-back line's `nothing is lost, <upstream> still holds those
commits`, which its own clause scopes to commits, and shape-doctor.py.

README. In `templates/assembly-root/README.md`, "(so you are not staring at a
detached HEAD)" follows "at its pinned commit" directly again; the paragraph is
still five lines. The template is rendered at scaffold time, not pinned.

THE RE-PIN, MEASURED AT THIS COMMIT with the PR's own changes only
(`update-shape.py check --upstream <a clone of base 3f768a6 plus this PR's
diff>`, on roots scaffolded from base 3f768a6; the branch tip also carries
main's #575 validators, merged in through 709eec1, which would add
`scripts/validate-pins.py` and `scripts/validate-family.py` rows): an assembly
root takes it as one `upstream-changed` re-pin of `scripts/bootstrap.py`,
`Makefile` and `AGENTS-shape.md` (the totals read `upstream-changed 3` and
`unchanged 8`), a family holder as one of `AGENTS-shape.md` (`upstream-changed
1`, `unchanged 9`). The two README templates are rendered, not pinned, so they
reach new projects only.

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 15, 2026
… the pin is fast-forwarded and checked out as one at the pin already is, and the family holder's AGENTS-shape.md and bump-leg.py say bootstrap places a leg where that loses no commit (#603) (#615)

`fast_forward_to_pin` now moves a tracking branch behind the pin, and puts the leg on it, from a third checkout: HEAD on another branch (T7), through `_move_and_check_out` as from the pin. That branch keeps its commits, so the move loses no commit, and `place_at_pin` already did the same for a tracking branch at the pin (P9). The success line is the one the other starts print, followed, when HEAD started on another branch, by one line saying the leg left it (`the leg was on <other>, which keeps its commits; it is on <tracking> now`, or `a branch with no commit yet` for an orphan HEAD). A checkout git refuses after `branch -f` worked leaves the branch at the pin and HEAD where it was, with the existing moved-and-refused lines; a `branch -f` git refuses prints the existing not-moved lines; a leg detached off the pin still gets `which is neither`. D1, option A of the checklist triage, approved by Brett Heap at 2026-10-15T16:49:38Z. A checkout git makes from another branch overwrites an ignored file at a path the pin tracks, and ends a merge in progress on the branch it leaves, as P9 and the missing-branch path already do: #458's to rule on.

Both AGENTS-shape.md copies (the family holder's, and the assembly root's, whose sentence #556 wrote), `leg_placement`'s docstring in bump-leg.py, the assembly root's Makefile help and both README templates say bootstrap places a leg where that loses no commit and says why where it does not. "Loses no commit", not "loses nothing": git's checkout overwrites an ignored file and ends a merge in progress (#458's to rule on).

A pinned-copy change, measured with `update-shape.py check` on roots scaffolded from base 3f768a6 against that base plus this PR's own changes (main's validator rows left out): an assembly root takes it as one `upstream-changed` re-pin of `scripts/bootstrap.py`, `Makefile` and `AGENTS-shape.md`, a family holder as one of `AGENTS-shape.md`; the two README templates are rendered at scaffold time, not pinned, so they reach new projects only.

Closes #603

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 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.

bootstrap.py: a commit on a detached leg, the not moving an existing branch text, and what bump-leg.py and AGENTS-shape.md still promise (one re-pin)

1 participant