Skip to content

update-shape.py: apply refuses to rewrite a file that has a second, hard-linked name, and a rollback that cannot put a byte back names it with the line that finishes it and puts back the rest (#422, #428) - #560

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

Conversation

@brettheap

@brettheap brettheap commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #422 (comment)
Claim: #428 (comment)

What changed and why

The update-shape rollback pair, one pull request by the 2026-10-14 triage. Three commits: #422, #428, then the fold of R560's review (below).

#422: a file with a second, hard-linked name. Rollback.write opens every file apply rewrites r+b and overwrites its bytes in place. So when a pinned copy, contracts/shape-pin.yaml, the manifest or (since #511) the root's own .gitattributes was kept as a hard link, its other name was rewritten too: a file outside the root, or a cp -al snapshot of the whole root, that no check showed, no pin covers and git does not record. Only the README's reader asked (#152).

#428: a rollback that cannot put a byte back. undo restored with a bare write_bytes and removed what --add made with a bare unlink and rmdir. One OSError escaped _rolled_back and main as a traceback, exit 1, with the refusal lost and the paths after it never tried.

  • Every restore and every removal is attempted, each under its own except OSError. The refusal that started the rollback is still the one reported, exit 2, with ROLLED BACK IN PART under its detail; a CommandFailed start prints with its step, as before.

  • A file is written back only where it no longer holds its old bytes, as bump-leg.py's _restore_file does (bump-leg.py _apply_rewrite rolls back only on Refusal and CommandFailed, so a PermissionError on a workflow file it rewrites, or the UnicodeEncodeError that #230's errors="replace" moves the decode crash to, leaves the pin rewritten and the leg on the new commit, with a traceback and no rollback #253).

  • Nothing is written or removed at or through a symlink, or written through a second name, that reached a path after this run wrote it (the review fold). Such a path is listed, so the block never says "exactly as it was" while one is left.

  • A restore that fails is listed under NOT put back with a line:

    • git -C <root> --literal-pathspecs checkout HEAD -- <path>, where that writes exactly the old bytes and changes nothing else: HEAD commits a blob there, the index holds the same blob, cat-file --filters of it equals the old bytes, and the directory takes a new file (a checkout unlinks and creates).
    • Otherwise the old bytes go into the object store with git hash-object -w --stdin, and the line is git -C <root> cat-file blob <id> > <path>, which writes in place.
    • Where git keeps nothing either, the line says the bytes are nowhere this run can point at.
    • For a path a link reached: a symlink is left to the operator, with the line to run once it is not a link; a second name is broken from this side first, with update-rewrite-hardlink's cp -p / mv -f line.
  • The HEAD claim, measured on main 5ed3e5d before building on it (in-process, red validators, ledger read at undo):

    • a --branch run over a clean tree held HEAD's bytes at every path it wrote (copy, pin, manifest);
    • an apply without --branch over an earlier in-place apply held HEAD's bytes at none of the three;
    • a --branch run accepts a CRLF working-tree manifest (hash-object normalises it to HEAD's blob) whose checkout would be LF.

    So the question is asked per path, by bytes, as a checkout would write them.

  • A removal that fails is rm -- <path> or rmdir -- <path>. A directory still holding an --add file that would not go is listed after it, not kept for "something this run did not write".

  • _said prints nothing under Put back: when nothing was.

Review folded (R560, APPROVE WITH NITS at 5d542c1)

R560's findings, as the coordinator relayed them, are folded in the third commit:

Verification folded (round 2, at 7b68d80 and e0c7185)

The verification of the third commit (R560v at 6d43f6e, APPROVE WITH NITS) asked for five items. They are the fourth and fifth commits:

  • SHOULD-3: a test where contracts/ is moved out of the root and linked back (WINDOWS_SKIP). The undo never writes through it; it lists the path with a line to run "once contracts is this root's own directory again". The test pastes that line after moving the directory back.
  • SHOULD-4: the same for a directory linked in above an --add path. Nothing is removed under it.
  • NIT-7: a validator that deletes contracts/ gets the checkout line again. The write question is asked of the nearest directory that exists, since git makes the rest. "Make it writable" is said only of a file that is there. The object-store line for such a path is prefixed mkdir -p <dir> && (fifth commit).
  • NIT-8: that "own directory again" wording, asserted in SHOULD-3's test.
  • Sonar S9073: nine composite asserts are split, each half with its own message.

R560v verified e0c7185: APPROVE WITH NITS. The two NITs it left are optional, and neither is worth a CI cycle on its own:

  • NIT-A: nothing pins the lexists choice behind "make it writable", though R560v showed by a real run that the alternative prints a line that fails.
  • NIT-B: a residual corner that is not a regression.

CI at e0c7185 was green on all three OSes, and SonarCloud showed 0 open issues.

Tests

New: tests/test_update_shape_hardlink.py (#422) and tests/test_update_shape_partial_rollback.py (#428). The #428 tests run in process and patch Path.write_bytes, unlink or rmdir for one path, only while undo runs, or have the stubbed validators do what a validator could (put a file back, move it out and link it, give it a second name); nothing is patched at the write. One POSIX-only test chmods the pin and contracts/, where the permission is the point. os.link needs no privilege on NTFS, so the hard-link tests run on all three platforms; the sh -c pastes and the symlink cases are WINDOWS_SKIP.

The brief's targeted set at the final head (the two new files, test_update_shape.py, _rewrite_symlink, _add_ignored, _add_symlink, _add, _landing, _merged_away, test_shape_doctor.py, test_repo_hygiene.py, test_cli_reference.py, test_agents_md_codes.py): 820 passed, 6 skipped, 0 failed in 9:48 on Linux (Python 3.12.3, pytest 9.1.1, git 2.43.0, git-filter-repo on PATH). The six skips are Windows-only: three NTFS junction tests and three PowerShell-line tests. The new files hold 42 tests (24 + 18). At the first head the same set was 812 passed, 6 skipped. CI's three OSes are the gate.

Mutants, each run over the two new files at the final head (42 tests):

  • W1, drop the hard-link check in apply: 9 fail. The four late-arrival tests still pass: Rollback.write refuses there, which is their job.
  • W2, restore the bare write_bytes in undo: 14 fail, on the PermissionError traceback or the write through a link.
  • M1, drop the index condition of the checkout line: 1 fails (the staged-blob test).
  • M2, write a file that already holds its old bytes: 1 fails.
  • M3, refuse a linked .gitattributes whose block --add leaves as it is: 1 fails.
  • No symlink check in _restore: 1 fails. No second-name check in _restore: 2 fail. No link guard in _remove: 1 fails. No directory condition on the checkout line: 1 fails.
  • At the first head, also: stopping the restore loop after the first failure failed 4; taking not passed, the check line removed, the removal handler dropped, and cat-file blob for --filters each failed at least 1.

Not covered end to end: .gitattributes with --add on a real adopted root. The test stands in with a scaffolded root whose .gitattributes carries the #471 block and whose pin row was moved to it, because an adoption costs a filter-repo run.

Shared files

update-shape.py is shared with lane 4's P8 (#431/#437/#535) and P4 (#536), on other lines. This PR touches:

  • the module docstring;
  • link_refusal's docstring and one comment in ReadmeShapeLine._classify (sentences this PR made false);
  • one line in cmd_check, after the conflicts REFUSAL AHEAD;
  • Rollback.write, undo, _said and new helpers in the class;
  • a block of new functions after _refuse_linked_copies;
  • one line after _refuse_linked_copies(rows) in cmd_apply.

One comment changes in tests/test_update_shape_add_symlink.py. No import changed. git merge-tree --write-tree of this head is clean against main 5ed3e5d and against main b8b7062 (#518, which landed during the review round and changes nothing this PR uses in shape_materialize.py), against lane 3's #554 head 4619757, and against #558 (fix/update-shape-small-fixes) at fabdd7e. Every helper the new tests import from test_update_shape.py, test_update_shape_add.py and conftest.py is still defined on #554's head.

The doctor is unchanged, and a hard-linked copy is not drift.

Since then, #562 (P4) and #558 (P8) have landed. This branch took two clean merges of main for ruling 27, with no hand resolution: bbcfd34 at f10824b and 64fc9a8 at a541b04. Each merge commit's tree equals git merge-tree --write-tree's. At a541b04, every tests/test_update_shape*.py plus test_update_add_codes_documented.py, test_repo_hygiene.py, test_agents_md_codes.py and test_cli_reference.py gave 734 passed and 3 skipped, all Windows-only (NTFS junctions).

v1.1.0.

Closes #422
Closes #428

🤖 Generated with Claude Code

Brett Heap and others added 2 commits October 14, 2026 18:06
…he manifest or a `.gitattributes` whose block `--add` extends while it has a second, hard-linked name, as `update-rewrite-hardlink` before `confirm` and again in `Rollback.write`, and `check` says so first in a `REFUSAL AHEAD` line, so a file outside the root or a `cp -al` snapshot of it is no longer rewritten through its other name (#422)

`Rollback.write` opens every file `apply` rewrites `r+b` and overwrites
the bytes where they are. A file with a second name on the filesystem is
the same bytes under both, so the other name changed too: a file outside
the root, or a whole `cp -al` snapshot of it, that no `check` showed, no
pin covers and git does not record. Only the README's reader asked
(`st_nlink > 1`, PR #152).

One question, `hard_links`: `os.lstat(path).st_nlink` for a regular file
with more than one name. `apply` asks it before `confirm` and before any
write, of every file it will rewrite (`hard_linked_rewrites`): each
`upstream-changed` copy, `contracts/shape-pin.yaml`, the manifest, and the
root's own `.gitattributes` when `--add` would extend its #511 block,
asked of `extend_eol_block` without writing. They are refused together as
`update-rewrite-hardlink`, whose remediation prints, per file, a
POSIX-quoted `cp -p` and `mv -f` that put a copy of the file in its place,
so the other name keeps its own bytes and git sees nothing to commit.
`Rollback.write` asks the same question after its symlink walk, for a
name that arrives while `confirm` waits, and the refusal arm puts back
what was written before it. Not by replace: `os.replace` would leave the
other name holding the old bytes and the link broken, which no undo puts
back. `README.md` is not asked: its reader still keeps a hard-linked
README, and says so.

`check` says so in one `REFUSAL AHEAD` line after the conflicts one, and
no row changes its verdict: a hard-linked copy holds the right bytes, so
it is no drift, and the doctor's `shape currency` row, which reads the
rows, is untouched. `link_refusal`'s docstring said `Rollback.write`
still wrote through a hard link; it names the new refusal instead.

tests/test_update_shape_hardlink.py, on #347's harness: a pinned copy,
the pin and the manifest each hard-linked, with and without `--branch`,
refused with the tree as it was by `lstat` and by bytes, the link still
one file and the other name unchanged; several named together; the
printed lines pasted, then the same command applying (POSIX); `check`'s
line, with every verdict, count and exit as for the same root unlinked;
no link, a linked file `apply` does not rewrite, and a linked README, all
applying; `.gitattributes` refused only where `--add` would extend its
block; a name arriving in `confirm`, refused by the ledger and rolled
back; the ledger's guard and `hard_links` alone. `os.link` needs no
privilege on NTFS, so all but the paste run on the three platforms.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oval under a handler of its own and lists each one it cannot finish with the line that finishes it by hand, `git checkout HEAD` where that writes the file's old bytes and otherwise `git cat-file blob` from the object store, so a file that will not take its bytes back no longer ends `apply` in a traceback, exit 1, with the refusal that started the rollback lost and the paths after it never tried (#428)

`undo` restored each file with a bare `write_bytes` and took away what
`--add` made with a bare `unlink` and `rmdir`. An `OSError` on any of them
escaped `_rolled_back` and `main`: a traceback, exit 1, the refusal that
started the rollback surviving only as `__context__`, and the loop stopped
at that path, which could leave the pin put back under a re-pinned
manifest. The git half already kept the rule `bump-leg.py`'s rollback
states (#253): what cannot be put back is named, with the command that
finishes it, and the refusal that started it is still the one reported.

The byte half keeps it now. `_restore` writes a file back only where it no
longer holds its old bytes, as `bump-leg.py`'s `_restore_file` does, and a
write that fails goes under `left` with a line. The line is `git -C <root>
--literal-pathspecs checkout HEAD -- <path>` only where that writes exactly
those bytes and leaves the index as it is: HEAD commits a blob there, the
index holds the same blob, and `cat-file --filters` of it, as a checkout
writes it, is the old bytes. Measured on main: a `--branch` run over a
clean tree held HEAD's bytes at every path it wrote; an `apply` without
`--branch` over an earlier one left uncommitted held them at none of the
copy, the pin and the manifest; and a `--branch` run accepts a CRLF
working-tree manifest that a checkout writes back as LF. Otherwise the old
bytes go into the object store with `git hash-object -w --stdin` and the
line is `git -C <root> cat-file blob <id> > <path>`; where git will keep
nothing either, the line says the bytes are nowhere this run can point at.
`_remove` lists a removal that fails with `rm --` or `rmdir --`, and a
directory still holding an `--add` file that would not go is listed after
it, never kept for "something this run did not write". `_said` says
`nothing` under `Put back:` when nothing was. The refusal that started the
rollback is still the one reported, exit 2, with `ROLLED BACK IN PART`
under its detail, and a `CommandFailed` start still prints with the step
it failed in.

tests/test_update_shape_partial_rollback.py, in process, with
`Path.write_bytes`, `unlink` or `rmdir` refusing one path only while
`undo` runs and nothing patched at the write: the manifest left with its
checkout line, which run puts the tree back as it was; the first path
failing with every later one restored; `main` exiting 2 with no traceback;
a `CommandFailed` start; a copy HEAD does not commit, kept as a blob whose
line, pasted, puts it back (POSIX); a manifest a checkout would write with
other line endings, kept as a blob too; nothing put back and nothing kept;
an `--add` file and the directory above it, and a directory alone, with
their lines pasted (POSIX).

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

sourcery-ai Bot commented Oct 14, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR protects apply from corrupting external or snapshot files through hard links and hardens rollback so individual restore/removal failures no longer mask the original refusal or prevent subsequent cleanup, with comprehensive in-process and cross-platform coverage.

Sequence diagram for hard-link protection during apply

sequenceDiagram
    participant User
    participant Apply as apply
    participant Check as hard_linked_rewrites
    participant FS as Filesystem
    participant Rollback as Rollback.write

    User->>Apply: cmd_apply()
    Apply->>Check: hard_linked_rewrites(root, rows, kind, taking)
    Check->>FS: hard_links(path)
    FS-->>Check: link count
    alt hard-linked rewrite found
        Check-->>Apply: hard_link_refusal()
        Apply-->>User: update-rewrite-hardlink, exit 2
    else no hard-linked rewrite
        Apply->>Rollback: write(path, data)
        Rollback->>FS: hard_links(path)
        alt link arrives while confirm waits
            Rollback-->>Apply: hard_link_refusal(arrived=True)
        else safe to write
            Rollback->>FS: overwrite bytes in place
        end
    end
Loading

Sequence diagram for partial rollback after restore failures

sequenceDiagram
    participant Apply
    participant Rollback as Rollback.undo
    participant Restore as _restore
    participant Remove as _remove
    participant FS as Filesystem

    Apply->>Rollback: undo()
    loop each written path
        Rollback->>Restore: _restore(path, original, done, left)
        Restore->>FS: read current bytes
        alt bytes differ
            Restore->>FS: write_bytes(original)
            alt restore fails
                Restore->>Rollback: append NOT put back command
            else restore succeeds
                Restore->>Rollback: append restored status
            end
        else original bytes already held
            Restore->>Rollback: append restored status
        end
    end
    loop each created path in reverse
        Rollback->>Remove: _remove(path, stuck, done, left)
        Remove->>FS: unlink() or rmdir()
        alt removal fails
            Remove->>Rollback: append NOT put back command
        else removal succeeds
            Remove->>Rollback: append removed status
        end
    end
    Rollback-->>Apply: _said(done, left)
    Apply-->>Apply: retain original refusal and exit 2
Loading

File-Level Changes

Change Details Files
Prevent in-place rewrites from mutating files through hard links.
  • Detect regular files with multiple directory entries using lstat and st_nlink.
  • Refuse all files that apply would rewrite before confirmation or writes, including pinned copies, pin, manifest, and applicable .gitattributes.
  • Add a late-arrival guard in Rollback.write and preserve the existing README hard-link behavior.
  • Print POSIX-quoted copy-and-replace remediation commands while leaving row verdicts unchanged.
  • Advertise hard-link refusals during check without treating them as drift.
update-shape.py
tests/test_update_shape_hardlink.py
Make rollback resilient to restore and removal failures while preserving actionable recovery instructions.
  • Attempt every file restoration and created-path removal independently instead of aborting on the first OSError.
  • Avoid rewriting files that already contain their original bytes.
  • Report failed restores with either a filtered HEAD checkout command, an object-store cat-file command, or an explicit unavailable-bytes message.
  • Report failed --add file and directory removals with rm/rmdir commands, including dependent directories after failed files.
  • Retain the initiating refusal and exit status, distinguish partial rollback, and print nothing when no rollback action succeeded.
update-shape.py
tests/test_update_shape_partial_rollback.py

Assessment against linked issues

Issue Objective Addressed Explanation
#422 Prevent apply from rewriting any file it will modify in place when that file has multiple hard links, refusing before confirmation or any write and identifying the affected paths with a remedy. ✅
#422 Have check warn when apply would refuse because a file to be rewritten has another hard-linked name, without changing the row verdicts. ✅
#422 Recheck for hard links at the write itself so a link created after the initial check cannot be written through. ✅
#428 Make byte rollback resilient to individual restore and --add removal failures: catch OSError per path, continue attempting all remaining paths, and report every item that could not be restored or removed with a practical manual recovery command where possible. ✅
#428 Preserve and report the failure that initiated rollback, including its refusal details and exit status 2, while attaching a “ROLLED BACK IN PART” report that distinguishes completed rollback actions from paths left for manual recovery. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Brett Heap and others added 3 commits October 14, 2026 18:48
…nk or a second name that reached a path after this run wrote it, and lists the path instead of saying the tree is exactly as it was, and its by-hand line is `git checkout HEAD` only where the index holds HEAD's blob and the directory takes a new file, so every line it prints is one that works (#422, #428)

The independent review of PR #560 (R560, APPROVE WITH NITS at 5d542c1)
found these, folded here:

`undo`'s restore asked nothing before writing. The project's validators
run between `Rollback.write` and the undo, and one that moved
`project.yaml` out of the root and linked it back had the old bytes
written at the link's end, under "the tree is exactly as it was" over a
root holding a link (main did the same). `_restore` now asks the two
questions the forward write asks, `symlink_on_the_way` and `hard_links`,
and a path either answers is not written: it is listed under `NOT put
back`, with what stands there and a line that holds. For a symlink, the
bytes go back once it is not a link, which the line never removes for
the operator. For a second name, the line first puts a copy of the file
in its place, with the `cp -p` / `mv -f` line `update-rewrite-hardlink`
prints (`_copy_over_itself`), because a `cat-file` redirect writes in
place. `_remove` likewise never unlinks a link that replaced an `--add`
file, which #239 measured as deleting the operator's link, nor removes
under one.

The checkout line was offered where it could not work. `git checkout`
unlinks the file and creates it again, so with `contracts/` at 0555 the
printed line failed however writable the pin was made. It is offered
only where the directory takes a new file now; elsewhere the `cat-file
blob ... >` line writes in place. The index condition it already had is
pinned by a test, as are two other behaviours the review's mutants showed
untested: a file already holding its old bytes is not written again, and
a linked `.gitattributes` whose block already names the `--add` file is
not refused.

Two comments this pull request had made false are corrected:
`ReadmeShapeLine._classify`'s, which said `Rollback.write` overwrites any
hard-linked file, and the one in tests/test_update_shape_add_symlink.py
that said a hard-linked copy is still rewritten.

Tests, in the two new files: an index holding another blob; `contracts/`
shut (POSIX); a file already put back; a symlink arriving before the
undo (POSIX), its line run back to the tree as it was; a second name
arriving, with its lines pasted (POSIX); a link replacing an `--add`
file (POSIX); `.gitattributes` whose block names the `--add` already.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry that exists, says "make it writable" only of a file that is there, and says a directory linked in above the path has to be this root's own again, and both link guards are pinned against a directory link (#422, #428)

Round 2 of PR #560's review, from R560v's verification of 6d43f6e (no
MUST; two SHOULD, two NIT, nine Sonar S9073 issues).

SHOULD-3 (tests/test_update_shape_partial_rollback.py): a validator that
moves `contracts/` out of the root and links it back. `_restore`'s walk
for a link (update-shape.py `symlink_on_the_way`) was pinned only for a
link AT the path: mutant MS3, which narrows it to the path's own
`is_symlink`, passed 42 of 42, and run for real wrote the pin's old bytes
outside the root under "ROLLED BACK ... exactly as it was". The pin at the
link's end now has to keep the validator's bytes, the root has to keep its
link, the block must not say "exactly as it was", and the pin's line is
asserted whole.

SHOULD-4 (the same file): the `docs/` that `--add` made, moved out and
linked back. `_remove`'s walk had the same gap: mutant MR2 passed 42 of 42
and deleted `docs/shape-guide.md` at the link's end, outside the root,
saying it had removed it (#239's hazard). The file there must survive and
both the file and the directory are listed, each with what stands there.

NIT-7, a regression from round 1 (update-shape.py `_restore_by_hand`,
`_restore`): round 1 asked `os.access` of the path's own parent, and a
validator that deletes `contracts/` leaves none, so the pin's line fell to
a `cat-file` redirect into a directory that does not exist ("Directory
nonexistent") and said "make it writable" of a file that is not there.
The question is now asked of the nearest directory that exists
(`Rollback._nearest_directory`; git makes the missing ones), so the
checkout line is back, and "make it writable" is said only where the file
is there. A test deletes `contracts/` under `--branch`, asserts the line
and pastes it through `sh`, and the pin's bytes come back.

NIT-8 (`_linked_by_hand`): for a link above the path the line said "once
contracts is not a link", which read as removing it, and after `rm` the
redirect failed "Directory nonexistent". It now says "once contracts is
this root's own directory again". SHOULD-3's test asserts the wording and
pastes the line after moving the directory back.

Sonar S9073 (tests): the nine composite asserts are split, each half with
its own failure message (test_update_shape_partial_rollback.py, eight;
test_update_shape_hardlink.py, one). Two of them had no message at all.

Mutants MS3 and MR2 are killed now; M1, M2, M3, MS1, MS2, MR1 and MN1
stay killed, and the round's own MN2, MN3 and MN4 (round 1's condition,
the unconditional "make it writable", the old "is not a link") are killed.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ect store makes the directory above it first when a validator deleted it, so `cat-file blob <id> > <path>` no longer fails "Directory nonexistent" over an `apply` without `--branch` (#422, #428)

Round 2 of PR #560's review, the case its first report named as out of
scope. The pin's bytes before a run are HEAD's only over a clean tree: an
`apply` without `--branch` over an uncommitted earlier apply holds bytes
HEAD does not commit, so `_restore_by_hand` keeps them in the object store
and prints `git -C <root> cat-file blob <id> > <path>`. A redirect creates
the file and never the directory above it, so with `contracts/` deleted by
a validator that line failed "Directory nonexistent". It is the same line
as at 5d542c1, so this is not a regression, but it is this PR's own
by-hand line and the one a person pastes.

When the path's parent is not a directory the line is now
`mkdir -p <quoted parent> && git -C <root> cat-file blob <id> > <path>`,
the parent quoted as the path is: still one pasteable command, and no
other line changes (the checkout line needs no prefix, git makes the
missing directories).

Test (tests/test_update_shape_partial_rollback.py, WINDOWS_SKIP): an
`apply` to B left uncommitted, then one to C without `--branch` whose
validator runs `shutil.rmtree(contracts)` and returns 1. The pin's line is
asserted to begin with the `mkdir -p` prefix and to hold the `cat-file
blob` redirect, the kept blob holds the pin's bytes, and the line pasted
through `sh -c` exits 0 with the pin's bytes back. Mutant MP1 (the prefix
dropped) is killed by that test alone; MP2 (the prefix always printed) is
killed by four existing tests; MN2 stays killed.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 15, 2026 11:05
…an merge for ruling 27

A clean merge of main bbcfd34, made for
ruling 27 with no hand resolution: git reported no conflict, and the merge's
tree equals the tree `git merge-tree --write-tree e0c7185 origin/main`
predicted (f8586d9). Main's #562 changed
update-shape.py, this PR's file, and git merged the two cmd_apply edits
without a conflict.

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

Copy link
Copy Markdown

@sourcery-ai

sourcery-ai Bot commented Oct 15, 2026

Copy link
Copy Markdown

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

You can request another review in 2 days and 12 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit f5fc705 into main Oct 15, 2026
5 checks passed
@brettheap
brettheap deleted the fix/update-shape-rollback-links branch October 15, 2026 12:50
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…ean merge for ruling 27

This is a clean merge of main a2243f1 (#557, on #560) with no hand
resolution: `git merge` auto-merged shape-doctor.py and
tests/test_doctor_legs_members_next.py and reported no conflict, and its tree,
e0e7f1e, equals what `git merge-tree
--write-tree 66a9353 origin/main` prints (T). T is also the tree the targeted
set passed on, committed in a scratch worktree before this merge existed:
997 passed, 3 skipped (the three PowerShell tests, Windows only).

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

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

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

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

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

Main: 2bf8fd4.

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