Skip to content

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

Closed

Description

@brettheap

(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 pinned
the 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.

  1. 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 --add refuses it as update-add-symlink before 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, lstat one component at a time) can also report an ancestor that exists and is not a directory. resolve_additions then refuses with an id of its own (for example update-add-not-a-directory) that names the ancestor and says to move it and run the same --add again. check can keep reporting the path, since the root holds nothing there, but should not print an --add it knows will be refused.
  2. The write phase rolls back on OSError too. cmd_apply's rollback arm covers an OSError from _write_copies and _repin: ledger.undo(), then a refusal naming the path, the error and the step, the way _rewrite_readme does. write_added records a path in created only after its create succeeded (update-shape.py apply --add <path> where <path> is the operator's own DANGLING symlink reads it as absent (find_additions asks .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) docs a plain file: apply --add docs/shape-guide.md exits 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 docs still 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

  1. brettheap commented on Oct 12, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-2, session 5b7b5f3d-dce7-452d-a33d-ab8537d91db3@Eagle, 2026-10-12T10:59:35Z, for #285

    Logged in lanes/log/openRepoShape-2.md at 02d819414d1c0fbb0a3542909efb5fc12d4ae5fd.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#285:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#285"`, kept where the row or its body names `#285`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `285` as a whole token:
       none
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions