Repository navigation
scaffold-project.py: pin_digest_from_source catches only Refusal, so a --pin-source that does not exist, is a file, or sits under a parent this user cannot enter ends --dry-run and the real run alike in a FileNotFoundError, NotADirectoryError or PermissionError traceback (exit 1), where a directory that is not a clone is the pin-source-unreadable refusal (exit 2) with its remediation #339
Description
Lane: openreposhape-4 (openRepoShape-4)
Named by this lane's writer of the #295 fix (PR #312, head eb0658f) as a follow-up: that fix turned the same crash into a finding in templates/assembly-root/scripts/validate-pins.py and left the scaffold's copy alone. The lane's handoff records it (addendum 17); neither review of #312 (eb0658f, 4aae417) nor the reviews of #288 carry it. Re-verified on main c7c02fa with scaffold-project.py run against local bare remotes under Python 3.12.3. gh was a shim that logs and exits 97 and was never called, because every run names --pin-source. #309, the one open PR that touches scaffold-project.py, changes three help= strings and the pin-collision remediation, not this path, so this survives the landing of lane 4's open PRs. Not claimed:
- Windows.
- Python versions other than 3.12.3. The crash is
subprocess.run(cwd=...)raising before git starts, notpathlib, so it does not depend on 3.14. - The same call reached through
setup.sh ... -- --pin-source <dir>. setup.sh hands it to this scaffold's--dry-runat the plan; read, not run.
What is wrong. declared_pin_values (scaffold-project.py:348-372 at c7c02fa) computes each --pin's digest through pin_digest_from_source(args.pin_source.resolve(), ...) (:123-134). That function calls repo_shape.tree_digest, which runs git ls-tree through git_out with cwd= the source. It catches Refusal only. A source git can enter but that is not a clone is git's own failure, a Refusal, and becomes pin-source-unreadable with a remediation. A source subprocess.run cannot enter at all raises OSError before git starts. Nothing catches that: main catches Refusal and CommandFailed (:338-345).
# $U: a clean checkout at c7c02fa. `gh` is never reached: every run names --pin-source and --local-remote-dir.
R=$(mktemp -d)
cd "$R" && mkdir plain locked locked/x && : > afile && chmod 000 locked
SHA=$(git -C "$U" rev-parse HEAD) # any 40-hex commit will do: no source below is ever read
scaffold() {
python3 "$U/scaffold-project.py" --org testorg --project MedxGlass --elected-by 'Test Human' \
--local-remote-dir "$R/remotes" --work-dir "$R/work" --pin "openGlass@$SHA" "$@" 2>&1 | tail -n 2
echo "exit ${PIPESTATUS[0]}"
}
scaffold --pin-source "$R/plain" --dry-run # a directory that is not a clone
# REFUSED pin-source-unreadable: $R/plain could not answer for opensoft/openGlass @ c7c02fa2...: `git ls-tree -r -z c7c02fa2...` in $R/plain exited 128: fatal: not a git repository ...
# Remediation: point --pin-source at a clone of that repository that HAS the pinned commit (`git fetch --all` first), or drop --pin-source and let `gh api` read the forge.
# exit 2
scaffold --pin-source "$R/missing" --dry-run # no such directory
# raise child_exception_type(errno_num, err_msg, err_filename)
# FileNotFoundError: [Errno 2] No such file or directory: '$R/missing'
# exit 1
scaffold --pin-source "$R/afile" --dry-run # a file
# NotADirectoryError: [Errno 20] Not a directory: '$R/afile'
# exit 1
scaffold --pin-source "$R/locked/x" --dry-run # under a parent this user cannot enter
# PermissionError: [Errno 13] Permission denied: '$R/locked/x'
# exit 1
scaffold --pin-source "$R/missing" # the real run
# FileNotFoundError: [Errno 2] No such file or directory: '$R/missing'
# exit 1
chmod 755 locked && ls -A "$R"
# afile locked plain (no remotes/, no work/: nothing was created)The whole traceback runs main → _scaffold → _declaration → declared_pin_values → pin_digest_from_source → repo_shape.tree_digest → git_out → subprocess.run. It comes at the plan, before anything is created, so no work is lost. What is lost is the refusal: an operator who mistyped the path reads a Python traceback with Python's exit 1, not the refusal's exit 2, instead of the pin-source-unreadable sentence that names the fix. Through setup.sh it lands in the middle of the plan block, before the prompt.
A related silence: --pin-source "$R/missing" with NO --pin (and no --referent-chain) is never read at all. --dry-run says nothing was created. and exits 0 (checked).
Why. pin_digest_from_source was written for the one way a clone can fail to answer: git says so. A path that is not a directory never reaches git, and tree_digest's docstring promises nothing about it. #295 found the same gap in the project's copied validate-pins.py (_digest_from_source, "only Refusal is caught"). Its fix (PR #312) judges the source with one stat() inside a guard (FileNotFoundError/NotADirectoryError → "no such directory", any other OSError → its strerror, S_ISDIR for a file) and catches the OSError from git's cwd= too. The scaffold's copy was out of that issue's scope.
What it should do. Every way an explicit --pin-source fails to answer is pin-source-unreadable, exit 2, with the reason and the remediation, before anything is created. The decisions are the author's:
- How. Either catch
OSErroraroundtree_digestinpin_digest_from_source, with the system's reason in the detail, or judge the path first with validate-pins.py: a--pin-sourcevalue isPRODUCT=PATHonly when the text before its first=names a declared neutral-product pin, so a bare stand-in undera=b/recomputes the digest instead of falling togh apiand askip, an explicit source that does not exist or is not a directory is theneutral-pin-source-unreadablefinding instead of aFileNotFoundErrortraceback, and a value no pin used is noted (#295) #312'sstat()-in-a-guard. The latter says "no such directory" for a missing path and "it is a file, not a directory" for a file, and still needs theOSErrorcatch for a source thatstat()can read butcwd=cannot enter. - Whether the two copies share it. The scaffold imports
repo_shapefrom this checkout, so it can import a helper the template'svalidate-pins.pycannot (that file imports only from the project's own pinnedrepo_shape.py). Either keep two small guards, each with a pointer to the other, or move one intorepo_shape.pyfor the scaffold and leave the template its own. - An unused
--pin-source. Decide whether a--pin-sourcethat no--pinand no--referent-chainreads is refused or noted (it is a flag that did nothing), as templates/assembly-root/scripts/validate-pins.py: a bare--pin-source <path>whose path holds an=is read as<product>=<path>(raw.partition("=")), so a stand-in clone under a directory nameda=bis never consulted and the pin's digest silently goes togh apiand askip(pins ok, exit 0), or, when the text before the=is a declared pin's name, to aFileNotFoundErrortraceback #295 decision 2 decided for the validator, or left as it is. Its--helpcalls it "the TEST path; no network".
How to test. In tests/test_scaffold_pin_and_reuse.py's style (local remotes, --pin-source, a gh that fails if called):
- A missing directory, a file, and a parent at mode 000, each with
--dry-runand without. Each must give exit 2,REFUSED pin-source-unreadablenaming the path, no traceback, and the remote directory and work directory left uncreated. - The mode-000 row skipped on Windows and as root, with the reason beside the skip, as validate-pins.py: a
--pin-sourcevalue isPRODUCT=PATHonly when the text before its first=names a declared neutral-product pin, so a bare stand-in undera=b/recomputes the digest instead of falling togh apiand askip, an explicit source that does not exist or is not a directory is theneutral-pin-source-unreadablefinding instead of aFileNotFoundErrortraceback, and a value no pin used is noted (#295) #312'sSEARCH_DENIEDrows intests/test_neutral_pin_validator.pydo. - The existing not-a-clone refusal unchanged.
Verified at main c7c02fa (scaffold-project.py sha256 afef9917…, scripts/repo_shape.py c4c33094…); #309 (head 2ea1717) does not touch this path.
Related:
- templates/assembly-root/scripts/validate-pins.py: a bare
--pin-source <path>whose path holds an=is read as<product>=<path>(raw.partition("=")), so a stand-in clone under a directory nameda=bis never consulted and the pin's digest silently goes togh apiand askip(pins ok, exit 0), or, when the text before the=is a declared pin's name, to aFileNotFoundErrortraceback #295 (the same crash in the copiedvalidate-pins.py, fixed by PR validate-pins.py: a--pin-sourcevalue isPRODUCT=PATHonly when the text before its first=names a declared neutral-product pin, so a bare stand-in undera=b/recomputes the digest instead of falling togh apiand askip, an explicit source that does not exist or is not a directory is theneutral-pin-source-unreadablefinding instead of aFileNotFoundErrortraceback, and a value no pin used is noted (#295) #312; its decision 3 is this issue's rule). - tests/test_scaffold_pin_and_reuse.py: two tests run the scaffolded project's
validate-pins.pywith no--pin-source, so it callsgh api repos/opensoft/<product>/git/trees/<sha>wheneverghis on PATH and the pin's digest is skipped instead of rechecked #272 (the scaffold tests that pass--pin-source). - scaffold-project.py
--pin spec@<sha>(orcode@,shape@, or two--pins whose products differ only by case) passes--dry-run, then creates all three repositories, pushes both legs and dies withREFUSED materialize-collision, because the pin'scontracts/<product>-pin.yamlis a file the shape already writes #241 and scaffold-project.py accepts a leg path that collides with a path the shape writes (--spec-path contracts,--code-path project.yaml), creates all three repositories and pushes both legs, then dies: #172's shape half on the scaffold side #197 (the scaffold's other refusals made before anything exists). - The
--link-sourceissue filed from the same batch (the naming validator's unguarded lookup, the same class).
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-5, session 3c13126a-509b-418f-b102-a3a282837b54@Eagle, 2026-10-12T14:30:41Z, for #339
Logged in
lanes/log/openRepoShape-5.mdatbdc98ef87cbf97a13ebb51882e0c60333191d303.The three reads (lane-collision-protocol Rule 1):
shape_materialize.py: `pin_digest_from_source` turns any `OSError` ou…
shape_materialize.py, adopt-project.py: a `--pin-source` that is not …
tests/test_adopt_pins.py: `plan` with a `--pin-source` and no `--pin`…
validate-repository-naming.py, repo_shape.py: a `--link-source` value…
Merge main cc8f2da into fix/adopt-plan-field-types: `pins:` is #273's…
Merge origin/main af01068 into fix/adopt-root-kept-symlinks: `_entry_…
Merge main cc8f2da into fix/adopt-plan-quote-yaml-keywords (#181)
docs/release-notes/v1.0.0.md: brought up to main d41cc29, so section …
scripts/shape_materialize.py and tests/test_pin_source_unreadable.py:…