Skip to content

update-shape.py: a files: or shape: header with a trailing comment is rewritten and keeps it, --branch is judged the same from any directory, and apply's messages say what is true (#437, #431, #535) - #558

Merged
brettheap merged 7 commits into
mainfrom
fix/update-shape-small-fixes
Oct 15, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #437 (comment)
Claim: #431 (comment)
Claim: #535 (comment)

Three S issues in disjoint functions of one file, in one pull request so that update-shape.py takes one landing and one three-OS run for all three. One commit per issue, in this order: 6c6e8208 (#437), a647bab8 (#431), 369fede9 (#535); then 8eed2d7f and fabdd7e5, the fix round after the independent review (below). Nothing in README.md, docs/handbook.html or any --help text changes, so docs/cli.md is as it was.

What changed and why

#437: a files: or shape: header that carries a trailing comment

rewrite_shape_pin found files: as line.rstrip() == "files:" and wrote it back bare, and _shape_block_bounds found shape: the same way. A header followed by a comment, which the reader and both validators accept, was refused as update-pin-unrecognised ("0 files line(s)") or update-manifest-no-shape (false: the block exists; its remedy, "add it", would give the manifest a second shape: key), after check had sent the person to apply.

Both headers are now found with repo_shape._strip_comment at column 0, the reader's own rule for where a comment starts (imported by its private name, as shape_materialize does since #380). That is the whole claim: a quoted key ("files":) or a space before the colon (files :) is read as the key by the reader and is not found here, as before. The files: header is written back as the line it was; the manifest's header is never rewritten. Kept: the missing-header count refusal, update-manifest-no-shape for a manifest with no shape:, #380's byte-identical rewrite of a scaffold-shaped pin and manifest, and rewrite_fault's read-back. A family holder's family.yaml shape: goes through the same function and is tested.

A shape: key written twice (fix round): the reader keeps the last block. On main a plain doubled key was refused by the read-back, and one whose first header carries a comment was ACCEPTED, because the exact match skipped the commented header and rewrote the last block, which the reader reads. With the comment-aware finder that second form would have been refused in the read-back's words, "the fault is this tool's line rewrite", which are false of a doubled key. _shape_block_bounds now refuses any manifest holding the header twice, by name, under update-manifest-unrecognised: "holds the shape: key twice, at lines N and M ... the reader keeps the last block", with the remedy to delete the extra block. It is a deliberate refusal of the form main accepted by accident, and the header finder is otherwise unchanged.

#431: --branch judged the same from any directory

checked_branch ran git check-ref-format --branch in the operator's directory, so from a .git file naming a missing gitdir, a .git/config with a syntax error, or a removed working directory a good name was refused as update-branch-invalid, git's failure read as the name's fault.

It now asks shape_materialize._ask_git_branch (no repository in play, #355), imported with BRANCH_CONTROL and BRANCH_UNCHECKED_REMEDIATION; shape_materialize.py is not edited. The alphabet check, the code, the --branch '<name>' prefix and this tool's own BRANCH_REMEDIATION for a name git refused or expanded are unchanged (the two tests that pin them pass as they were). A git that refuses main too, or cannot be run, is worded "could not be checked" with BRANCH_UNCHECKED_REMEDIATION, never the name's fault. @{-1} is outside the alphabet, so it is refused before git is asked; the expansion arm is covered in-process.

#535: four messages

  • N1. A branchless apply whose written paths are all HEAD's bytes again prints one NEXT saying there is nothing to commit, not a commit line. An --add file is not in HEAD, so a run that added one still gets its line; a root with no commit gets the line it always got. Fix round: the test is by bytes AND by mode, git diff --quiet HEAD over the written paths (--literal-pathspecs), because a chmod leaves the blob alone and git commits it; that diff is answered only where HEAD names a commit, so it carries the no-commit case too.
  • N3. The both row's detail names the upstream's file; update-conflict's remedy spells git show <full target>:<source> for each both row (merge_by_hand replaces the MERGE_BY_HAND constant). <target> is filled too: Row gains target, set where source is, so cmd_apply passes nothing new. unmapped and upstream-removed rows are named no command (none reads). rewrite_link_refusal keeps its <source path> placeholder (the issue marks it optional, and it holds neither value).
  • N4. The commit message and --pr's body claim "not hand-edited" only of the copies re-copied from the upstream, and name the paths --accept-local kept. They reach commit_on_branch and pull_request_line through _land as the new keyword accepted, the only line of cmd_apply this PR changes. Fix round: a --pr re-run (_finish_landing) has no rows, so _accepted_in_the_pin reads the accepted paths back out of the committed pin: the rows whose recorded digest is not the digest of the upstream's file at the target AND is not the digest the tracking branch's pin (origin/<tracking>) held. The second condition keeps an in-place adoption's Makefile row, which has always recorded bytes with the adoption's block appended, from being reported as kept by a flag nobody passed; where origin/<tracking> cannot be read the answer is none (a body that names too few paths is incomplete, one that names a path nobody accepted is false). The re-run opens the very command the first run tried, as test_update_shape_landing.py holds it to.
  • N7. The gh pr create that apply --branch prints without --pr carries --title and --body, shlex-quoted, with --pr's words (pull_request_words is the one place they are written). Not --fill, which reads the commits of wherever the line is pasted. The bare form pull_request_line builds for a failed --push's NEXT is the same line's twin and carries them too. next_line is not passed the upstream or the accepted paths, so _land keeps the words on args. Fix round: pull_request_words takes the root's manifest name, so a holder's body says family.yaml re-pinned, not project.yaml (it said project.yaml in the plain NEXT, the failed---push NEXT and --pr's own body), and its opening clause "the copied shape files were re-copied from the upstream", false for a run whose only change is an accepted both row, is gone: the body says what was re-pinned and that no copy re-copied from the upstream was hand-edited, which is true of none, one or many. A count, as the commit message gives, is not added: a re-run does not know it and must open the same command.

gh's behaviour without a terminal was not run, and no test or probe here runs a real gh pr create: the change is printed text, tested as text (round trip through shlex.split, compared with what --pr opens).

Tests

New files, one per issue: tests/test_update_shape_header_comments.py (54), tests/test_update_shape_branch_any_directory.py (28), tests/test_update_shape_messages.py (26). The assertions that pinned the old texts moved in place: test_update_shape_next_quoting.py, test_update_shape_pr_base.py, test_update_shape_landing.py, test_update_shape.py (:1229). The fix round adds, in those files: the holder's words read whole (the printed line, --pr's body and the failed-push line); a run that re-copied nothing; the re-run's body equal to the first run's, with and without an adopted Makefile row, and with nothing accepted; a chmod of a written path (POSIX only, WINDOWS_SKIP); and a doubled shape: key with a comment on neither, the first, the last and both headers, for both kinds.

