Repository navigation
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
Conversation
…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>
Reviewer's GuideThe PR protects Sequence diagram for hard-link protection during applysequenceDiagram
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
Sequence diagram for partial rollback after restore failuressequenceDiagram
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
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…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>
This was referenced Oct 15, 2026
…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>
|
brettheap
marked this pull request as ready for review
October 15, 2026 12:49
|
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 |
This was referenced Oct 15, 2026
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>
This was referenced Oct 16, 2026
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: #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.writeopens every fileapplyrewritesr+band overwrites its bytes in place. So when a pinned copy,contracts/shape-pin.yaml, the manifest or (since #511) the root's own.gitattributeswas kept as a hard link, its other name was rewritten too: a file outside the root, or acp -alsnapshot of the whole root, that nocheckshowed, no pin covers and git does not record. Only the README's reader asked (#152).hard_links:os.lstat(path).st_nlink > 1for a regular file. A symlink is still update-shape.py: a pinned copy,contracts/shape-pin.yamlor the manifest kept as a symlink, or under one, is refused asupdate-rewrite-symlinkbefore anything is written, andcheckcalls such a copycopy-is-link, soapplyno longer writes the standard's bytes at the link's end and reports a commit that does not hold them (#286) #347's, asked first.applyasks it beforeconfirmand before any write, of every file it will rewrite: eachupstream-changedcopy, the pin, the manifest, and.gitattributeswhen--addwould extend its tests-windows never executes an adoption: git-filter-repo is not installed there, so the eight adopt suites skip and v1.0.0's "adopt works on Windows" is unverified #471 block (asked ofextend_eol_blockwithout writing). It refuses them together asupdate-rewrite-hardlink, exit 2, beforeupdate-conflictandupdate-local-drift.cp -p -- <f> <f>.update-shape-copy && mv -f -- <f>.update-shape-copy <f>: a copy of the file put in its place, so the other name keeps its own bytes. git sees no change, so there is nothing to commit before the same command is run again.Rollback.writeasks the same question after its symlink walk, for a name that arrives whileconfirmwaits; the refusal arm puts back what was already written.os.replacewould leave the other name holding the old bytes and the link broken, which no rollback puts back. That is the README guard's own reason.checksays so in oneREFUSAL AHEADline after the conflicts one. No new row state: a hard-linked copy holds the right bytes, so it is not drift.README.mdis not asked: its reader still keeps a hard-linked README (kept README.md: another name points at these same bytes, untouched).#428: a rollback that cannot put a byte back.
undorestored with a barewrite_bytesand removed what--addmade with a bareunlinkandrmdir. OneOSErrorescaped_rolled_backandmainas 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, withROLLED BACK IN PARTunder its detail; aCommandFailedstart 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_filedoes (bump-leg.py_apply_rewriterolls back only onRefusalandCommandFailed, so aPermissionErroron a workflow file it rewrites, or theUnicodeEncodeErrorthat #230'serrors="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 backwith 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 --filtersof it equals the old bytes, and the directory takes a new file (a checkout unlinks and creates).git hash-object -w --stdin, and the line isgit -C <root> cat-file blob <id> > <path>, which writes in place.update-rewrite-hardlink'scp -p/mv -fline.The HEAD claim, measured on main 5ed3e5d before building on it (in-process, red validators, ledger read at
undo):--branchrun over a clean tree held HEAD's bytes at every path it wrote (copy, pin, manifest);applywithout--branchover an earlier in-place apply held HEAD's bytes at none of the three;--branchrun accepts a CRLF working-tree manifest (hash-objectnormalises 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>orrmdir -- <path>. A directory still holding an--addfile that would not go is listed after it, not kept for "something this run did not write"._saidprintsnothingunderPut 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:
_restorenow askssymlink_on_the_wayandhard_linksfirst, as the forward write does, and lists such a path with a true line._removenever unlinks or removes through a link it did not make (update-shape.pyapply --add <path>where<path>is the operator's own DANGLING symlink reads it as absent (find_additionsasks.exists()) and writes THROUGH it, so when the project's validators go red the rollback unlinks the operator's symlink, leaves the file it wrote at the link's target outside the root, and prints that the tree is exactly as it was #239's hazard). Tests cover a symlink, a second name and a link at an--addpath.contracts/at 0555, the checkout line failed however writable the pin was. It is offered only where the directory takes a new file; elsewhere the in-placecat-fileline. Tested POSIX-only..gitattributeswhose block already names the--addfile is not refused.ReadmeShapeLine._classify's, and tests/test_update_shape_add_symlink.py:387-388.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:
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.--addpath. Nothing is removed under it.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 prefixedmkdir -p <dir> &&(fifth commit).R560v verified e0c7185: APPROVE WITH NITS. The two NITs it left are optional, and neither is worth a CI cycle on its own:
lexistschoice behind "make it writable", though R560v showed by a real run that the alternative prints a line that fails.CI at e0c7185 was green on all three OSes, and SonarCloud showed 0 open issues.
Tests
New:
tests/test_update_shape_hardlink.py(#422) andtests/test_update_shape_partial_rollback.py(#428). The #428 tests run in process and patchPath.write_bytes,unlinkorrmdirfor one path, only whileundoruns, 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 testchmods the pin andcontracts/, where the permission is the point.os.linkneeds no privilege on NTFS, so the hard-link tests run on all three platforms; thesh -cpastes and the symlink cases areWINDOWS_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):
apply: 9 fail. The four late-arrival tests still pass:Rollback.writerefuses there, which is their job.write_bytesinundo: 14 fail, on thePermissionErrortraceback or the write through a link..gitattributeswhose block--addleaves as it is: 1 fails._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.takingnot passed, thecheckline removed, the removal handler dropped, andcat-file blobfor--filterseach failed at least 1.Not covered end to end:
.gitattributeswith--addon a real adopted root. The test stands in with a scaffolded root whose.gitattributescarries the #471 block and whose pin row was moved to it, because an adoption costs a filter-repo run.Shared files
update-shape.pyis shared with lane 4's P8 (#431/#437/#535) and P4 (#536), on other lines. This PR touches:link_refusal's docstring and one comment inReadmeShapeLine._classify(sentences this PR made false);cmd_check, after the conflictsREFUSAL AHEAD;Rollback.write,undo,_saidand new helpers in the class;_refuse_linked_copies;_refuse_linked_copies(rows)incmd_apply.One comment changes in
tests/test_update_shape_add_symlink.py. No import changed.git merge-tree --write-treeof this head is clean against main 5ed3e5d and against main b8b7062 (#518, which landed during the review round and changes nothing this PR uses inshape_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 fromtest_update_shape.py,test_update_shape_add.pyandconftest.pyis 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, everytests/test_update_shape*.pyplustest_update_add_codes_documented.py,test_repo_hygiene.py,test_agents_md_codes.pyandtest_cli_reference.pygave 734 passed and 3 skipped, all Windows-only (NTFS junctions).v1.1.0.
Closes #422
Closes #428
🤖 Generated with Claude Code