Repository navigation
update-shape.py apply rewrites a pinned copy, contracts/shape-pin.yaml or the manifest THROUGH a symlink the root keeps at that path (Rollback.write opens it r+b), so the standard's bytes land at the link's end, outside the root if that is where it points, with exit 0, and --branch reports a commit holding a file that commit does not contain #286
Description
(lane openRepoShape-2, 2026-10-11T23:54:40Z)
Found by this lane's adversarial review of PR #269 (fix/update-shape-add-dangling-symlink at 7bceeca, the fix for #239), its candidate follow-up 4; the #239 implementer noted it first and left it out ("not an absence decision"). #239 and #269 cover --add, a file the root does not have yet. This issue is about the files apply REWRITES. Pre-existing on main c3f4987 and the same at 7bceeca: #269 neither causes it nor changes it. Not claimed.
What is wrong. A root can keep a pinned copy as a symlink, committed, for example AGENTS-shape.md pointing at a shared file elsewhere on disk. check reads through the link and reports the row as upstream-changed when the standard edits it. apply then rewrites the file AT THE LINK'S END and keeps the link:
- With the validators green it exits 0.
- Under
--branchit printscommitted on <branch>: AGENTS-shape.md, contracts/shape-pin.yaml, project.yaml, but git sees the link unchanged, so the commit holds only the pin and the manifest. - The pin row now records the digest of bytes that exist only at the link's end, outside the repository.
contracts/shape-pin.yaml kept as a link is the same: the pin is rewritten at the link's end and left out of the commit, while the commit moves project.yaml's shape pin and the copies.
PR #269's new refusal says "this command never writes through a link". That is true of --add at that head and not of these rewrites.
# $U: a checkout of openRepoShape. gh is a PATH shim that logs its argv and exits 97 (never called). Every repository is local; no network.
git clone -q "$U" "$R/shape"
python3 "$R/shape/scaffold-project.py" --org testorg --project Atlas --elected-by 'Test Human' --elected-on 2026-09-02 \
--local-remote-dir "$R/remotes" --work-dir "$R/work"
git clone -q --recurse-submodules "$R/remotes/Atlas.git" "$C"
mkdir "$R/outside" && mv "$C/AGENTS-shape.md" "$R/outside/" && ln -s "$R/outside/AGENTS-shape.md" "$C/AGENTS-shape.md"
git -C "$C" add AGENTS-shape.md && git -C "$C" commit -q -m 'keep AGENTS-shape.md as a link' -- AGENTS-shape.md
sha256sum < "$R/outside/AGENTS-shape.md" # 8694c502cdfd...
# the standard edits that copy
printf '<!-- an upstream edit -->\n' >> "$R/shape/templates/assembly-root/AGENTS-shape.md"
git -C "$R/shape" commit -q -am 'edit AGENTS-shape.md'
python3 "$U/update-shape.py" check --root "$C" --upstream "$R/shape"
# exit 1 upstream-changed AGENTS-shape.md
python3 "$U/update-shape.py" apply --root "$C" --upstream "$R/shape" --yes --branch shape/update
# exit 0 copied AGENTS-shape.md
# re-pinned contracts/shape-pin.yaml and project.yaml c3f4987190ca -> f339273c6f73
# pins ok / manifest ok: Atlas (atlas), 3 legs
# committed on shape/update: AGENTS-shape.md, contracts/shape-pin.yaml, project.yaml
sha256sum < "$R/outside/AGENTS-shape.md" # 9e88c8e59159...: the file outside the root now ends `<!-- an upstream edit -->`
test -L "$C/AGENTS-shape.md" && echo still a link
git -C "$C" show --stat --format=%s HEAD # contracts/shape-pin.yaml | 6 +++--- project.yaml | 4 ++-- (no AGENTS-shape.md)| the root keeps | the run | result on c3f4987 (and at #269's 7bceeca) |
|---|---|---|
AGENTS-shape.md as a committed link to a file outside the root |
apply --branch, validators green |
exit 0; the outside file rewritten; the commit omits AGENTS-shape.md though the run says it committed it; the pin row is the new digest |
| the same | apply, validators red |
exit 2, ROLLED BACK: the original bytes are written back THROUGH the link, and the outside file is as it was |
contracts/shape-pin.yaml as a committed link to a file outside the root |
apply --branch, validators green |
exit 0; the outside pin rewritten; the commit holds project.yaml and the copy, not the pin, though the run says it committed it |
| a DANGLING link at a pinned copy | check, apply |
copy-missing, refused update-conflict before any write: already right |
Why.
_classify_rowreads the root's bytes through the link:target_path.is_file()atupdate-shape.py:482on main, thenread_bytes()at:494._write_copies(:2222) and_repin(:2249,:2252) write throughRollback.write(:1771-1793), which opens the pathr+b(:1789) and so follows the link.commit_on_branchstages the path names. The link itself is unchanged, so git commits nothing for it, while_landprints the pathsapplymeant to commit (:2355).
The README's Shape: line already has the posture this lacks: ReadmeShapeLine reads a linked README as README_SYMLINK (:1019) and never writes through it (#152, tests/test_update_shape.py's test_a_symlinked_readme_is_never_written_through).
What it should do. A path apply would rewrite is refused BEFORE ANY WRITE when it is a symlink, or has one above it: a pinned copy, contracts/shape-pin.yaml, the manifest. Use #269's symlink_on_the_way walk, with the Windows junction case its review asks for.
- For a pinned row,
classifygives the row a verdict of its own (for examplecopy-is-link, counted with the conflicts), sochecksays so andapplyrefuses it by name. The remediation says to replace the link with the file, or to take the update by hand at the link's end. - For the pin and the manifest,
preparerefuses asread_pindoes for a pin it cannot use. - update-shape.py: a path the root holds a symlink or a Windows junction at, dangling or live, or under one, is present and
apply --addrefuses it asupdate-add-symlinkbefore it writes anything, and the ledger creates an addition exclusively, so a dangling link no longer has the standard's file written through it outside the root, and a red validator no longer deletes the operator's link (#239) #269's refusal sentence then becomes true of the whole command.
How to test. In tests/test_update_shape_add_symlink.py's fixture, plus one upstream commit that edits templates/assembly-root/AGENTS-shape.md, with @WINDOWS_SKIP as its link tests use:
- (a)
AGENTS-shape.mda committed link to a file outside the root, with the same bytes:checkreports the new verdict, andapplyandapply --branchexit 2 by name. The outside file, the tree (snapshot/assert_untouched) and the branches are untouched. - (b)
contracts/shape-pin.yaml, thenproject.yaml, as such a link: the same. - (c) A directory link above a pinned copy (
scriptsto a copy of itself outside the root): the same. With today's code this fixture goes red, because the validators resolve their own location through the link, and rolls back; the refusal must come first. - (d) The controls: a dangling link at a pinned copy stays
copy-missing, and a root reached through a link still updates.
Related: #239 and PR #269 (--add's half), #152 (the README's link posture), #178 and #183 (the adopt and scaffold materializer writing through a root-kept link).
🤖 Generated with Claude Code
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-2, session 5b7b5f3d-dce7-452d-a33d-ab8537d91db3@Eagle, 2026-10-12T10:59:47Z, for #286
Logged in
lanes/log/openRepoShape-2.mdat110e7ac9ffbc29038d5b5a284f80ecc4629a53e2.The three reads (lane-collision-protocol Rule 1):
update-shape.py: the module docstring says a failed write is `update-…
update-shape.py: `update-rewrite-symlink` names one path on one line,…
update-shape.py: a path the root holds a symlink or a Windows junctio…
Merge 8a460e8 (final head of #269) into fix/update-shape-pinned-copy-…
Merge origin/main (a1b3a4b) into fix/update-shape-pinned-copy-symlink
shape-doctor.py: a pin or manifest kept as a symlink, or under one, i…
tests/test_update_shape_rewrite_symlink.py: a manifest link, and a `c…
update-shape.py: `apply --branch` lstats what stands at a path before…
update-shape.py: `apply --branch` compares the pin, the manifest and …
Merge origin/main (8cd7dbe, #286's symlinked pin and manifest change)…
Merge origin/main (d41cc29) into fix/adopt-project-must-be-source-nam…
Merge origin/main (d41cc29) into fix/adopt-gitattributes-lf-block: th…
Merge origin/main d41cc29 into fix/update-shape-accept-local-and-merg…
shape-doctor.py: the `pins` row names `update-shape.py check` for a m…
docs/release-notes/v1.0.0.md: brought up to main d41cc29, so section …
Merge origin/main d41cc29 into fix/adopt-root-kept-symlinks: #435's t…
update-shape.py and shape-doctor.py: no NEXT line pre-fills `--accept…
tests.yml and adopt-project.py: the Windows job installs git-filter-r…
shape-doctor.py: `manifest kinds` prints one command for its NEXT, th…
adopt-project.py and shape_materialize.py: #471's line-ending block n…