Repository navigation
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
Conversation
…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>
Reviewer's GuideUpdates Sequence diagram for directory-independent branch validationsequenceDiagram
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
Sequence diagram for accurate apply landing messagessequenceDiagram
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
Flow diagram for preserving commented shape headersflowchart 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"]
Flow diagram for apply no-change and conflict messagingflowchart 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"]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…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>
This was referenced Oct 14, 2026
…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>
This was referenced Oct 15, 2026
…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>
|
brettheap
marked this pull request as ready for review
October 15, 2026 11:48
|
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 |
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>
This was referenced Oct 15, 2026
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>
This was referenced Oct 16, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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.pytakes one landing and one three-OS run for all three. One commit per issue, in this order:6c6e8208(#437),a647bab8(#431),369fede9(#535); then8eed2d7fandfabdd7e5, the fix round after the independent review (below). Nothing inREADME.md,docs/handbook.htmlor any--helptext changes, sodocs/cli.mdis as it was.What changed and why
#437: a
files:orshape:header that carries a trailing commentrewrite_shape_pinfoundfiles:asline.rstrip() == "files:"and wrote it back bare, and_shape_block_boundsfoundshape:the same way. A header followed by a comment, which the reader and both validators accept, was refused asupdate-pin-unrecognised("0filesline(s)") orupdate-manifest-no-shape(false: the block exists; its remedy, "add it", would give the manifest a secondshape:key), aftercheckhad sent the person toapply.Both headers are now found with
repo_shape._strip_commentat column 0, the reader's own rule for where a comment starts (imported by its private name, asshape_materializedoes 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. Thefiles: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-shapefor a manifest with noshape:, #380's byte-identical rewrite of a scaffold-shaped pin and manifest, andrewrite_fault's read-back. A family holder'sfamily.yamlshape: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_boundsnow refuses any manifest holding the header twice, by name, underupdate-manifest-unrecognised: "holds theshape: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:
--branchjudged the same from any directorychecked_branchrangit check-ref-format --branchin the operator's directory, so from a.gitfile naming a missing gitdir, a.git/configwith a syntax error, or a removed working directory a good name was refused asupdate-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 withBRANCH_CONTROLandBRANCH_UNCHECKED_REMEDIATION;shape_materialize.pyis not edited. The alphabet check, the code, the--branch '<name>'prefix and this tool's ownBRANCH_REMEDIATIONfor a name git refused or expanded are unchanged (the two tests that pin them pass as they were). A git that refusesmaintoo, or cannot be run, is worded "could not be checked" withBRANCH_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
applywhose written paths are all HEAD's bytes again prints one NEXT saying there is nothing to commit, not a commit line. An--addfile 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 HEADover 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.bothrow's detail names the upstream's file;update-conflict's remedy spellsgit show <full target>:<source>for eachbothrow (merge_by_handreplaces theMERGE_BY_HANDconstant).<target>is filled too:Rowgainstarget, set wheresourceis, socmd_applypasses nothing new.unmappedandupstream-removedrows are named no command (none reads).rewrite_link_refusalkeeps its<source path>placeholder (the issue marks it optional, and it holds neither value).--pr's body claim "not hand-edited" only of the copies re-copied from the upstream, and name the paths--accept-localkept. They reachcommit_on_branchandpull_request_linethrough_landas the new keywordaccepted, the only line ofcmd_applythis PR changes. Fix round: a--prre-run (_finish_landing) has no rows, so_accepted_in_the_pinreads 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'sMakefilerow, which has always recorded bytes with the adoption's block appended, from being reported as kept by a flag nobody passed; whereorigin/<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, astest_update_shape_landing.pyholds it to.gh pr createthatapply --branchprints without--prcarries--titleand--body,shlex-quoted, with--pr's words (pull_request_wordsis the one place they are written). Not--fill, which reads the commits of wherever the line is pasted. The bare formpull_request_linebuilds for a failed--push's NEXT is the same line's twin and carries them too.next_lineis not passed the upstream or the accepted paths, so_landkeeps the words onargs. Fix round:pull_request_wordstakes the root's manifest name, so a holder's body saysfamily.yamlre-pinned, notproject.yaml(it saidproject.yamlin the plain NEXT, the failed---pushNEXT 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 acceptedbothrow, 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 throughshlex.split, compared with what--propens).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 adoptedMakefilerow, and with nothing accepted; a chmod of a written path (POSIX only,WINDOWS_SKIP); and a doubledshape:key with a comment on neither, the first, the last and both headers, for both kinds.Mutants, each run against the new test file(s) for its issue on the final head and reverted (
update-shape.pyrestored byte-identical, checked by sha256). Every one is killed:line.rstrip() == "<key>:") restoredchecked_branchasks baregit check-ref-format --branchin the operator's directory, not the helper_same_as_head)_landkeeps drop the accepted pathsproject.yamlwhatever the root isMakefileis named)shape:is not refused by nameThe N1 "HEAD check" mutant of the first round became equivalent when the mode check was added (a root with no commit makes
git diff HEADfail, which is "not the same"), so the explicit_has_headbeside 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 areWINDOWS_SKIP.Shared files
update-shape.pyis also edited by three other lane-4 PRs, on other lines: P3 (#422, #428) the module docstring,Rollback.writeandundo, one line incmd_check, a new function after_refuse_linked_copiesand one line incmd_applyafter_refuse_linked_copies(rows); P4 (#536) a new function between_nothing_to_doand_write_copiesand one line incmd_applybeforeconfirm(...); P9 (#532, later)cmd_check, theupdate-branch-existsremedy,_refuse_local_drift,_note_untaken_additions,accept_local_exitandkeep_local_exits. The two import blocks are this PR's alone. Incmd_applythis 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:
files:andshape:headers, while refusing manifests with duplicateshape:keys.gh pr createcommands.Enhancements:
Tests: