Repository navigation
bootstrap.py, the assembly root's template and the family holder's, ends in a FileNotFoundError traceback, exit 1, when git is not on PATH, because the root's step (a) calls recorded_gitlink, its offline upstream notice calls _try_git and the holder's step (a) runs git submodule update, and nothing catches OSError, where the docstrings say step (a) "never raises" and the notice "is silent when it cannot answer", so the first command a new engineer runs after cloning answers a missing git with a stack trace and the code its own bootstrap FAILED line uses: the third sibling of #368 and #412 #463
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by this lane's review of PR #446 (#412, validate-pins.py with no git), from the writer's own list of what that PR leaves out: "scripts/bootstrap.py with no git gives FileNotFoundError: [Errno 2] ... 'git', exit 1". The review rated it a follow-up of its own, lower priority: "bootstrap's job is git, so it only needs a clean refusal in place of a traceback". It is the sibling of #368 (validate-family.py) and #412 (validate-pins.py): the same missing git, a third script. Re-verified on main dd8d0ea (templates/assembly-root/scripts/bootstrap.py sha256 ae1426fd…, templates/family-root/scripts/bootstrap.py 0a0fb055…, scripts/repo_shape.py ef0251ef…), with local bare remotes, an empty global git config, GIT_ALLOW_PROTOCOL=file and a gh that exits 97 (never called). Neither PR #446 nor PR #406 touches either file; the one open PR that does is #456 (#391, main's print of the manifest's name). The exposure is small: the engineer usually cloned the repository, so git was there a minute ago, and what removes it is a shell with a stripped PATH or a container image that dropped it. What this is about is the sentence and the code: bootstrap is "the one command an engineer runs after git clone --recurse-submodules", and its exit 1 reads as "a validator failed".
What is wrong. Three places run git and nothing around them catches OSError:
U=<openRepoShape checkout> # a clean checkout at dd8d0ea
# Runs in bash on Linux (GNU coreutils). Nothing leaves the machine: every repository is a local bare one under $R, git
# reads an empty global config, GIT_ALLOW_PROTOCOL=file makes any accidental https/ssh clone fail, and a `gh` that exits
# 97 stands first on PATH.
R=$(mktemp -d); mkdir $R/bin; printf '#!/bin/sh\nexit 97\n' > $R/bin/gh; chmod +x $R/bin/gh; touch $R/gitconfig
export PATH=$R/bin:$PATH GIT_CONFIG_GLOBAL=$R/gitconfig GIT_CONFIG_NOSYSTEM=1 GIT_ALLOW_PROTOCOL=file \
GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t.invalid GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t.invalid
python3 $U/scaffold-project.py --org testorg --project Atlas --elected-by 'Test Human' --elected-on 2026-10-12 \
--local-remote-dir $R/remotes --work-dir $R/work >/dev/null
python3 $U/scripts/family.py init --org testorg --family Fam --created-by 'Test Human' --created-on 2026-10-12 \
--local-remote-dir $R/fam --work-dir $R/h >/dev/null
python3 $U/scripts/family.py add --family-root $R/h/Fam --member testorg/Atlas --local-remote-dir $R/remotes >/dev/null
git clone -q --recurse-submodules $R/remotes/Atlas.git $R/Atlas
mkdir $R/nogit && ln -s "$(command -v python3)" $R/nogit/python3 # a PATH with python3 on it and no git
last() { awk '/^Traceback/ {t = ""} {t = t $0 "\n"} END {printf "%s", t}' "$1" | grep -E '^ File "|^[A-Za-z]*Error' \
| grep -v subprocess.py | sed -E 's|"[^"]*/scripts/|"scripts/|'; } # the last traceback's frames, trimmed
echo "(1) the assembly root"; cd $R/Atlas
python3 scripts/bootstrap.py | tail -1; echo "bootstrap.py, git on PATH: exit ${PIPESTATUS[0]}"
env PATH=$R/nogit python3 scripts/bootstrap.py > $R/1.txt 2>&1; echo "bootstrap.py, no git: exit $?"; last $R/1.txt
echo "(2) a clone with no submodules yet, and a checkout of the standard named by SHAPE_UPSTREAM_PATH"
git clone -q $R/remotes/Atlas.git $R/Atlas2; cd $R/Atlas2
env PATH=$R/nogit SHAPE_UPSTREAM_PATH=$U python3 scripts/bootstrap.py > $R/2.txt 2>&1; echo "bootstrap.py, no git: exit $?"; last $R/2.txt
echo "(3) the family holder"; cd $R/h/Fam
env PATH=$R/nogit python3 scripts/bootstrap.py > $R/3.txt 2>&1; echo "bootstrap.py, no git: exit $?"; last $R/3.txt
env PATH=$R/nogit python3 scripts/bootstrap.py --no-fetch 2>&1 | tail -1; echo "bootstrap.py --no-fetch, no git: exit ${PIPESTATUS[0]}"Output at dd8d0ea:
(1) the assembly root
bootstrap ok
bootstrap.py, git on PATH: exit 0
bootstrap.py, no git: exit 1
File "scripts/bootstrap.py", line 420, in <module>
File "scripts/bootstrap.py", line 403, in main
File "scripts/bootstrap.py", line 349, in _checkout_all_tracking_branches
File "scripts/bootstrap.py", line 114, in checkout_tracking_branch
File "scripts/repo_shape.py", line 1055, in recorded_gitlink
File "scripts/repo_shape.py", line 872, in git_out
FileNotFoundError: [Errno 2] No such file or directory: 'git'
(2) a clone with no submodules yet, and a checkout of the standard named by SHAPE_UPSTREAM_PATH
bootstrap.py, no git: exit 1
File "scripts/bootstrap.py", line 420, in <module>
File "scripts/bootstrap.py", line 406, in main
File "scripts/bootstrap.py", line 376, in _run_neutral_validators
File "scripts/bootstrap.py", line 175, in shape_upstream_notice
File "scripts/bootstrap.py", line 95, in _try_git
File "scripts/repo_shape.py", line 872, in git_out
FileNotFoundError: [Errno 2] No such file or directory: 'git'
(3) the family holder
bootstrap.py, no git: exit 1
File "scripts/bootstrap.py", line 210, in <module>
File "scripts/bootstrap.py", line 186, in main
File "scripts/bootstrap.py", line 91, in fetch_members
FileNotFoundError: [Errno 2] No such file or directory: 'git'
family bootstrap ok
bootstrap.py --no-fetch, no git: exit 0
- (1) is the common case. A fresh recursive clone has initialised legs, so step (a) asks git for each leg's gitlink and dies on the first. Nothing of (b) (the validators) or (c) (the authority readout) runs, and the
bootstrap FAILEDline is never printed. - (2) is the notice. The two submodule legs are not initialised, so step (a) prints
NOT INITIALIZEDfor each and never meets git. Step (b) runs its three validators as children; the pins child dies on the same missing git (that is The assembly root'sscripts/validate-pins.pyends in aFileNotFoundErrortraceback, exit 1, a finding's code, when git is not on PATH, becauserecorded_gitlinkreachesrepo_shape.git_outand nothing catchesOSError, where the file's own exit codes give 1 to drift and 2 to a question that could not be asked: the sibling of #368, whose fix is ruled intovalidate-family.pyonly #412), and then bootstrap's ownshape_upstream_noticerunsgit rev-parse HEADin the checkoutSHAPE_UPSTREAM_PATHnames and dies too. - (3) is the holder, whose step (a) is
git submodule update --init --recursive. With--no-fetch, which runs no git, it passes.
Why.
checkout_tracking_branch(templates/assembly-root/scripts/bootstrap.py:104at dd8d0ea) says in its docstring "Prints; never raises; never moves the pin". It callsrecorded_gitlink(root, path)at:114with nothing around it.recorded_gitlink(scripts/repo_shape.py:1039, the root's pinned copy) catches onlyRefusal(:1056), andgit_out(:871) callssubprocess.run(["git", ...])(:872), which raisesFileNotFoundErrorwhen there is no git andPermissionErrorwhen the one found will not run, before any return code exists for it to turn into aRefusal._try_git(bootstrap.py:93) has the same one-clause catch,except Refusal(:96). Its callers are step (a)'s five branch operations (:115,:117,:124,:129,:140) andshape_upstream_notice(:175), whose docstring says "Where it is not, this is silent: an absent answer is not a finding, and it never fails the bootstrap".- The holder's
fetch_members(templates/family-root/scripts/bootstrap.py:80) runssubprocess.run(args, ...)at:91. It handles a git that exits non-zero (FETCH INCOMPLETE (git exit N), "What did arrive is bootstrapped below",:101-106) and not a git that does not start. - Bootstrap has three exits: 2 for a root it cannot load (
:393), 1 forbootstrap FAILED: <validators>(:414), and 0. The traceback leaves with 1, so the exit code that means "a validator failed" is also what a machine with no git says.validate-pins.pyandvalidate-family.pygive "the question could not be asked" the code 2; validate-family.py's default and--pinsmodes end in aFileNotFoundErrortraceback, exit 1 (a finding's code), when git is not on PATH: on main for every holder with a member (recorded_gitlinkthroughrepo_shape.git_out), and after #317 still for a holder whose.gitmodulesis gone, though #317's own check answersgitmodules-unreadable, exit 2, whenever the file is there #368 and The assembly root'sscripts/validate-pins.pyends in aFileNotFoundErrortraceback, exit 1, a finding's code, when git is not on PATH, becauserecorded_gitlinkreachesrepo_shape.git_outand nothing catchesOSError, where the file's own exit codes give 1 to drift and 2 to a question that could not be asked: the sibling of #368, whose fix is ruled intovalidate-family.pyonly #412 move them there. - Both files are pinned copies (
contracts/shape-pin.yamllistsscripts/bootstrap.pyin a project and in a holder), so a change reaches existing roots throughupdate-shape.pyasupstream-changed.
What it should do. Bootstrap says what is missing and never ends in a traceback. The decisions are the author's:
- Where.
- Once, at the top of
main: rungit --version, and refuse before any step when it cannot start. Nothing then runs half. - Or at each call: catch
OSErrorin_try_gitand aroundrecorded_gitlinkat:114, so step (a) prints a line for the leg and goes on as it already does for "could not create branch ... leaving detached", and (b) and (c) run. With The assembly root'sscripts/validate-pins.pyends in aFileNotFoundErrortraceback, exit 1, a finding's code, when git is not on PATH, becauserecorded_gitlinkreachesrepo_shape.git_outand nothing catchesOSError, where the file's own exit codes give 1 to drift and 2 to a question that could not be asked: the sibling of #368, whose fix is ruled intovalidate-family.pyonly #412 landed, (b)'s pins child then refusesgit-not-runnableand bootstrap endsbootstrap FAILED: pins (exit 2), exit 1. - The first keeps the exit code honest and skips the authority readout. The second keeps step (a)'s "never raises" contract and ends in the code a failed validator already has.
- Once, at the top of
- The code and the sentence. The one the two validators use,
git-not-runnable(validate-family.py's default and--pinsmodes end in aFileNotFoundErrortraceback, exit 1 (a finding's code), when git is not on PATH: on main for every holder with a member (recorded_gitlinkthroughrepo_shape.git_out), and after #317 still for a holder whose.gitmodulesis gone, though #317's own check answersgitmodules-unreadable, exit 2, whenever the file is there #368, The assembly root'sscripts/validate-pins.pyends in aFileNotFoundErrortraceback, exit 1, a finding's code, when git is not on PATH, becauserecorded_gitlinkreachesrepo_shape.git_outand nothing catchesOSError, where the file's own exit codes give 1 to drift and 2 to a question that could not be asked: the sibling of #368, whose fix is ruled intovalidate-family.pyonly #412, and the shared-wrapper option the review of The assembly root'sscripts/validate-pins.pyends in aFileNotFoundErrortraceback, exit 1, a finding's code, when git is not on PATH, becauserecorded_gitlinkreachesrepo_shape.git_outand nothing catchesOSError, where the file's own exit codes give 1 to drift and 2 to a question that could not be asked: the sibling of #368, whose fix is ruled intovalidate-family.pyonly #412 raised: one wrapper inscripts/repo_shape.py, whichbootstrap.pyalready imports from, with the remediation's subject as a parameter), so that a project says one thing about a missing git whichever script meets it first. - The notice.
shape_upstream_noticeis silent where it cannot answer. A_try_gitthat returnsNonefor anOSErroras it does for aRefusalmakes it so. - The holder. Whether a git that does not start is the same
FETCH INCOMPLETEpath (go on to each member's ownmake bootstrap, as the module's design has it for a git that fails) or a refusal.
How to test. In process, as tests/test_validate_pins_without_git.py does in PR #446: patch the module's git runner (git_out, and the holder's subprocess.run) to raise FileNotFoundError, change no PATH, so the test means the same on every OS. Assert the exit code and that no traceback is printed, once for step (a) with an initialised leg, once for the notice with SHAPE_UPSTREAM_PATH set, and once for the holder's fetch. One test may run the real subprocess with a PATH that holds no git and skips on Windows. Control: git runnable, exit 0, bootstrap ok, unchanged.
Verified at main dd8d0ea (the three sha256 above).
Related: #368 (validate-family.py with no git), #412 (validate-pins.py, PR #446), #391 (the manifest's name printed with a plain print in both of these files; PR #456 edits main beside this), #455 (a lone surrogate in tracking_branch reaches the same git_out and ends the same step in a traceback: another input that git_out cannot carry), #408 (the doctor's reading of a missing git; see its decision 2).
Activity
brettheap commentedon Oct 12, 2026
Lane: openreposhape-4 (openRepoShape-4)
Moved to #459's known-limits checklist (#459 (comment)). Brett Heap ruled on 2026-10-12 that a new edge case found in review goes on its area's checklist, not into a new issue, and this lane filed this one before reading that ruling. Running bootstrap with no git on PATH is outside v1.0.0's scope.
Both roots' scripts/bootstrap.py: `main` escapes what stdout cannot e…