Skip to content

bump-leg.py rewrites and commits only the workflow files git tracks, so an untracked draft workflow that names the leg is no longer added whole to the lockstep commit and an ignored one no longer dies at git add after the rewrite: both are refused by name as bump-leg-workflow-untracked, before anything is written and under --dry-run as well (#207) - #222

Merged
brettheap merged 3 commits into
mainfrom
fix/bump-leg-tracked-workflows-only
Oct 11, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-3 (openRepoShape-3)
Claim: #207 (comment)

What

scripts/bump-leg.py rewrites and commits only the workflow files git tracks; an untracked or ignored workflow it would rewrite (one naming the leg at a commit other than --to) is refused by name instead of being swept into the lockstep commit.

  • Tracked files only. plan_workflows makes a row for a workflow file only when git ls-files -z -- .github/workflows holds it. The directory is still walked on disk, because that is the set validate-pins.py reads, and .yml and .yaml are both read.
  • An untracked or ignored file with a reference to move is a refusal, not a skip. REFUSED bump-leg-workflow-untracked, exit 2, naming each file with its reference count, before anything is written, under --dry-run as well. Skipped, validate-pins.py would still read its old @<sha> off the disk and turn the bump red after the rewrite. The refusal's own text gives both exits: commit the file first (it is then tracked and moves with the bump) or move it out of .github/workflows/ until the bump has landed.
  • A file with nothing to move is left alone, unnamed. No reference to the leg, or the leg already at --to: not rewritten, not staged, not refused.
  • Unchanged: a bump with no such file is byte-identical to main. --help is unchanged, so docs/cli.md is too.
  • Docs. The README's bump-leg.py paragraph says "tracked" and names the refusal, and stays at its 1416-line cap: the paragraph was tightened in place to net zero lines, and the refusal's two exits are left to the refusal's own message rather than spelled in the README. docs/handbook.html says the same in the page's own wording, and the footer's history keeps both clauses (shape-doctor.py reads only project.yaml at a root that also carries a family.yaml, so an assembly root whose family.yaml declares two unmounted members is COMPLIANT, and its manifest kinds row says that kind has a registered validator, which is never run #211's, which landed first, and this one's) with its README.md blob re-taken from the merged README: 23b0c6a55802788ce0113c00ea476714c4f38252 (git hash-object README.md). Both pages say the tool refuses an untracked or ignored workflow file "it would rewrite", which is what the code does: one naming the leg at --to is left alone.

Why

Measured on this machine with repro2.sh (kept in the lane scratch): one Atlas fixture scaffolded into local bare remotes, the spec leg advanced by one pushed commit, then bumped once by scripts/bump-leg.py from origin/main eb6df75 and once from this branch's head 7c41d98 (re-measured after the merge of main; the fixture pins author and committer dates, so the commit shas below repeat). Every .out, .err, commit sha, git show --stat, workflow file, validate-pins.py output and git status of the two runs was compared with cmp.

Scenario (root on a bump/spec branch, a tracked legs.yml naming both legs) main eb6df75 head 7c41d98
untracked draft-not-mine-yet.yml naming the leg, --dry-run exit 0, workflows 2 reference(s) in 2 file(s) listing the draft with no word that it is untracked exit 2, REFUSED bump-leg-workflow-untracked: 1 workflow file(s) that git does not track (untracked or ignored) name testorg/Atlas-spec ..., .github/workflows/draft-not-mine-yet.yml 1 reference(s); tree unchanged
same, real run exit 0, committed c1db62c4ea49: spec, contracts/spec-pin.yaml, .github/workflows/draft-not-mine-yet.yml, .github/workflows/legs.yml; the draft is a new file in "Bump spec leg" exit 2, the same refusal; no commit (root HEAD still "Reference both legs from a workflow"); the draft is still untracked
untracked draft.yaml (the other suffix), real run exit 0, committed 9bf000e2f2d1: ... .github/workflows/draft.yaml ... exit 2, the same refusal
ignored local-draft.yml (in .git/info/exclude), --dry-run exit 0, lists it exit 2, the same refusal
same, real run exit 2 after the rewrite: REFUSED advancing a leg. THE EXACT COMMAND WAS: git add -- spec contracts/spec-pin.yaml .github/workflows/legs.yml .github/workflows/local-draft.yml ... The following paths are ignored by one of your .gitignore files; rolled back, no word about the cause exit 2, the same refusal, before anything is written
no such file (tracked legs.yml only) exit 0, committed the spec gitlink, contracts/spec-pin.yaml, legs.yml byte-identical to main: dry and real stdout and stderr, root HEAD sha, git show --stat, workflow files, validate-pins.py, git status
untracked files with nothing to move (other.yml names only the code leg, already.yml is already at --to) exit 0, only the tracked file moves byte-identical to main, same nine files
the draft committed first exit 0, the draft moves with the bump byte-identical to main, same nine files

Where the table says "byte-identical" the run printed IDENTICAL main/head for dry.out, dry.err, real.out, real.err, head.txt, show.txt, workflows.txt, validate.txt and status-after.txt. The untracked, untracked-.yaml and ignored scenarios print DIFFERS for the files that carry the change above (in the ignored one the root HEAD, show.txt, workflow files, validate-pins.py output and git status stay identical, because neither run commits).

Tests

Targeted set on head 7c41d98cdfa1668f8576f3c7f5cabaea5133a16f (the merge of origin/main eb6df75, which holds #220, into the branch; tests/test_git_output_decoding.py and tests/test_stdout_encoding.py are added to the usual five because main's #217 and #214 changed scripts/bump-leg.py, which this branch also edits):

PATH=<git-filter-repo 2.47.0>:$PATH python3 -m pytest tests/test_bump_leg.py tests/test_pin_validator.py tests/test_shape_doctor.py tests/test_repo_hygiene.py tests/test_cli_reference.py tests/test_git_output_decoding.py tests/test_stdout_encoding.py -q
356 passed, 1 skipped in 371.92s (0:06:11)

The one skip is tests/test_stdout_encoding.py:286 ("the default this is about is Windows' own"), a test of #214's, not ours. Earlier heads of this branch ran the five-file set: 995fcb1 296 passed in 271.16s (0:04:31), 52f4bff 295 passed in 513.02s (0:08:33). A scratch merge of 995fcb1, main 60d08a1 and #220's head 1ddb86c, run before #220 landed, gave 306 passed in 837.24s (0:13:57) on the five-file set.

tests/test_bump_leg.py gains ten test cases, 23 to 33 collected (nine functions, one parametrized over draft.yml and draft.yaml): the untracked draft refused by name and never committed, the refusal rewriting nothing, every untracked file named once with its count, moving the draft out letting the bump through, committing the draft first letting it move with the bump, an untracked file with nothing to move left alone, an ignored file refused untouched, an ignored file with nothing to move left alone, and (added in the fix round) a tracked workflow named légs.yml that is not refused as untracked. That last one pins the NUL-separated read in _tracked_workflows: with -z dropped and the split on \n, it fails (REFUSED bump-leg-workflow-untracked ... légs.yml 1 reference(s), exit 2) and the other 32 tests in the file still pass; on the head it passes. It also asserts that git quotes that name in the plain listing, so it cannot pass for want of a quoted name. None of the new tests carries a skip marker, so the Windows job skips none of them; I ran them on Linux only, so the macOS and Windows jobs are their first run there. test_readme_is_short_enough_to_be_read and test_the_handbook_follows_the_readmes_outline are in the set and pass (README 1416 lines; footer blob equals git hash-object README.md). The full suite was not run here; CI runs it on three OSes.

Composes with

This branch holds origin/main eb6df75 (it carries #211 / PR #220, #217 / PR #230 and #214 / PR #246, all landed). Trial merges (git merge-tree --write-tree --name-only <sibling> HEAD) of every sibling branch that touches a file this branch touches, run at the tips below against head 7c41d98:

Sibling Tip Touches of ours Result
fix/bump-leg-rollback-every-failure (#253, PR #264; local and pushed are the same sha) a8d31ee scripts/bump-leg.py CLEAN
fix/validator-child-encoding (local, not pushed) 5757e29 scripts/bump-leg.py CLEAN
origin/main now c3f4987 (one commit past the merged eb6df75: scripts/repo_shape.py and one new test) none CLEAN

Left out

  • scripts/validate-pins.py still reads every workflow file on disk, untracked and ignored included; it is a digest-pinned copy in every project, so moving it is a shape update and not this change's to make, and the refusal is what keeps the two readers agreeing. No issue filed.
  • The README's bump-leg.py paragraph does not spell the refusal's two exits (commit the file first, or move it out of .github/workflows/ until the bump has landed) because README.md sits at its 1416-line cap; they are in the refusal's own text. Raising the cap is a human decision.
  • A tracked workflow file renamed by case alone outside git, on a case-insensitive filesystem (macOS, Windows, core.ignorecase=true), is refused as untracked: git status is clean, so the root counts as clean, but the tracked set is compared by exact spelling, so Legs.yml on disk is not legs.yml in the index, and "commit it first" cannot clear it because there is nothing to commit. Asking git which files are untracked (git ls-files -o -z -- .github/workflows, which applies core.ignorecase) instead of taking a set difference would fix it. Reasoned from the code, not reproduced here (no case-insensitive filesystem in the lane); main bumps such a root fine.

Closes #207

🤖 Generated with Claude Code

Summary by Sourcery

Prevent bump-leg from incorporating or failing on untracked and ignored workflow files while preserving atomic, tracked-only workflow updates.

New Features:

  • Refuse bumps that would rewrite untracked or ignored workflow files, identifying each affected file and reference count before modifying anything, including in dry-run mode.

Bug Fixes:

  • Prevent untracked workflow drafts from being unintentionally added to lockstep bump commits and prevent ignored workflows from failing later during staging.
  • Preserve atomic bump behavior by rejecting the entire operation when an affected workflow is not tracked.

Enhancements:

  • Restrict workflow rewriting and commit planning to files tracked by Git while continuing to leave unrelated or already-updated untracked workflows untouched.
  • Document tracked-workflow handling and the new refusal behavior in the README and handbook.

Documentation:

  • Update user-facing bump-leg documentation to describe tracked workflow processing and refusal of affected untracked or ignored files.

Tests:

  • Add coverage for untracked and ignored workflows, dry-run refusal, atomicity, remediation paths, no-op workflows, multiple references, and non-ASCII tracked filenames.

…so an untracked draft workflow that names the leg is no longer added whole to the lockstep commit and an ignored one no longer dies at `git add` after the rewrite: both are refused by name as `bump-leg-workflow-untracked`, before anything is written and under `--dry-run` as well (#207)

`plan_workflows` walked `.github/workflows/` on disk and the commit then named every rewritten file as an explicit pathspec, so an untracked draft that referenced the leg was staged into "Bump spec leg" as a new file, while the root-clean check had skipped it on the ground that an untracked file cannot reach that commit. The dry run listed the file without saying it was untracked. An ignored draft was no better, only later: the dry run exited 0 listing it, then `git add` refused it after the rewrite ("The following paths are ignored by one of your .gitignore files") and the bump was rolled back with no word about the cause.

- **Tracked files only.** A row is made for a workflow file only when `git ls-files -z -- .github/workflows` holds it. The directory is still walked on disk, because that is the set `validate-pins.py` reads, and `.yml` and `.yaml` are both read.
- **A file git does not track that has a reference to move is a refusal, not a skip.** Skipped, the validator reads its old `@<sha>` off the disk and the bump goes red after the rewrite (rolled back), with a finding that never says the file is not in the repository, after the plan had said `no @<sha> reference names this leg`. The refusal names every such file with its reference count and gives both exits: commit it first (it is then tracked and moves with the bump) or move it out of the directory until the bump has landed.
- **Ignored counts as untracked.** The validator reads an ignored file and the commit's `git add` refuses it, so on main the bump could only fail late; it is now refused before anything is written, under the same code.
- **A file with nothing to move is left alone, unnamed.** No reference to the leg, or the leg already at `--to`: not rewritten, not staged, not refused.
- **Unchanged:** a bump with no such file is byte-identical to main, the tracked-workflow rewrite included (same output, same commit sha, same files). `--help` is unchanged, so `docs/cli.md` is too. The README's paragraph on what the command rewrites and refuses now says "tracked" and names the refusal (README.md stays at its 1416-line cap, so the refusal's two exits are left to its own message), and `docs/handbook.html` says the same, its footer's `README.md blob` moved with the README.
- **Left out:** `validate-pins.py` still reads every file on disk, untracked included. It is a digest-pinned copy in every project, so it is not this change's to move; the refusal is what keeps the two readers agreeing.

Closes #207

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-11T18:57Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 5 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR makes bump-leg.py inspect all workflow files for validator consistency but rewrite and stage only Git-tracked ones; relevant untracked or ignored files now produce a named bump-leg-workflow-untracked refusal before any mutation, including in dry runs, with comprehensive regression tests and synchronized documentation.

Sequence diagram for refusing an untracked workflow during bump-leg

sequenceDiagram
    participant User
    participant Bump as bump-leg.py
    participant Git
    participant Validator as validate-pins.py

    User->>Bump: plan_workflows(root, repository, commit)
    Bump->>Git: git ls-files -z -- .github/workflows
    Git-->>Bump: tracked workflow paths
    Bump->>Bump: Read workflow files on disk
    Bump->>Bump: Find references naming leg at another commit
    alt workflow is tracked
        Bump->>Bump: Plan rewrite
        Bump->>Validator: Validate rewritten workflows
    else workflow is untracked or ignored
        Bump-->>User: Refusal bump-leg-workflow-untracked
        Bump-->>User: Exit 2, no files written
    end
Loading

Flow diagram for tracked workflow validation during bump-leg

flowchart TD
    A["bump-leg.py plan_workflows"] --> B["Walk .github/workflows on disk"]
    B --> C["Read .yml and .yaml files"]
    C --> D["Find references naming the target leg"]
    D --> E{"Reference needs moving?"}
    E -->|No| F["Leave file alone"]
    E -->|Yes| G{"Git tracks file?"}
    G -->|Yes| H["Plan rewrite and staging"]
    G -->|No| I["Refuse: bump-leg-workflow-untracked"]
    I --> J["Exit 2 before writes, including --dry-run"]
Loading

File-Level Changes

Change Details Files
Restrict workflow planning to tracked files and reject relevant untracked or ignored workflows before mutation.
  • Query tracked workflow paths with NUL-safe git ls-files.
  • Continue scanning both .yml and .yaml files on disk to match validator behavior.
  • Refuse each untracked or ignored workflow that contains references needing the bump, including path and reference count.
  • Leave unrelated or already-updated untracked/ignored workflows untouched and unnamed.
  • Apply the refusal during dry runs and before any files, index entries, or commits are changed.
scripts/bump-leg.py
Add regression coverage for tracked-only workflow rewriting and refusal atomicity.
  • Cover untracked .yml and .yaml drafts, including multiple files and reference counts.
  • Verify refusals preserve the root, pin, gitlink, checkout, workflow bytes, and status.
  • Verify moving a draft out or committing it first allows the bump as appropriate.
  • Verify ignored workflows are refused when relevant and ignored workflows with nothing to move remain untouched.
tests/test_bump_leg.py
Document the tracked-workflow policy and the new refusal behavior.
  • Update the README command documentation while preserving the line cap.
  • Update handbook wording and its README blob hash/footer metadata.
  • Clarify that untracked or ignored workflows naming the leg are refused.
README.md
docs/handbook.html

Assessment against linked issues

Issue Objective Addressed Explanation
#207 Ensure bump-leg.py only rewrites and commits workflow files tracked by Git, preventing an untracked draft workflow from being added wholesale to the lockstep commit. ✅
#207 Refuse untracked or ignored workflow files that contain references needing the leg bump, before any files are modified, including during dry runs, so validation does not fail later over a skipped stale reference. ✅
#207 Update the relevant documentation and tests to describe and verify the tracked-workflow restriction and refusal behavior while preserving agreement with validate-pins.py's on-disk workflow reading. ✅

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

…ked or ignored workflow file the bump would rewrite, and a test pins the NUL-separated read, so a tracked workflow with a non-ASCII name is never refused as untracked (#207)

Fix round 1 on #222, from the independent review.

- **The docs say what the code refuses.** README.md and `docs/handbook.html` said the tool refuses an untracked or ignored workflow file "that names the leg". It refuses only one it would REWRITE: one naming the leg at a commit other than `--to`. One already at `--to`, or naming no leg of the project, is left alone and unnamed (`test_an_untracked_workflow_with_nothing_to_move_is_left_alone`). Both now say "it would rewrite", which is shorter, so README.md stays at 1416 lines; the handbook footer's prose follows, and its `README.md blob` is re-taken from `git hash-object README.md`.
- **`-z` has a test.** `_tracked_workflows` reads `git ls-files -z` because git quotes a name with a byte outside ASCII in the plain listing (`core.quotePath`). `test_a_tracked_workflow_with_a_non_ascii_name_is_not_taken_for_untracked` commits `.github/workflows/légs.yml` naming the leg and asserts the bump exits 0, moves it and commits it; it also asserts that git does quote the name in the plain listing, so it cannot pass for want of a quoted name. Measured: with `-z` dropped and the split on `\n`, that test fails (`REFUSED bump-leg-workflow-untracked ... légs.yml  1 reference(s)`) and the other 32 in the file pass; on the head it passes.
- **Unchanged:** `scripts/bump-leg.py` is not touched.
- **Left out:** a tracked file renamed by case alone outside git, on a case-insensitive filesystem, is read as untracked (the tracked set is compared by exact spelling) and is refused with an exit, "commit it first", that cannot clear it. Asking git which files are untracked (`git ls-files -o -z`, which applies `core.ignorecase`) would fix it; named under Left out in the PR.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-11T19:24Z) fix round 1 (independent review, codex is out of quota): README and handbook now say the refusal is for an untracked or ignored workflow file it would rewrite (blob re-taken, README still 1416), a new test pins the -z read with a tracked légs.yml (fails with -z dropped, passes on the head), the PR body's Composes with table is refreshed and the case-only-rename edge is named under Left out; targeted set 296 passed on 995fcb1.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-11T19:30:24Z)

This lane reviewed PR #222 at 52f4bff read-only in its own detached worktrees; nothing authored, no claim.

MERGEABLE WITH NITS. No P1, P2 or P3. Sequencing: whichever of #222 and #220 lands second keeps both footer history clauses in docs/handbook.html and re-sets the README blob.

Reproduced offline (Atlas in local bare remotes, an untracked draft-not-mine-yet.yml naming the spec leg at its pin). Main exits 0, committed 5379eec93551: spec, contracts/spec-pin.yaml, .github/workflows/draft-not-mine-yet.yml, and the draft is a new file in the commit. Head exits 2, REFUSED bump-leg-workflow-untracked, naming the draft, under --dry-run too, and the tree is unchanged. An ignored draft is refused up front, where main died at git add after the rewrite. With --to at the current pin, main committed a "bump" that only added the draft, and head refuses. A bump with no such file matches main.

Findings (at 52f4bff):

Tests:

Merges (git merge-tree --write-tree):

Since this review the branch moved to 995fcb1, one commit: README and handbook now say the refusal is for an untracked or ignored workflow file the bump would rewrite, plus a test, which answers the first nit. This lane did not re-run it.

Report and drivers: opensoft/brett-wip handoffs/xFactory/attachments/lane-openRepoShape-2/review222-52f4bff/.

…workflows-only

The handbook footer conflicted with #220 (#211), which landed first: the footer
keeps both history clauses (#211, then #207) and its `README.md blob` is re-taken
from the merged README (`git hash-object README.md`). README.md is 1416 lines,
AGENTS.md 464.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-11T23:18Z) fix round 2: merged main with #220; footer keeps both history clauses and pins the merged README blob. #220 (#211) landed as eb6df75 and conflicted with this PR only on the docs/handbook.html footer; the merge commit 7c41d98 keeps both clauses (#211, then #207) and re-takes README.md blob from the merged README: 23b0c6a55802788ce0113c00ea476714c4f38252 (git hash-object README.md). README.md is 1416 lines (cap 1416), AGENTS.md 464 (cap 464); the handbook body hunks of both PRs merged without conflict. scripts/bump-leg.py also merged cleanly with main's #217 and #214 edits. Targeted set on 7c41d98 (test_bump_leg, test_pin_validator, test_shape_doctor, test_repo_hygiene, test_cli_reference, plus test_git_output_decoding and test_stdout_encoding): 356 passed, 1 skipped (tests/test_stdout_encoding.py:286, #214's Windows-default test) in 371.92s, including test_the_handbook_follows_the_readmes_outline. The issue's repro re-measured on main eb6df75 versus 7c41d98: the untracked draft is swept into the commit on main (exit 0) and refused by name on the head (exit 2, dry run too), the ignored draft dies at git add on main and is refused before any write on the head, and the no-such-file, nothing-to-move and committed-first scenarios are byte-identical to main. Trial merges: #253 / PR #264 a8d31ee CLEAN, fix/validator-child-encoding 5757e29 CLEAN, origin/main c3f4987 (one commit past eb6df75, repo_shape.py only) CLEAN. The PR body's tests, composes-with and why lines are updated.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 018ce0d into main Oct 11, 2026
5 checks passed
@brettheap
brettheap deleted the fix/bump-leg-tracked-workflows-only branch October 11, 2026 23:46
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #272 via #274, #205 via #232) into fix/adopt-record-submodule-placement

Brings in 23e16ae (#251), 60588d5 (#206), 018ce0d (#207), 67a398a (#272)
and d94d155 (#205) on top of 5751460. No rebase. The first four change
none of this branch's files. d94d155 changes shape-doctor.py and
tests/test_shape_doctor.py, as this branch does.

One conflict, in shape-doctor.py's module docstring, under EXIT CODES.
Both sides added a clause to exit 1's list of findings: #205 "a manifest
is there and does not load", and #189 "or, for a submodule, a leg other
than the one `project.yaml` records for it". Both are kept: #205's in its
place in the list, and #189's after the path-policy clause it qualifies.
Only the docstring's first line reaches `--help`, so docs/cli.md is
unchanged.

Merged without a conflict, and checked:

- `check_placement`: #205 adds its `waiting_row` gate after the
  family-holder return and before `ctx.legs()`. The merged function is
  this branch's plus exactly those eight lines. So a `project.yaml` that
  does not load makes the placement row `n/a`, waiting on the `manifest`
  row, before the record is read. The record is read from `ctx.manifest`,
  which #205 now loads once, with the same `load_yaml`, so a manifest that
  loads gives the record it always gave.
- #205 removes `Context._declares`, and nothing on this branch names it.
  This branch leaves `placement_row` unchanged, and #205's gate calls it as
  (NA, reason, None, detail).
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py, scripts/shape_materialize.py,
  shape-doctor.py, validate-manifest.py or update-shape.py. Each merged
  module's names are the base's plus what either side added. Every
  definition only one side changed is that side's text. The two that both
  changed are the docstring above and `check_placement`.
- These commits leave adopt-project.py, scripts/repo_shape.py and
  scripts/shape_materialize.py untouched. So the record's writer and
  reader are the ones the round trip and the revert table ran on at
  a01ab02, and the record is still spelled by `double_quoted`.
- AGENTS.md is untouched: 472 lines, under the cap of 472.
- The branch's diff against main is still #189 alone: 12 files, +1670
  -157. The one deletion more than against 5751460 is the EXIT CODES
  sentence above, which now rewraps two of #205's lines.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #205, #215, #241 and #249/#250 merge with no conflict, and `_entry_findings` still hands `_leg_findings` the tree and ends with the root-symlink findings

origin/main moved 60588d5 -> b30595c while the targeted runs of 8f624aa
went: 018ce0d (#207 via #222), 67a398a (#272 via #274), d94d155 (#205
via #232), c2ff2b0 (#215 via #240), c3a9645 (#241 via #256) and b30595c
(#249 and #250 via #284). It is merged again because c2ff2b0 changes
adopt-project.py and b30595c changes scripts/shape_materialize.py's
`render`.

Conflicts: none. `git merge-tree --write-tree` gave tree be093a5, and
the merge stages the same tree.

Checked on that tree:
- `_entry_findings` still calls `_leg_findings(plan, tree)` and ends
  with `_root_symlink_findings(plan, source, tree)`.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, none is loaded but bound nowhere (AST),
  and main changed the signature of no function this branch calls.
- Both sides are intact by +/- line sets: each side's change against the
  merge base 60588d5 reappears, line for line, against the other side.
- c2ff2b0 adds helpers to tests/conftest.py (`planned`, `GITLINK`,
  `findings` and others) and no fixture. tests/test_adopt_root_symlinks.py
  imports its conftest names one by one and none of these, so its own
  `planned` and `GITLINK` are unaffected.
- b30595c's `render` spells a double-quoted YAML value with
  `double_quoted`; this branch's walker reads `assembly_root_paths()` and
  never renders.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…usals name a value to change instead of a command, so it no longer repeats AGENTS.md's old "each one names what to run instead" as if it held for `scaffold-leg-path-collides` and `scaffold-pin-collides` (#288)

The page's rules for assistants quote AGENTS.md ("Read a refusal rather than retrying: each one names what to run instead"). The previous commit on this branch qualified that sentence in AGENTS.md, because two refusals name a VALUE to change and no command, so the page's copy had become the absolute sentence with an undocumented exception that #288 exists to remove. It now says the same in the page's voice: each one names what to run instead, except two that name a value to change, a `--spec-path` or `--code-path` over a path the shape writes and a `--pin` the shape cannot write, both before anything is created. The codes are left to AGENTS.md, which the section already links as the full text. The footer's history names #288 beside #207's, as #222 did for its own page edit.

`README.md blob` in the footer is unchanged: this commit moves no byte of README.md, and `git hash-object README.md` still equals it. `tests/test_repo_hygiene.py`, the only test that reads the page, passes over these bytes.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
… a commit git refuses included, rolls the pin, the workflow files, the leg's checkout and the index back and says whether all of it went back, so a workflow file it cannot write, a validator line the stdout cannot encode, a signer that cannot sign or an interrupt no longer leaves the pin rewritten and the leg on the new commit (#253) (#264)

`_apply_rewrite` promised in its docstring to roll every byte back and re-raise, but its handler was `except (Refusal, CommandFailed)`, and `main()` catches the same two. A `PermissionError` on a workflow file it rewrites, a `UnicodeError` from echoing a validator's output, or an interrupt ended in a traceback with the pin rewritten, the leg on the new commit and the index changed, and no finding about any of it. On main at 39ac59c the issue's own steps for a read-only workflow file and for an undecodable validator line each exited 1 with a traceback, `git status` showing the pin and the gitlink modified, and the leg's HEAD on the new commit.

The lane's review of the first head found the rollback short of its own promise in four more places:
- The commit ran after `_apply_rewrite` returned, outside the rollback. A commit git refuses (a `commit.gpgsign` signer that cannot sign, a `commit-msg` hook) left the bump staged and the leg on the new commit, and the same command run again was refused as `bump-leg-root-dirty`.
- A `bump-leg-validators-red` refusal said every byte was rolled back when a rollback step had failed.
- A second Ctrl-C during the rollback skipped the steps after it, the index included.
- A forward checkout that moved the leg and then failed (a `post-checkout` hook) or was interrupted left the leg moved.

Re-verification found a fifth. A Ctrl-C that cut the forward checkout off before git moved HEAD left the new commit's files in the leg, and the report still said the tree was exactly as it was.

The handler is now `except BaseException`, and the rollback is helpers instead of a loop and a `try`:
- `_apply_rewrite` keeps the commit inside the `try` as its last statement, with `cmd_bump` building the message first and handing it in. It reads the root's HEAD just before the commit, and the leg's `git status` (`_leg_status`: `git status --porcelain -z --untracked-files=all`, as bytes) before the checkout.
- `_head_guard` compares the root's HEAD with the one read before the commit after any failure.
  - Unmoved: every byte is rolled back.
  - Moved, because the failure came after the commit landed (an interrupt, or the `rev-parse` that reads the new HEAD back): nothing is rolled back under the commit, and the report says `The bump was committed as <sha>`.
  - Unreadable: nothing is rolled back, and the report says the commit could not be read, since rolling back under a commit that did land is the worse mistake.
- `_roll_back` puts back, in the order it always did, every file written, the leg's checkout and the index, and returns whether all of it went back.
- `_put_back` attempts each step whatever the steps before it did, and prints, never raises, a step that fails. A second Ctrl-C names its step as `interrupted` and the steps after it still run. No signal handler is installed.
- `_restore_file` writes a file back only when it no longer holds its original bytes, so a read-only file whose write never opened it is not reported as unrestorable.
- `_restore_leg_if_moved` restores the leg when its branch or HEAD differs from where it stood, so a forward checkout that moved the leg and then failed or was interrupted is put back, and a leg that never moved is not checked out again through a hook that refused. Either way it then compares the leg's `git status` with the one read before the checkout and raises `LegNotAsFound` when they differ. Nothing in the leg is removed: an untracked file of the operator's own is allowed there, and the rollback cannot tell it from one the checkout wrote.
- A report says one of three things: every byte is back (`ROLLED_BACK`), `THE ROLLBACK WAS NOT COMPLETE` (`NOT_ROLLED_BACK`), or the commit landed. `_with_verdict` puts the sentence in a `Refusal`, replacing `bump-leg-validators-red`'s own when the rollback did not earn it. Where it goes depends on the failure:
  - a `Refusal` leaves as itself, with the verdict in its text;
  - an `OSError` or a `UnicodeError` that nothing committed leaves as `REFUSED bump-leg-rewrite-failed` naming the exception, exit 2, the way `update-shape.py` turns an unwritable README into `update-readme-unwritable`;
  - a `CommandFailed` leaves as `REFUSED advancing a leg`, exit 2, after one stderr line saying what became of the tree;
  - an interrupt or a defect is re-raised as itself after the same line.
- The module docstring's refusal list gains one bullet. No `--help` text changes (`render-cli-reference.py --check` is ok), and `main()` is not touched.

Tests: `tests/test_bump_leg_rollback.py` is new, 24 cases in 13 functions, all passing at this head. Each case compares what the bump could change with what it was before the bump: the root's HEAD, the pin's bytes, every workflow's bytes, the gitlink, `git diff --cached --quiet`, `git status`, the leg's HEAD and the branch the leg stood on. A case where the leg itself cannot be put back compares what can still be compared. The cases are:
- a read-only tracked workflow file, run as a subprocess (the issue's reproduction);
- a write that dies half way (`ENOSPC` after 20 bytes);
- a validator's output that cannot be decoded, and one the stdout cannot echo, the second raised from a stream that fails, not from `PYTHONIOENCODING`, because #214's `escape_what_stdout_cannot_encode` reconfigures that stream at the top of `main()`;
- an interrupt while writing and one while validating, and a defect in the tool;
- the leg's own restore failing beside a red validator, an unwritable file and a failing `git add`, and a second interrupt inside a rollback step;
- a forward checkout that moved the leg and then failed (an interrupt, a `post-checkout` hook), and one that left files in the leg (cut off by an interrupt, cut off with exit 130, and moved then interrupted);
- an untracked file of the operator's own in the leg beside a red validator, and `--to` the commit already pinned, which detaches the leg where it stood;
- a commit git refuses (a `gpg.program` that does not exist, a `commit-msg` hook);
- a failure after the commit landed (an interrupt, a `Refusal` from reading HEAD back, an `OSError`), and a HEAD that cannot be read once the commit was asked for.

Run against main's `scripts/bump-leg.py` (c7c02fa), 22 of the 24 fail for the issue's reasons. The read-only run exits 1 with the `PermissionError` traceback. The `OSError`, `UnicodeError` and rollback-step cases escape `main()`. The interrupts and the defect stop at `pin is not as it was found`, and both refused commits leave the pin changed. The two that pass are guards: an untracked file of the operator's own in the leg beside a red validator, and a leg detached at the commit it stood on, which main's own red-validator rollback already gets right. Twenty mutants of the code at 5269aac were all killed.

Left out:
- The README and `docs/handbook.html`, which say a refusal leaves the tree as it was found. That is now true for a refused commit as well. Where it is not (a rollback step itself fails, or a cut-off checkout left files in the leg) the refusal says THE ROLLBACK WAS NOT COMPLETE. #222 edited the same README paragraph.
- Removing the files a cut-off checkout wrote into the leg. It is not provably safe: `git checkout` overwrites an IGNORED file without asking, and a new `.gitignore` written before the cut can make such a file show as untracked. Until the operator removes them, git refuses the re-run's checkout (`untracked working tree files would be overwritten`).
- A file git ignores. The leg's `git status` does not list it, so a cut-off checkout that wrote only ignored paths leaves files no report names. `--ignored` would read every build output in the leg twice per bump. Ignored files do not block the re-run.
- A leg `post-checkout` hook that refuses every checkout, the restoring one included. That step is reported as not put back even when git did move the leg back. This is conservative, not wrong: a failed hook can leave work undone that neither HEAD nor `git status` shows (git-lfs's sets the read-only bit on lockable files).
- An interrupt outside a rollback step, such as between two steps, because no signal handler is installed from a library function. An uncaught `KeyboardInterrupt` is not turned into a refusal.
- Two nits of re-verification 2: no test holds `--untracked-files=all` against `normal`, and an ignored file at a path the target commit tracks is lost on rollback, as on main.
- #289, a committed workflow file or pin that is not UTF-8, which ends in a traceback before anything moves; and #285, which covers `update-shape.py`, whose `cmd_apply` also rolls back only on `Refusal` and `CommandFailed`.

Reviewed adversarially by this lane at a8d31ee (CHANGES NEEDED: a refused commit, three smaller rollback gaps and a nit), re-verified at 67a1163 after fix round 1 (CHANGES NEEDED: a cut-off checkout reported as put back) and at 5269aac after fix round 2 (MERGEABLE WITH NITS).

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…le-files

The one conflicted file is scripts/bump-leg.py, in three hunks in
`plan_workflows`, all against #222's tracked-files change (#207): the helper
block keeps main's `_tracked_workflows` and this branch's `_not_utf8`,
`_leg_pin_text` and `_workflow_texts`; the docstring keeps both paragraphs;
the loop head reads `tracked`, `untracked`, then
`for path, text in _workflow_texts(root, workflows)`, main's own
`text = path.read_text(...)` line going with the walk it belonged to. The
net diff against main is scripts/bump-leg.py and
tests/test_bump_leg_unreadable.py, nothing else.

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 12, 2026
…ten and committed under git's spelling, so a `legs.yml` renamed `Legs.yml` by case alone outside git on macOS or Windows is no longer refused as `bump-leg-workflow-untracked` while `git status` is clean, with an exit that could not clear it (#332) (#344)

`plan_workflows` walks `.github/workflows/` on disk and made a row for a file only when the walk's spelling was in git's tracked set, compared exactly. On a disk that ignores case (APFS and NTFS by default) a tracked `legs.yml` renamed outside git to `Legs.yml` is one file under both names. Git calls the root clean, so `_ensure_root_clean` passed, and the walk's `Legs.yml` was then refused as untracked, with "commit it first" as the exit when there was nothing to commit.

- **A name git does not hold is the tracked file when the filesystem says it is.** `_tracked_spelling`: a name in git's tracked set is its own spelling, as before. Any other name is the tracked file only when exactly one tracked name in the same directory is missing from the listing and `os.path.samefile` resolves it to this very file. `_same_file` is the one place the tool asks, and the seam the tests replace. A tracked name the walk lists is a file of its own, a hard link included. A name that two tracked spellings both resolve to (an index holding `legs.yml` and `LEGS.yml`) is still refused as untracked, because one rewrite cannot keep two index entries in step.
- **The row carries git's spelling.** The dry run lists `.github/workflows/legs.yml`, the commit's `git add` and pathspec name it, and the rewrite reaches the one file through it. Git matches a commit's pathspec against the index by exact spelling whatever the disk does, so the disk's spelling could never have been committed. Measured with `core.ignorecase=true`: `git commit -- Legs.yml` refuses with "pathspec 'Legs.yml' did not match any file(s) known to git". The disk keeps its own spelling, and nothing is renamed.
- **The filesystem is asked, not `core.ignorecase`.** The issue suggested asking git which files are untracked (`git ls-files -o`). On a disk that keeps case, with `core.ignorecase=true`, git lists a separate `Legs.yml` beside the tracked `legs.yml` as neither untracked nor stageable (`git add Legs.yml` is a no-op). Tried as a mutation, that direction took the second file for tracked, and the bump ended `bump-leg-validators-red` after the rewrite instead of a refusal before it. With `samefile` it is refused by name before anything is written, whatever the setting says.
- **Tests.** Four functions in `tests/test_bump_leg.py`, seven cases. `test_a_tracked_workflow_respelled_by_case_moves_under_gits_spelling` is the issue on its own disk. A tracked `legs.yml` is renamed `Legs.yml` outside git and `git status` is clean. The dry run lists `.github/workflows/legs.yml`, and the bump exits 0 and commits exactly `spec`, the pin and `.github/workflows/legs.yml`, with the rewrite in HEAD's blob. The project's validator is green and the status is still clean. It runs only where a probe of the test's temporary directory finds a disk that ignores case (the macOS and Windows jobs), and is skipped on one that keeps it. `test_a_respelled_tracked_workflow_is_planned_under_gits_spelling` is the rule on every disk, with `_same_file` answering as a disk that ignores case would. It fails on main with the issue's refusal. `test_a_name_two_tracked_spellings_answer_to_is_refused_as_untracked` runs everywhere. `test_a_second_spelling_that_is_a_second_name_is_refused_whatever_git_says` is parametrized over a separate file and a hard link, each with `core.ignorecase` true and false, and runs only where the disk keeps case. Four mutations of the fix are each caught by at least one of these tests: the row carrying the disk's spelling, any number of matches accepted, the listing check dropped, and asking `git ls-files -o`.
- **Unchanged:** main's and this branch's `bump-leg.py` were each run over a copy of one project, on an ordinary bump (a tracked `legs.yml` naming the leg). Both print the same dry-run and real output and write the same commit message and the same tree. The refusal's id, text and exits are unchanged. `--help` is unchanged, so `docs/cli.md` is too, and README's "an untracked or ignored workflow file it would rewrite" stays true.
- **Left out:** an index that already holds two spellings of one workflow, on a disk that ignores case and lists one of them exactly, still has that one rewritten and committed while the other keeps its old `@<sha>` in the tree. That defect predates #222. A name APFS stores in another Unicode normalization form than the index falls under the same `samefile` rule, but is not tested here.

Closes #332

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant