Repository navigation
validate-family.py's default and --pins modes end in a FileNotFoundError traceback, exit 1 (a finding's code), when git is not on PATH: on main for every holder with a member (recorded_gitlink through repo_shape.git_out), and after #317 still for a holder whose .gitmodules is gone, though #317's own check answers gitmodules-unreadable, exit 2, whenever the file is there #368
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by this lane's adversarial review of #300 (PR #317, head 03ddadd; its nit NIT-4). --no-members gained a git dependency there, and "the default and --pins modes already crashed the same way on main whenever there are members". #317's fix round (e0cc351) catches OSError in its new .gitmodules reader and refuses gitmodules-unreadable. The re-check (at e0cc351, "NIT-4 is fixed, more broadly than the writer says") showed that this covers the default and --pins modes too in a real holder, because the .gitmodules check now runs first, leaving only "a holder with member rows but no .gitmodules". Re-verified on main c7c02fa and on a scratch merge of #318's head 0f2a0dd (which carries c7c02fa) with #317's head c23e2b0, with a PATH holding python3 and no git. The exposure is small: CI and the doctor both run git, and a holder is a git repository. After #317 the residue needs a hand-edited holder as well. The issue is the exit code: the validator's own docstring makes 1 "a FINDING: drift a reviewer can act on" and 2 "the question could not be asked ... never an implicit pass", and a traceback with 1 reads as drift.
What is wrong.
U=<openRepoShape checkout> # a clean checkout at c7c02fa
# Runs from a clean checkout of openRepoShape, in bash, on Linux (GNU coreutils). Nothing leaves the machine: members and
# holder live in local bare repositories 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
for P in Atlas Nova Zed; do
python3 $U/scaffold-project.py --org testorg --project $P --elected-by 'Test Human' --elected-on 2026-10-12 \
--local-remote-dir $R/remotes --work-dir $R/work >/dev/null
done
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 2>&1
H=$R/h/Fam; cd $H
add() { python3 $U/scripts/family.py add --family-root $H --member "$1" --local-remote-dir $R/remotes; }
add testorg/Atlas >/dev/null
mkdir $R/nogit && ln -s "$(command -v python3)" $R/nogit/python3 # a PATH with python3 on it and no git
for MODE in "" --pins; do
env PATH=$R/nogit python3 scripts/validate-family.py $MODE 2>&1 | grep -E '^(REFUSED|FileNotFoundError|family ok)' | cut -c1-150; echo "validate ${MODE:-(default)}: exit ${PIPESTATUS[0]}"
done
mv .gitmodules $R/ # the same holder with its .gitmodules gone (a hand edit)
for MODE in "" --pins --no-members; do
env PATH=$R/nogit python3 scripts/validate-family.py $MODE 2>&1 | grep -E '^(REFUSED|FileNotFoundError|family ok)' | cut -c1-150; echo "validate ${MODE:-(default)}: exit ${PIPESTATUS[0]}"
done
env PATH=$R/nogit python3 $R/work/Atlas/scripts/validate-pins.py 2>&1 | grep -E '^(REFUSED|FileNotFoundError|pins ok)'; echo "an assembly root's validate-pins.py: exit ${PIPESTATUS[0]}"Output at c7c02fa:
FileNotFoundError: [Errno 2] No such file or directory: 'git'
validate (default): exit 1
FileNotFoundError: [Errno 2] No such file or directory: 'git'
validate --pins: exit 1
FileNotFoundError: [Errno 2] No such file or directory: 'git'
validate (default): exit 1
FileNotFoundError: [Errno 2] No such file or directory: 'git'
validate --pins: exit 1
family ok: Fam (fam), 1 member(s)
validate --no-members: exit 0
FileNotFoundError: [Errno 2] No such file or directory: 'git'
an assembly root's validate-pins.py: exit 1
On the merge of #318 and #317, the first two runs (the holder as add left it) answer REFUSED gitmodules-unreadable: .gitmodules could not be read as git reads it: git could not be run (No such file or directory), exit 2. Every run after them prints what main prints. With the .gitmodules gone, the default and --pins modes still end in the traceback, exit 1, and --no-members passes, because without the file nothing in that mode runs git.
Why. git_out (scripts/repo_shape.py:831 at c7c02fa) calls subprocess.run(["git", ...]), which raises FileNotFoundError when there is no git to run. recorded_gitlink catches only Refusal, and so does the validator's main, so the exception leaves as a traceback with exit 1. The same helper serves the assembly root's validate-pins.py, which ends the same way (the last line above). That file is not a family file; it is named here for whoever fixes the helper.
What it should do. No validator mode reports exit 1 for "git could not be run". The decisions are the author's:
- Where. Either in the validator, catching
OSErroraround the member checks and refusing, as family.py:addrefusesmember-path-registeredwhen a.gitmodulessection under another name already gives the mount path, andvalidate-family.pyfindsgitmodules-path-twice, soaddno longer commits two sections for one path that a recursive clone resolves to the later one, and a holder that already has such a ghost section fails its own gate (#300) #317 does in_check_gitmodules_paths. Or ingit_out, raising a refusal when git cannot be run, which fixes every caller in every tool at once. - One caution for the second.
recorded_gitlinkswallows aRefusalfromgit_out(except Refusal: continue) and returnsNone, so a missing git would then read as "no gitlink recorded" and the validator would refusemember-gitlink-absent, a false sentence. A refusal for a missing git has to get past that clause, through a code it re-raises or an exception type of its own. - One code for it across the validator. family.py:
addrefusesmember-path-registeredwhen a.gitmodulessection under another name already gives the mount path, andvalidate-family.pyfindsgitmodules-path-twice, soaddno longer commits two sections for one path that a recursive clone resolves to the later one, and a holder that already has such a ghost section fails its own gate (#300) #317 spendsgitmodules-unreadableon its case; a member check without git is a different question.
How to test. In-process, as #317's no-git test does: patch the validator module's git runner to raise FileNotFoundError, changing no PATH, so the test means the same on every OS. In a holder with member rows and no .gitmodules, assert exit 2, a refusal and no traceback, for the default mode and for --pins.
Verified at main c7c02fa and at the scratch merge of #318's head 0f2a0dd with #317's head c23e2b0 (validate-family.py sha256 7ec3eec0…); the merge narrows the finding to a holder whose .gitmodules is gone, and it survives there.
Related: #317 (e0cc351's OSError clause in _check_gitmodules_paths), #292 (the same git_out, decoding strictly), #367 (the same validator, misled by a glob in a row's path).
Activity
brettheap commentedon Oct 12, 2026
CLAIMED — lane openRepoShape-4, session e7cd5391-c790-49b0-a88f-a97d34b22582@Eagle, 2026-10-12T15:18:39Z, for #368
Logged in
lanes/log/openRepoShape-4.mdat66d8014420b9843a5353d05a16fd9bc716274923.The three reads (lane-collision-protocol Rule 1):
validate-pins.py: no git to run, or a git that cannot be executed, re…