pytest tests/test_update_shape_header_comments.py tests/test_update_shape_branch_any_directory.py tests/test_update_shape_messages.py tests/test_update_shape.py tests/test_update_shape_landing.py tests/test_update_shape_add_ignored.py tests/test_update_shape_next_quoting.py tests/test_update_shape_pr_base.py tests/test_update_shape_merged_away.py tests/test_pin_rewrite_layout.py tests/test_repo_hygiene.py tests/test_cli_reference.py tests/test_agents_md_codes.py
621 passed in 279.97s (Linux; git-filter-repo from CI's pin venv on PATH, so the adoption tests do not skip)
pytest tests/test_family.py -k "update_shape or shape_block or manifest_no_shape"
4 passed
pytest tests/test_update_shape_add.py tests/test_update_shape_add_symlink.py tests/test_update_shape_add_not_a_directory.py tests/test_update_shape_rewrite_symlink.py tests/test_commit_trailers.py tests/test_refusal_default_remediation.py tests/test_shape_pin_files_not_a_list.py tests/test_doctor_next_is_a_command.py tests/test_load_yaml_refusals.py
359 passed, 3 skipped (NTFS junctions, Linux)
pytest tests/test_shape_doctor.py -k "currency or conflict or update_shape or accept_local"
24 passed

Mutants, each run against the new test file(s) for its issue on the final head and reverted (update-shape.py restored byte-identical, checked by sha256). Every one is killed:

mutant result
#437: the exact-header match (line.rstrip() == "<key>:") restored 40 of 54 fail
#431: checked_branch asks bare git check-ref-format --branch in the operator's directory, not the helper 9 of 28 fail
#535 N1: the whole nothing-to-commit branch removed 2 of 26 fail (in-process and end to end)
#535 N1: the HEAD requirement removed (no _same_as_head) 2 of 26 fail (a root with no commit, and the chmod)
#535 N1: the mode check ignored (blobs only, HEAD still required) 1 of 26 fails (the chmod)
#535 N7: the words _land keeps drop the accepted paths 1 of 26 fails
MUST: the words name project.yaml whatever the root is 1 of 26 fails (the holder)
SHOULD: the re-run's body names no accepted path 2 of 26 fail
SHOULD: the re-run names every row that is not the upstream's bytes (no base comparison) 2 of 26 fail (the adopted Makefile is named)
NIT: a doubled shape: is not refused by name 8 of 54 fail
NIT: the body opens with the old "were re-copied" clause 2 of 26 fail

The N1 "HEAD check" mutant of the first round became equivalent when the mode check was added (a root with no commit makes git diff HEAD fail, which is "not the same"), so the explicit _has_head beside it was removed rather than left as a guard no input could tell from its absence (fabdd7e5, no output changes). The docstring-only narrowing of what the header finder claims has no mutant.

CI is the Windows gate: fixtures for #437 are bytes (write_bytes, a CR-keeping regex), and the removed-directory cases of #431 and the chmod case are WINDOWS_SKIP.

Shared files

update-shape.py is also edited by three other lane-4 PRs, on other lines: P3 (#422, #428) the module docstring, Rollback.write and undo, one line in cmd_check, a new function after _refuse_linked_copies and one line in cmd_apply after _refuse_linked_copies(rows); P4 (#536) a new function between _nothing_to_do and _write_copies and one line in cmd_apply before confirm(...); P9 (#532, later) cmd_check, the update-branch-exists remedy, _refuse_local_drift, _note_untaken_additions, accept_local_exit and keep_local_exits. The two import blocks are this PR's alone. In cmd_apply this PR changes the _land(...) call and nothing else.

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

Closes #437
Closes #431
Closes #535

🤖 Generated with Claude Code

Summary by Sourcery

Make update-shape.py preserve valid header comments, validate branches consistently, and report apply and pull-request actions accurately.

Bug Fixes:

  • Preserve and correctly process trailing comments on top-level files: and shape: headers, while refusing manifests with duplicate shape: keys.
  • Make branch validation independent of the operator’s current directory and distinguish invalid names from Git environments that cannot be checked.
  • Correct apply, conflict, commit, and pull-request messages so they accurately describe changed files, accepted local files, upstream sources, and no-op commits.
  • Include complete title and body arguments in generated gh pr create commands.

Enhancements:

  • Improve conflict remediation by identifying the exact upstream file and target commit for each mergeable conflict.
  • Detect content and file-mode changes relative to HEAD before printing commit instructions.
  • Keep pull-request wording consistent between direct creation, rerun flows, and printed next commands, including family manifests.

Tests:

  • Add comprehensive coverage for commented headers, duplicate shape keys, branch validation from unavailable directories, corrected apply messages, no-op commits, mode changes, rerun behavior, family holders, and pull-request command quoting.

Brett Heap and others added 3 commits October 14, 2026 17:26
…der that carries a trailing comment is found the way the reader finds it and written back as it was, so `apply` no longer refuses it as `update-pin-unrecognised` or `update-manifest-no-shape` after `check` sent the person to it (#437)

#380 made the `commit:` and `tree_sha256:` rewrites keep each line's own
layout and comment, and left the two block headers matched as
`line.rstrip() == "files:"` and `line.rstrip() == "shape:"`. A header
followed by a comment, which `repo_shape`'s reader and both validators
accept, was therefore counted as no `files` line and refused as
`update-pin-unrecognised`, and a `shape:  # mirrors the pin` was refused as
`update-manifest-no-shape` ("has no `shape:` block ... add it"), which is
false and whose remedy would have given the manifest a second `shape:` key.

Both headers are now found with `repo_shape._strip_comment` at column 0,
the reader's own definition of where a comment starts (`shape_materialize`
imports it by that private name for the same reason, #380), so this tool and
the reader cannot disagree about which line is a header. The `files:` header
is written back as the line it was, comment and spacing included, as
`with_scalar` writes a scalar line; a header with nothing but trailing
blanks is still written bare, so a scaffold-shaped pin is rewritten to the
scaffold's own bytes. The manifest's header line is never rewritten, so its
comment stays.

Kept: the count refusal for a pin with no column-0 `files:` line, and
`update-manifest-no-shape` for a manifest with no column-0 `shape:` line,
each with its validator's name; `rewrite_fault`'s read-back, which is also
what refuses a doubled `shape:` (the reader keeps the last block, so the
read-back of a rewrite of the first one differs), as it did before.

tests/test_update_shape_header_comments.py: the rewrites in-process on the
scaffold's own rendering of each pin and manifest, for six comment spacings
and for a project and a family holder, and `apply` end to end for a project
and a family holder with the comment on the pin, the manifest or both,
compared byte for byte with what `apply` writes for the same project with no
comment. Fixtures are bytes, so no Windows CRLF reaches a comparison.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… through the helper the scaffold and the family holder ask with, so a good name is no longer refused as `update-branch-invalid` from a directory git cannot open, and a git that could not answer says the name "could not be checked" instead of blaming it (#431)

`checked_branch` ran `git check-ref-format --branch <name>` with no `cwd`
and no `--git-dir`, so git set up whatever repository surrounded the
operator's directory and died where it could not open one: a `.git` file
naming a gitdir that is gone, a `.git/config` with a syntax error, a
directory removed under the shell. The refusal read git's failure as the
name's fault, `--branch 'shape/update-x' is not a branch name git will
create`, and its remedy told the person to pass the name they had passed.
`--root` names the repository `apply` works on and has nothing to do with
where the person stands. A repository git DID open also lent `@{-1}` its
own reflog.

The question is now `shape_materialize._ask_git_branch`'s, the helper #355
wrote for exactly this (an empty temporary directory is both `--git-dir` and
the working directory), imported by name rather than copied, with
`BRANCH_CONTROL` and `BRANCH_UNCHECKED_REMEDIATION`. The alphabet check
comes first and is unchanged. The code `update-branch-invalid`, the line's
`--branch '<name>' ` opening and this tool's own `BRANCH_REMEDIATION` for a
name git refused or expanded are unchanged, and the two tests that pin them
(`test_update_shape_landing.py`, `test_update_shape_add_ignored.py`) pass as
they were. When git refuses the name it is asked about `BRANCH_CONTROL`, as
`_branch_verdict` does, and a git that refuses `main` too judged nothing:
that, and a git that cannot be run, are worded "could not be checked" and
carry `BRANCH_UNCHECKED_REMEDIATION`, which is about git, never about the
name. `shape_materialize.py` is not edited.

tests/test_update_shape_branch_any_directory.py: `apply --branch
shape/update-x` against an upstream that has not moved, from a `.git` file
naming a missing gitdir, a malformed `.git/config` and (POSIX only) a
removed directory, exits 0 with `nothing to do` as from an ordinary
directory; `a..b`, `x.lock` and `a/` are still refused from each, as the
name's fault; `@{-1}` is still refused in a repository whose previous
checkout was another branch; a git whose global config does not parse says
"could not be checked", with git's own words and the remedy about git. The
arms that need a git this suite cannot break are covered in-process.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s so instead of printing a commit line, the `both` row and the `update-conflict` remedy name the upstream's file and the target commit, the commit message and the `--pr` body claim "not hand-edited" only of the copies re-copied from the upstream and name what `--accept-local` kept, and the `gh pr create` the `--branch` NEXT line prints carries the title and body `--pr` uses (#535)

Four messages that were misleading or unpasteable, each on a path that
works, in disjoint functions of one file.

N1. `next_line`'s branchless part printed `checkout -b && commit` even when
every path the run wrote was HEAD's bytes again (an `apply` that moved the
pin back to HEAD's), and pasted, git said "nothing to commit". When HEAD
names a commit and `_not_what_head_commits` finds nothing among the written
paths, the one NEXT says there is nothing to commit because the tree holds
what HEAD commits. An `--add` file is not in HEAD, so a run that added one
still gets its line; a root with no commit gets the line it always got.
`_unwritten_changes`' HEAD question is now `_has_head`, shared.

N3. The `both` row's detail now ends `(upstream's file: <source>)`, which
`_classify_row` already held. `merge_by_hand` replaces the `MERGE_BY_HAND`
constant: for each `both` row it spells `git show <full target>:<source>`,
one command by the path in the root when several rows are named. `Row`
gains `target`, the commit the source is read at, set where `source` is, so
`_refuse_conflicts` and `conflict_remediation` fill `<target>` too without
`cmd_apply` passing it. `unmapped` and `upstream-removed` rows are named no
command: none reads for them. `rewrite_link_refusal` keeps its `<source
path>` placeholder (it holds neither value, and the issue marks it optional).

N4. The commit said "the copies are not hand-edited" of every file, and so
did `--pr`'s body ("No copy was hand-edited"), though `--accept-local`
re-pins a file from the project's own bytes. Both now say it of the copies
re-copied from the upstream, and name the paths `--accept-local` kept. The
paths reach `commit_on_branch` and `pull_request_line` through `_land` as a
new keyword, `accepted`, which `cmd_apply` computes from the rows still
`locally-modified` or `both` (every such row was accepted, or refused before
any write); `already-at-target` is not one. `pull_request_words` is the one
place the title and body are written.

N7. The `gh pr create` that `apply --branch` prints without `--pr` had no
`--title`, `--body` or `--fill`, so `gh` prompts for them, and in a shell with
no terminal the pasted line likely stops there (gh's behaviour without one
was not run, and no test or probe here runs a real `gh pr create`). It now
carries `--title` and `--body`, shlex-quoted, with `--pr`'s words, which
`_land` keeps on `args` for `next_line`, whose own call in `cmd_apply` stays
as it was. Not `--fill`: it reads the commits of wherever the line is pasted,
and `check`'s NEXT sends people to paste from the standard's checkout. The
bare form `pull_request_line` builds for a failed `--push`'s NEXT is the
plain line's twin and carries the same words.

tests/test_update_shape_messages.py covers the four (N1 in-process over real
repositories and end to end, N3 for `check`'s row and `apply`'s refusal with
one row, two rows and a `copy-missing` row beside a `both` one, N4 for the
message and the `--pr` body with and without `--accept-local`, N7 as text read
back through `shlex.split` and compared with what `--pr` opens). The
assertions that pinned the old texts move in place:
`test_update_shape_next_quoting.py`, `test_update_shape_pr_base.py`,
`test_update_shape_landing.py` and `test_update_shape.py`.

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

Updates update-shape.py to preserve commented shape headers, perform directory-independent branch validation, and produce accurate, actionable apply/conflict/commit/pull-request messages, with extensive regression coverage across project and family-holder workflows.

Sequence diagram for directory-independent branch validation

sequenceDiagram
    participant Operator
    participant UpdateShape
    participant BranchHelper
    participant Git

    Operator->>UpdateShape: checked_branch(value)
    UpdateShape->>BranchHelper: _ask_git_branch(value)
    BranchHelper->>Git: check-ref-format --branch value
    Git-->>BranchHelper: branch verdict
    BranchHelper-->>UpdateShape: answer
    alt answer matches value
        UpdateShape-->>Operator: accept branch
    else Git refuses value
        UpdateShape->>BranchHelper: _ask_git_branch(BRANCH_CONTROL)
        BranchHelper->>Git: check-ref-format --branch BRANCH_CONTROL
        Git-->>BranchHelper: control verdict
        alt Git can answer
            UpdateShape-->>Operator: update-branch-invalid
        else Git cannot answer
            UpdateShape-->>Operator: could not be checked
        end
    end
Loading

Sequence diagram for accurate apply landing messages

sequenceDiagram
    participant Apply as cmd_apply
    participant Land as _land
    participant PR as pull_request_words
    participant Commit as commit_on_branch
    participant Next as next_line

    Apply->>Apply: _accepted_local(rows)
    Apply->>Land: _land(..., accepted)
    Land->>PR: pull_request_words(repository, target, accepted)
    PR-->>Land: title and body
    Land->>Commit: commit_on_branch(..., accepted)
    Commit-->>Land: commit message names kept paths
    Land-->>Apply: landing result
    Apply->>Next: next_line(...)
    Next-->>Apply: quoted gh pr create with --title and --body
Loading

Flow diagram for preserving commented shape headers

flowchart LR
    Input["Pin or manifest with files: or shape: and trailing comment"] --> Strip[_strip_comment]
    Strip --> Match[_is_block_header]
    Match --> RewritePin["rewrite_shape_pin preserves original files: line"]
    Match --> Bounds["_shape_block_bounds finds shape: block"]
    RewritePin --> ReadBack["rewrite_fault read-back validation"]
    Bounds --> Manifest["Manifest header remains unchanged"]
Loading

Flow diagram for apply no-change and conflict messaging

flowchart TD
    Apply["apply writes selected paths"] --> Head{"HEAD exists?"}
    Head -->|no| CommitHint["existing commit guidance"]
    Head -->|yes| Compare["_not_what_head_commits"]
    Compare -->|paths match HEAD| Nothing["NEXT: nothing to commit"]
    Compare -->|paths differ| Commit["commit guidance"]
    Conflict["Conflict rows"] --> Merge["merge_by_hand(rows)"]
    Merge --> Both["both rows name git show target:source"]
    Merge --> Other["unmapped/upstream-removed rows name no command"]
Loading

File-Level Changes

Change Details Files
Make shape-pin and manifest rewrites recognize top-level headers with trailing comments while preserving the original header text.
  • Match files: and shape: through the shared comment-stripping rule.
  • Preserve comments and spacing on rewritten files: headers while retaining missing-header and read-back safeguards.
  • Cover project, family-holder, byte-preserving, end-to-end, and duplicate-header behavior.
update-shape.py
tests/test_update_shape_header_comments.py
tests/test_update_shape.py
Validate --branch independently of the operator’s current directory or repository state.
  • Reuse shape_materialize’s repository-independent git branch checker and shared control/remediation constants.
  • Distinguish a branch name rejected by git from git being unable to answer, while preserving alphabet and expansion checks.
  • Test ordinary, malformed, missing, and removed working-directory scenarios plus in-process error paths.
update-shape.py
tests/test_update_shape_branch_any_directory.py
Correct apply, conflict, commit, and pull-request guidance so generated messages reflect actual outcomes and are executable.
  • Suppress commit commands when all written tracked paths already match HEAD, while retaining commands for additions and roots without commits.
  • Name upstream source paths and full git show <target>:<source> commands for resolvable conflicts, omitting commands for rows without upstream bytes.
  • Track --accept-local paths through landing and describe them separately from files re-copied from upstream.
  • Centralize pull-request title/body generation and include shell-quoted --title and --body in printed gh pr create commands instead of --fill.
update-shape.py
tests/test_update_shape_messages.py
tests/test_update_shape_landing.py
tests/test_update_shape_next_quoting.py
tests/test_update_shape_pr_base.py

Assessment against linked issues

Issue Objective Addressed Explanation
#431 Validate apply --branch names independently of the operator’s working directory or surrounding repository, and distinguish a name Git rejects from a failure that prevents Git from answering. ✅
#431 Preserve rejection of invalid branch names and names Git would expand, without allowing a repository’s reflog to affect validation. ✅
#437 Recognize top-level files: headers in shape pins and shape: headers in manifests even when they have accepted trailing comments, preserve those comments during rewrites, and retain the existing refusal behavior for missing or doubled headers and byte-identical scaffold rewrites. ✅
#535 Avoid printing a commit command when a branchless apply leaves all written paths identical to HEAD, while retaining the command when there is no HEAD or an added file creates a change. ✅
#535 Make conflict details and remedies identify the upstream source path, and provide a usable git show command with the target commit and source path for each applicable conflict. ✅
#535 Make apply's commit message and pull-request body accurately describe re-copied files and identify paths kept with --accept-local; include title and body in printed gh pr create commands. ✅

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 18:23
…l`, claim no copy was re-copied, and are rebuilt for a `--pr` re-run with what `--accept-local` kept; a mode change is not "nothing to commit"; a `shape:` key written twice is refused as that; and the header docstrings claim only the reader's comment rule (#535, #437)

Fix round 1 for the independent review of PR #558 (R558, CHANGES REQUESTED
at 369fede). Each point below was reproduced before it was changed.

MUST. `pull_request_words` hard-coded "`project.yaml` re-pinned", which N7
now prints in the plain `--branch` NEXT and in the failed-`--push` NEXT, and
which `--pr` opens a holder's pull request with: false where the root has
`family.yaml`. It takes the kind's manifest name, as the commit message does
(`_land`, `pull_request_line` and `next_line`'s fallback all hold `kind`).

SHOULD. A `--pr` re-run (`_finish_landing`) built its body from no rows, so
it dropped the paths the first run's body named. `_accepted_in_the_pin` reads
them back out of the committed pin: a row whose recorded digest is not the
digest of the upstream's file at the target AND is not the digest the
tracking branch's pin held (`origin/<tracking>`), so an in-place adoption's
`Makefile` row, which has always recorded bytes with the adoption's block
appended, is not reported as kept by a flag nobody passed. Where
`origin/<tracking>` cannot be read the answer is none: a body that names too
few paths is incomplete, one that names a path nobody accepted is false. No
change to `cmd_apply`.

SHOULD. N1's "nothing to commit" compared blobs, which a file-mode change
leaves alone and git commits. It also requires `git diff --quiet HEAD`
(`--literal-pathspecs`) over the written paths.

NIT. The body's opening clause, "the copied shape files were re-copied from
the upstream", was false when only an accepted `both` row changed. It is
gone: the body says what was re-pinned, then that no copy re-copied from the
upstream was hand-edited, which is true of none, one or many. A count, as the
commit message gives, is not added: a re-run does not know it, and opens the
very command the first run tried, which `test_update_shape_landing.py` holds
it to.

NIT. A manifest whose `shape:` key is written twice, where the first header
carries a comment, was accepted on main (the exact match skipped the
commented header and rewrote the last block, which the reader reads) and was
refused by this PR's finder in the read-back's words, "this tool's line
rewrite", which are false of a doubled key. `_shape_block_bounds` now refuses
any manifest holding the header twice, by name, under
`update-manifest-unrecognised`: the key is written twice at the named lines,
the reader keeps the last block, delete the extra one. The header finder is
unchanged, and its docstrings, and the import's comment, now claim only the
reader's rule for a comment: a quoted key or a space before the colon is
read as the key by the reader and is not found here, as before.

Tests: the holder's words whole (the printed line, `--pr`'s body and the
failed-push line), a run that re-copied nothing, the re-run's body equal to
the first run's with and without an adopted `Makefile` row and with nothing
accepted, a chmod of a written path (POSIX only), and the doubled key with a
comment on neither, the first, the last and both headers, for both kinds.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…, since `git diff --quiet HEAD` is answered only where HEAD names a commit, so the explicit `_has_head` test beside it could be removed without changing one answer (#535)

The first fix round added `git diff --quiet HEAD` to the nothing-to-commit
test beside `_has_head`. A root with no commit makes that diff fail (no
revision HEAD), and a failed answer is "not the same", so `_has_head` could
no longer decide anything: removing it left every test green, and no test
could be written that it alone passed. A guard that no input can
distinguish from its absence is not a guard. The condition is now
`_same_as_head(...) and not _not_what_head_commits(...)`, and the comment
says where the HEAD requirement lives. `_has_head` stays for
`_unwritten_changes`, where it is the whole question. No output changes.

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

Merges origin/main 2247dbf (#554's squash, which made about 50 test files clone
just-pushed local remotes with `clone_just_pushed`, and added it to tests/conftest.py) into the PR's start head
fabdd7e, which was green on all three OSes and SonarCloud.

The merge was CLEAN: git reported no conflict, tests/test_update_shape.py (a file this PR and #554 both change) was
auto-merged in different hunks, and nothing was resolved by hand. The merge tree equals what
`git merge-tree --write-tree fabdd7e origin/main` predicted, 6dd5f4d.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ll-fixes, a clean merge for ruling 27

A clean merge of main bbcfd34, for ruling
27, with no hand resolution. git merge-tree --write-tree of the start head
6b73e70 with that main printed tree
da3fd77 and exited 0, and this commit's tree
is that tree.

Main brought #553 (shape-doctor.py), #561 (scripts/bump-leg.py) and #562
(update-shape.py and tests/test_update_shape_kept_helper.py). Only
update-shape.py is a file this PR also changes; git merged it without a
conflict.

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:48
@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 2 days and 13 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit a35f1be into main Oct 15, 2026
5 checks passed
@brettheap
brettheap deleted the fix/update-shape-small-fixes branch October 15, 2026 11:49
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 17, 2026
…y writable first, `committed on <branch>:` names what the commit holds, a branch named like a tag is pushed and read by its full name, and a quoted `files:` or `shape:` header is found as the reader finds it (#674) (#810)

Four cases, each measured on main 4ce9045 with a scaffolded project and
local bare remotes, git 2.43.0.

The rollback line. A validator that deleted `contracts/shape-pin.yaml` and
left `contracts/` at 0555 got `... kept as blob <id> in this repository,
then: git -C <root> cat-file blob <id> > <root>/contracts/shape-pin.yaml`,
and pasted, that failed `Permission denied`. `Rollback._restore` now asks
the directory the command writes into, `_nearest_directory`'s answer, and
where the file is absent and that directory will not take it, the line says
`so make <that directory> writable, then:` first. The directory is named by
its full path, since it is the root itself where `contracts/` went too, and
need not be inside the root at all. A present file keeps "so make it
writable", and an absent file under a writable directory keeps the bare
command.

`committed on <branch>:`. `_land` printed `paths`, and `cmd_apply` always
puts the pin and the manifest in it, so a run that only drops a merged-away
row, or keeps a copy with `--accept-local` at the pinned commit, said
`contracts/shape-pin.yaml, project.yaml` of a commit holding the pin alone:
the manifest was rewritten with the bytes it had. The line now names what
`git diff-tree --root --no-commit-id -r --name-only --no-renames -z HEAD`
lists, through the tool's own `_git_answer`, and keeps `paths` only should
git not answer or list nothing.

A branch named like a tag. In a root tagged `v1.0.0`, `apply --yes --branch
v1.0.0 --push` committed and then refused `pushing v1.0.0`, exit 2, `error:
src refspec v1.0.0 matches more than one`, and the NEXT line it printed failed
the same way pasted, as did `--branch`'s own NEXT line. `push_branch` and both
NEXT lines now push `refs/heads/<b>:refs/heads/<b>` (`_full_refspec`), the
form #689 and #672 ask of `family.py`'s and `bump-leg.py`'s push lines; it
pushes the branch and not the tag, and `-u` still sets the upstream.
`current_branch` read `git symbolic-ref --quiet --short HEAD`, which prints
`heads/v1.0.0` there, so a root ON `v1.0.0` was refused `update-branch-exists`
"('heads/v1.0.0' is checked out)" by `commit_on_branch`, and a `--pr` re-run
there was not recognised by `_awaits_its_pull_request`, said "nothing to do"
and opened nothing. It reads the ref whole now and takes `refs/heads/` off
through `_branch_of`, which `Rollback.new_branch` shares; a HEAD attached
anywhere else is on no branch.

A quoted header. `_is_block_header` asked for the exact text `<key>:` once a
comment was stripped, so `"files":`, `'files':` and `files :`, which the
reader and both validators read as the key `files`, passed `check`, which
sent the person to `apply`, and `apply` refused `update-pin-unrecognised`
("0 `files` line(s)"); `"shape":` likewise got `update-manifest-no-shape`,
"has no `shape:` block", which is false. It now asks the reader's own
question, `_split_key(_strip_comment(line.strip())) == (key, "")`, at the
reader's column (no space in front, and no tab among the blanks the reader
refuses), and a line `_split_key` refuses is no header rather than a
traceback. The header is written back as the line was, quotes included. So
`"shape":` beside `shape:` is one key written twice, as the reader reads it,
and is refused by name as #558 refuses a doubled bare `shape:`. The two
docstrings that said such a header "is not found here" say it is.

Left as they were, by the issue's own direction: AGENTS.md:283's placeholder
`git -C <path> push -u origin shape/update-<sha>` (true of the branch it
names; AGENTS.md is at its 492-line cap), `run_validator`'s environment
(a rider that changes no verdict), and `landing_failed`'s `--` before
`origin`, which its line always carried.

Tests: `tests/test_update_shape_apply_lines_and_headers.py`, one or more per
case, on the neighbours' harnesses. The tests that held the bare push form
move with it (`tests/test_update_shape_landing.py`,
`tests/test_update_shape_next_quoting.py`), and
`tests/test_update_shape_header_comments.py`'s docstring no longer says a
quoted header is not found.

Closes #674

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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