Repository navigation
update-shape.py apply --add <dir>/<file> where the root holds a plain FILE at <dir> (or a directory it cannot write) dies in a NotADirectoryError (or PermissionError) traceback from Rollback.write_added after the pinned copies were written, and nothing puts them back, because cmd_apply rolls back only on Refusal and CommandFailed #285
Description
(lane openRepoShape-2, 2026-10-11T23:54:38Z)
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 3; the #239 implementer noted it first and left it out ("not a link, not this issue"). Pre-existing on main c3f4987 and the same at 7bceeca: #269 neither causes it nor changes it. Not claimed.
What is wrong. update-shape.py apply --add <path> creates the standard's new file at <path> with Rollback.write_added, after _write_copies has already rewritten every upstream-changed copy. When <path> sits under a name the root holds as a plain FILE (.github, with --add .github/workflows/validate.yml), the write raises NotADirectoryError; when it sits in a directory this process cannot write, it raises PermissionError. Neither is a Refusal or a CommandFailed, so cmd_apply never reaches ledger.undo() and main prints a traceback. The copies stay rewritten and the pin is not moved, so the next check reads the rewritten copy as already-at-target. check offers the path as upstream-added beforehand, with the --add to paste.
# $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"
# a project cut before .github/workflows/validate.yml existed: drop its `files:` row and the file in one commit (the
# python3 row-dropper is #239's, with that path), then keep a plain file named `.github`
git -C "$C" rm -q .github/workflows/validate.yml && git -C "$C" commit -q -m cut -- .github/workflows/validate.yml contracts/shape-pin.yaml
printf 'a plain file the operator keeps\n' > "$C/.github" && git -C "$C" add .github && git -C "$C" commit -q -m 'a file named .github'
# the standard moves on by one commit that fixes a pinned copy
printf '# an upstream fix every project is owed\n' >> "$R/shape/templates/assembly-root/scripts/validate-manifest.py"
git -C "$R/shape" commit -q -am 'edit validate-manifest.py'
python3 "$U/update-shape.py" check --root "$C" --upstream "$R/shape"
# exit 1 upstream-changed scripts/validate-manifest.py upstream-added .github/workflows/validate.yml
# ADDED UPSTREAM: ... --add .github/workflows/validate.yml
python3 "$U/update-shape.py" apply --root "$C" --upstream "$R/shape" --yes --add .github/workflows/validate.yml
# exit 1 copied scripts/validate-manifest.py
# Traceback (most recent call last):
# update-shape.py line 2594, in main / line 2389, in cmd_apply / line 2232, in _write_copies / line 1805, in write_added
# NotADirectoryError: [Errno 20] Not a directory: '<C>/.github/workflows'
git -C "$C" status --porcelain # M scripts/validate-manifest.py
grep '^commit' "$C/contracts/shape-pin.yaml" # still the commit the scaffold pinnedthe root at .github |
the run | result on c3f4987 (and at #269's 7bceeca) |
|---|---|---|
a plain file .github |
apply --add .github/workflows/validate.yml |
exit 1, NotADirectoryError traceback from write_added; M scripts/validate-manifest.py left, pin unmoved |
.github/workflows/ an empty directory, mode 0555 |
the same | exit 1, PermissionError traceback from write_added's write_bytes; M scripts/validate-manifest.py left, pin unmoved |
nothing at .github (control) |
the same | exit 0, the file added and its row appended |
Why. Two steps. find_additions asks only whether something is AT the path (Path.exists() at update-shape.py:766 on main; os.path.lexists plus #269's link walk at 7bceeca), and a path under a plain file does not exist, so the addition is offered and taken. Rollback.write_added (:1795-1810) then makes the missing parents it can see with p.exists() (:1804), appends the path to created (:1807) and calls path.write_bytes(data) (:1808), which raises the OSError. cmd_apply rolls back on except (Refusal, CommandFailed) only (:2402), and main catches the same two (:2595-2599), so the copies _write_copies wrote at :2222 before the additions at :2232 are never put back. _rewrite_readme already turns its own OSError into a refusal for exactly this reason ("An OSError escaping would leave that tree behind"); the copy and addition writes have no counterpart. #253 is the same gap in bump-leg.py; #263 is the same plain-file-at-a-directory input in adopt-project.py.
What it should do.
- A path under a non-directory is refused by name before anything is written. The walk 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 adds (symlink_on_the_way,lstatone component at a time) can also report an ancestor that exists and is not a directory.resolve_additionsthen refuses with an id of its own (for exampleupdate-add-not-a-directory) that names the ancestor and says to move it and run the same--addagain.checkcan keep reporting the path, since the root holds nothing there, but should not print an--addit knows will be refused. - The write phase rolls back on
OSErrortoo.cmd_apply's rollback arm covers anOSErrorfrom_write_copiesand_repin:ledger.undo(), then a refusal naming the path, the error and the step, the way_rewrite_readmedoes.write_addedrecords a path increatedonly after its create succeeded (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 second change), so the undo never removes something this run did not make.
How to test. In tests/test_update_shape_add_symlink.py's fixture (commit B fixes scripts/validate-manifest.py, a pinned copy, and adds docs/shape-guide.md), with its snapshot/assert_untouched helpers:
- (a)
docsa plain file:apply --add docs/shape-guide.mdexits 2 with the new refusal, no traceback, and the tree is untouched (the pinned copy included). - (b)
docs/an empty read-only directory, POSIX only and skipped when running as root: the run exits 2 with a refusal,ROLLED BACK, and the tree as it was. - (c) The control: nothing at
docsstill adds.
Reverting (1) makes (a) fail, and reverting (2) makes (b) fail.
Related: #239 and PR #269 (the link walk this extends), #253 (the same rollback gap in bump-leg.py), #263 (a plain file at a directory the shape writes into, in adopt-project.py).
🤖 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:35Z, for #285
Logged in
lanes/log/openRepoShape-2.mdat02d819414d1c0fbb0a3542909efb5fc12d4ae5fd.The three reads (lane-collision-protocol Rule 1):
update-shape.py: the module docstring's EXIT CODES line is left as #269…
update-shape.py: the module docstring says a failed write is `update-…
bump-leg.py: a bump that fails for any reason once the leg has moved,…
update-shape.py: a path the root holds a symlink or a Windows junctio…
update-shape.py: `apply --add` refuses a path under a file the root k…
Merge remote-tracking branch 'origin/main' into fix/update-shape-next…
update-shape.py: `apply --branch` compares the pin, the manifest and …
README.md and the handbook: the by-hand setup commands dry-run the sc…