Repository navigation
validate-pins.py: no git to run, or a git that cannot be executed, refuses git-not-runnable, exit 2, naming the leg's gitlink, pinned commit or tree digest it could not read, instead of a traceback that exits 1 where 1 is a finding, and only a git that will not start counts, so a leg directory nobody may enter is not told to put git on PATH (#412) - #446
Merged
Merged
Conversation
…aming the leg's gitlink, pinned commit or tree digest it could not read, instead of a `FileNotFoundError` traceback that exits 1 where 1 is a finding, and only a git that will not start counts, so a leg directory nobody may enter is not told to put git on PATH (#412) `git_out` raises `FileNotFoundError` when there is no git on PATH and `PermissionError` when the `git` found there will not run, and nothing in the validator caught either. A scaffolded root on such a machine ended in a traceback out of `recorded_gitlink`: exit 1, which this script's own exit codes give to "a FINDING: drift a reviewer can act on", though nothing was found, and a person or a CI step reading the code took it for drift. The doctor's `pins` row said so too, in its words: "reports a finding (exit 1): FileNotFoundError: [Errno 2] No such file or directory: 'git'". The three calls that run git now run inside `_git_runs`: the leg's gitlink (`recorded_gitlink`), the pinned commit's lookup in the submodule (`git_out`) and the tree digest (`tree_digest`). It refuses `git-not-runnable`, exit 2, "the question could not be asked", naming which of the three it was and saying to put git on PATH and run `scripts/validate-pins.py` again. The code is the one `validate-family.py` chose for the same fact (#368, #406), so a project and a holder say the same thing; the wording is this validator's own and uses no holder words. Three decisions the issue left to its author: - WHERE: in this validator, as #368's fix did in `validate-family.py`, and not in `git_out` or `repo_shape.py`, which every tool shares. The change adds nothing to the names this file imports from `repo_shape`, so a root whose `repo_shape.py` is older than this copy still imports it. - ONLY A GIT THAT WILL NOT START COUNTS. #406's wrapper caught every `OSError` and told an operator whose directory nobody could read to put git on PATH. This one is told apart two ways. The block is around the three git calls alone, never a whole check, so the `(submodule / ".git").exists()` before them, a pin file or a workflow nobody may read stay what they were. And inside a git call it counts a `FileNotFoundError` or `PermissionError` only when `exc.filename` is `git` or absent: `subprocess` raises the same two classes when the DIRECTORY it was to run git in cannot be entered, and on POSIX names that directory where a program that will not start names `git` (probed on Linux, Python 3.12: no git on PATH gives `filename 'git'`, a `git` that is not executable gives `'git'` and a PermissionError, a missing, unreadable or non-directory `cwd` gives the directory). Any other error leaves the wrapper as it entered. - `recorded_gitlink`'s `except Refusal` does not turn the missing git into "no gitlink recorded": an `OSError` is no `Refusal`, passes straight through it and reaches `_git_runs`, so there is no false `pin-gitlink-absent`. The commit lookup's `_git_runs` is OUTSIDE its `try`, for the same reason one step on: inside, its own `except Refusal` would say `pin-commit- unresolvable`, which tells the operator to fetch a commit. `_digest_from_source` (a neutral product's `--pin-source`) keeps its own `except OSError`: it is reached only after every leg's gitlink was read, so no git means the refusal above came first. This file is a pinned copy and reaches existing roots through `update-shape.py` as `upstream-changed`; nothing more is needed here. tests/test_validate_pins_without_git.py (38 tests, run in process, the validator's own `git_out` and the one `recorded_gitlink` and `tree_digest` call in `repo_shape` patched to raise `FileNotFoundError(2, ..., "git")`, no PATH changed): exit 2, the code and no `Traceback` in each of three runs (plain, `--quiet`, `--pin-source`) and for each of the three errors a git that will not start raises; each call named by the check it is; the missing git is neither `pin-gitlink-absent` nor `pin-commit-unresolvable`, each with its control where git runs; the same root says `pins ok` through the same harness; five errors that are no missing git, at each of the three calls, leave the wrapper unchanged; and one POSIX test with no fake, PATH an empty directory. 18 fail on main's validator. Mutants, each killed: the `OSError` clause removed (18 fail), any `OSError` counts (15), the classes narrow but the directory counts (6), `OSError` broad but `filename` checked (6), the refusal exits 1 (18), the commit lookup's wrapper inside its `try` (3), each of the three wrappers removed (13, 3, 2), `recorded_gitlink` swallowing `OSError` in `repo_shape.py` (18). Closes #412 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 4 hours and 48 minutes by commenting |
Reviewer's GuideThe validator now catches only failures that prevent git itself from starting, reports them uniformly as the existing exit-2 git-not-runnable refusal with actionable, check-specific diagnostics, and adds targeted tests to protect classification, error propagation, and existing behavior. Sequence diagram for validate-pins git-not-runnable handlingsequenceDiagram
participant Validator as validate-pins.py
participant Gitlink as recorded_gitlink
participant Git as git_out
participant Digest as tree_digest
participant Refusal as Refusal
Validator->>Gitlink: recorded_gitlink(root, path)
Gitlink-->>Validator: gitlink or OSError
alt git cannot start
Validator->>Refusal: _git_runs converts FileNotFoundError/PermissionError
Refusal-->>Validator: git-not-runnable, exit 2
else gitlink is absent
Validator->>Refusal: pin-gitlink-absent
end
Validator->>Git: git_out(rev-parse, cwd=submodule)
Git-->>Validator: commit lookup result
alt git cannot start
Validator->>Refusal: _git_runs converts startup error
Refusal-->>Validator: git-not-runnable, exit 2
else commit cannot be resolved
Validator->>Refusal: pin-commit-unresolvable
end
Validator->>Digest: tree_digest(submodule, commit)
Digest-->>Validator: digest or startup error
alt git cannot start
Validator->>Refusal: _git_runs converts startup error
Refusal-->>Validator: git-not-runnable, exit 2
else digest differs
Validator-->>Validator: report pin-digest-mismatch
end
Flow diagram for classifying git execution failuresflowchart TD
A["Run one git-dependent operation"] --> B{"FileNotFoundError or PermissionError?"}
B -- No --> C["Propagate the original error"]
B -- Yes --> D{"exc.filename is git or absent?"}
D -- No --> C
D -- Yes --> E["Raise git-not-runnable refusal"]
E --> F["Exit 2 and name the unread check"]
F --> G["Remediation: put git on PATH and rerun validate-pins.py"]
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
This was referenced Oct 12, 2026
…C`) is `git-not-runnable` too, so the rule is any `OSError` whose `filename` is `git`, and the test picks each git call by its subcommand, so #406's `git --literal-pathspecs ls-files` no longer fails eight of its cases on a merge (#412) Fix round 1 of the review of d4b371f. MUST-1. `tests/test_validate_pins_without_git.py` chose the call to fail by `args[0] == "ls-files"` (and `"rev-parse"`, `"ls-tree"`). #406 makes `recorded_gitlink` run `git --literal-pathspecs ls-files -s -- <path>`, so on a merge with it the predicate never matched, the "gitlink" cases failed nothing, and eight of the file's tests went red: whichever of #406 and this PR landed second would have failed CI. Each predicate now reads the git SUBCOMMAND, wherever it sits among git's global options (`subcommand()`, which also skips the value of `-C` and `-c`), and a test of its own feeds each predicate the argv with options in front of it, so the file says so on its own head and not only on a merge with #406. NIT-1. A `git` on PATH that exists and cannot be executed (a binary for another architecture, a script with no shebang) fails `ENOEXEC`, a plain `OSError` whose `filename` is `git`, and ended in the same traceback, exit 1, that #412 is about. `_git_runs` now counts any `OSError` whose `filename` is `git`, and a `FileNotFoundError` or `PermissionError` whose `filename` is None (Windows' `CreateProcess` failures carry no filename, so None is required there and is kept for those two classes only). Every exclusion the tests pin stands: an error naming a directory or another file, and a nameless `OSError` that is neither class, leave the wrapper unchanged. Tests: `OSError(ENOEXEC, ..., "git")` from the fake in every mode and at each of the three calls, a nameless `PermissionError`, and an `ENOEXEC` that names no file or a directory (not git, so not counted). With a real `git` file with no shebang first on PATH, `subprocess` raises `OSError: [Errno 8] ... 'git'` and the run ends in the refusal (checked in scratch, Linux, Python 3.12). Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
git-not-runnable, exit 2, naming the leg's gitlink, pinned commit or tree digest it could not read, instead of a FileNotFoundError traceback that exits 1 where 1 is a finding, and only a git that will not start counts, so a leg directory nobody may enter is not told to put git on PATH (#412)git-not-runnable, exit 2, naming the leg's gitlink, pinned commit or tree digest it could not read, instead of a traceback that exits 1 where 1 is a finding, and only a git that will not start counts, so a leg directory nobody may enter is not told to put git on PATH (#412)
brettheap
added a commit
that referenced
this pull request
Oct 12, 2026
…n whose path no row names is a finding in every mode, a hand-edited `path:` is `member-path` with nothing read through it and `recorded_gitlink` reads its path literally, and no git to run refuses `git-not-runnable` instead of a traceback that exits 1 (#361, #367, #368) (#406) #361. Every member check started from a row, so a gitlink in `members/` or a `.gitmodules` section that no `members:` row names passed the holder's own gate: a member mounted by hand, a row deleted by hand, the section `family.py remove` leaves of two for one path (#360), a section for `Members/Atlas`. Two findings now, each with its own code, both made before the `--no-members` return, as `member-duplicate` (#331) is. `member-gitlink-unnamed` reads every gitlink in `members/` (the directory in any case) from the whole index at stage 0, else HEAD, the order `recorded_gitlink` reads one in. `gitmodules-path-unnamed` reads the sections as git reads the file, in one reading shared with `gitmodules-path-twice`. "Unnamed" is decided exactly, against the path each row gives. A path a row names under `str.casefold()` is still a finding, because macOS and Windows hold the two as one directory, and which of three things it is decides what it says to run. An index git cannot list is a new refusal, `gitlinks-unreadable`. The remedies print each command on its own line: a section is taken out key by key; a gitlink that is a second path to a member that stands (its row's path canonical, and that exact path held too) with `git rm --cached`, which touches no file; and any other gitlink by putting its row back from git, or with a plain `git rm` and then `family.py add`, its project shell-quoted. `git rm --cached` there would leave the checkout in the way, and `add` refuses that as `member-path-taken`. That `git rm` takes the hand mount's section out too, so the section's finding says so and prints no `--unset-all` line, which would exit 5 after it. Where a row was hand-edited into a case variant of its own mount, the finding says the row is the hand edit and to put `members/<project>` back, and a member recorded only at a case variant is said to be in the wrong case, with the `git log -p -- family.yaml` restore sentence: neither prints a command, because `git rm --cached` or `--unset-all` there takes the member's own record out, after which `--no-members` says `family ok`. #367. A row whose hand-edited `path:` is `member-path` now stops its member's checks once its pin is read, and the finding names the one path the row may have. At `members/*` the gitlink listing had read a glob, answered with a neighbour's commit, and ended in a `member-uninitialized` refusal that dropped both findings. `recorded_gitlink` passes git's global `--literal-pathspecs` to both of its listings, never `:(literal)` magic. A caller's exported `GIT_GLOB_PATHSPECS` or `GIT_ICASE_PATHSPECS` is kept out of those two git calls (`git_out` takes an `env`), because git refuses either beside the literal setting, and a refused listing reads as no gitlink. There is one `scripts/repo_shape.py`, copied into every project and holder, so this reaches every caller. The issue's decision 3, a refusal that prints the findings gathered before it, is not taken. #368. Without git, the default and `--pins` modes ended in a `FileNotFoundError` traceback, exit 1, a finding's code. Every git-reading check now runs inside `_git_runs`, which refuses one code for the whole validator, `git-not-runnable`, exit 2, naming the check. #317's no-git clause moves to it, so `gitmodules-unreadable` means only a file git ran and could not read. `recorded_gitlink`'s `except Refusal` lets the `OSError` through. The assembly root's `validate-pins.py` ended in the same traceback; that is #412, fixed in PR #446 with the same code. `_check_members`'s docstring no longer miscounts the checks that need no member checked out. Tests: tests/test_validate_family_unnamed_mounts.py, tests/test_validate_family_member_path_glob.py and tests/test_validate_family_without_git.py, with #317's tests and #331's duplicate tests brought up to the new findings and code. The case-variant states and an unmerged gitlink are built in git's index and objects, so a case-insensitive disk holds them. Closes #361 Closes #367 Closes #368 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap
pushed a commit
that referenced
this pull request
Oct 12, 2026
…t: `check_pin` takes the root its `update-pin-row` remediation reads, so #290's refusal survives #267's split instead of a NameError (#290) Main 580d7df carries #267 (decff87), which split update-shape.py's `read_pin(root)` into the read and `check_pin(pin, pin_path)`, so that shape-doctor.py can check the pins in a root's history in memory. The `update-pin-row` raises moved into `check_pin`, and on this branch they call `pin_rows_remediation(root)`, #290's `git -C <root> log` advice, where `root` is not a name. The textual merge is clean and the tree dies at run time: `NameError: name 'root' is not defined` for a `files:` of `5`, `true` or `1.5` in `check`, `apply` and shape-doctor.py, which then prints no report at all. This commit takes the planned #267 composition, the PR's two attachments fix1-compose-267-resolution-v2.patch and root-pin-faults-after-267.patch, applied unchanged; main moved their hunks by offset only (shape-doctor.py 526 lines, tests/test_shape_doctor.py 521, update-shape.py 1): - update-shape.py: `check_pin(pin, pin_path, root)`, `read_pin` passes its root, and the docstring says `root` is the repository whose history a refusal's `git log` reads. - shape-doctor.py `pin_reads_at` passes `ctx.root`. Its docstring says `check_pin` refuses `files: 5` as `update-pin-row`, and `TypeError` leaves its `except` tuple: no pin's bytes raise one now, so one means a caller out of step with `check_pin`, and the doctor fails loudly rather than answer that no commit holds a pin to put back. - tests/test_shape_doctor.py: #267's ROOT_PIN_FAULTS gains `files: 5`, `true` and `1.5`, each `update-pin-row`, so each is INVALID, exit 1, with `checkout HEAD` as its NEXT, which run as printed leaves the root COMPLIANT. Main's other changes to this PR's files merge as text away from these lines: #446 (245d775) in validate-pins.py, #406 (ec8e09a) in validate-family.py, #379 (87d2eb7) in update-shape.py, and #375 (cd1fc79) and #357 (5678f68) in shape-doctor.py. Neither module has changed on main since decff87. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap
pushed a commit
that referenced
this pull request
Oct 12, 2026
…landed: `_commit_the_split` keeps #445's reported steps and reads `follow_ups:` through `_plan_follow_ups` Main moved from dd8d0ea to 580d7df: decff87 (#267), dba5fb0 (#403), 245d775 (#446), 75052cb (#445), ec8e09a (#406) and 580d7df (#416). Only #445 touches adopt-project.py, and it conflicted in one hunk of `_commit_the_split`. #445 wraps that function's body in a `try` that names each step (cloning the source for the split, mounting the legs, committing the split, pushing the split branch), so a failure once both legs are pushed flushes stdout, prints `REFUSED <step>.`, what failed and one notice of what the run left. This branch reads `follow_ups:` there through `_plan_follow_ups`, the one read of it (#293). The resolution keeps #445's `try`, steps and reports whole, and carries this branch's read into its "committing the split" step: git had kept main's `[str(f) for f in plan.get("follow_ups", [])]` inside the `try`. The two contracts meet without overlap. `plan-bad-follow-ups` and `election-date-malformed` are findings of `_entry_findings`, which `execute` refuses through `_refuse_what_check_finds`. That runs before #445's `_refuse_an_occupied_work_dir`, `_refuse_a_split_branch_left_behind` and `_create_leg_remotes`, so these are refusals before any leg exists: exit 2, `REFUSED <code>:` and the remediation that says no leg repository was created and nothing was pushed, as #445's own refusals before the legs say, and no notice. #445's report covers the failures once a leg exists, which these values no longer reach. The guard in `_manifest_values` is unchanged; #445 does not touch it. #445's comment over `_template_values` in `cmd_execute` named "an `elected_on` that is not a date" as a refusal the table can raise. With #294's finding, `_refuse_what_check_finds` refuses such a date first, so the comment now says the date no longer reaches the table. #445's test of a table refusal stubs `_template_values` and is unchanged. tests/test_adopt_execute_refusals.py: the two end-to-end runs through `main` (#293, #294) now also assert that the refusal says no leg was created and that #445's notice ("NOTHING has been rolled back") does not follow it. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap
pushed a commit
that referenced
this pull request
Oct 12, 2026
…eport of a failure after the legs carries the materializer's refusals, and its two early refusals follow this branch's `core.symlinks` probe origin/main moved dd8d0ea -> 75052cb (four commits), merged by sha: - decff87 (#237 via #267), shape-doctor.py and update-shape.py; - dba5fb0 (#383 via #403), scripts/bump-leg.py and docs/handbook.html; - 245d775 (#412 via #446), the template's scripts/validate-pins.py; - 75052cb (#171, #188 and #177 via #445), adopt-project.py and AGENTS.md: every failure once a leg repository exists is reported at its step as `REFUSED <step>.`, what failed, and `_nothing_rolled_back`'s notice, exit 2; `execute` refuses `adopt-work-dir-occupied` and `adopt-branch-exists` before it creates anything. tests/test_adopt_ignored_shape_files.py and every other file merge with no conflict. adopt-project.py conflicts in five hunks, each resolved by keeping both sides: 1. The materializer's import list: this branch's names (BOOTSTRAP, SYMLINK_HOPS, TOO_MANY_SYMLINKS, template_files), without RULESET_HINT, which #188 replaced by its own ADOPT_RULESET_HINT and nothing here uses. 2-4. `Source`: this branch's `work_root`, where a climb above the root leads (#183), and #188's `cloned`, read by `_cloned_here`. Both are keyword-defaulted, so the four-argument constructions in the tests and #188's `cloned=True` read as before, and `open` hands both on. 5. `_commit_the_split`: main's steps inside #188's report, with this branch's `spell=_spelled`, and its comment, on the materializer call. How #188 meets this branch's code: - `execute`, all before `_create_leg_remotes`: `_refuse_what_check_finds` (with `plan-root-symlink`), this branch's `core.symlinks` probe (`adopt-symlinks-off`), `_refuse_unconsented_seeding`, then #188's `adopt-work-dir-occupied` and `adopt-branch-exists`, then the confirmation and `_template_values`. A plan's own faults come first, then whether `git` here can hold what the plan keeps, then what an earlier run left, whose way out is "run `execute` again". The probe's throwaway repository (`symlinks-probe-*` in the work directory) is removed before `_refuse_an_occupied_work_dir` reads the work directory, and it is none of the three directories that function asks about. - The materializer's refusals (`materialize-outside`, `materialize-symlink-loop`, `materialize-blocked`) are raised inside the step "committing the split", which #188 wraps. They are `Refusal`s, so `_failed_loudly` prints `REFUSED committing the split. <code>: <detail>` on one line, still spelled by `_spelled`, then their remediation, then the notice that both legs are pushed; `execute` exits 2, as it did. - The two end-to-end tests of that path expected the old one-line `REFUSED <code>:`. The next commit makes them expect #188's report. Checked on the merged tree: - AST (fin183g/names.py, base dd8d0ea): no top-level name is bound twice in adopt-project.py, scaffold-project.py or scripts/repo_shape.py, and none is loaded but bound nowhere; scripts/shape_materialize.py binds `YamlError` and `parse_yaml` twice, as main does since 3ef059c. Main changed the signatures of `Source.__init__` (`cloned=False`), `_create_leg_remotes` (`work_root`, `source=None`) and `_verify` (`urls`, `branch`); every call of the last two is main's, and every construction of `Source` passes four arguments or `cloned=` by keyword. - Both sides intact (fin183g/sides_intact.py): the only lines that differ from either side's own change are those of the five hunks. - A rehearsal of this merge (tree 59eca4e, the same as this one), with the next commit's test change, passed tests/test_adopt_root_symlinks.py, tests/test_adopt_late_failure.py, tests/test_adopt_filter_repo_failure.py and tests/test_adopt_ignored_shape_files.py: `231 passed`. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Lane: openreposhape-4 (openRepoShape-4)
Claim: #412 (comment)
What and why
An assembly root's
scripts/validate-pins.pyon a machine with no git ended in aFileNotFoundErrortraceback, exit 1. This file's own exit codes give 1 to "a FINDING: drift a reviewer can act on" and 2 to "the question could not be asked ... never an implicit pass", so a CI step or a person reading the code took a missing git for drift, though nothing was found. The doctor'spinsrow said so in its own words.git_outraisesFileNotFoundErrorwhen there is no git on PATH,PermissionErrorwhen thegitfound there will not run, and a plainOSError(ENOEXEC) when it cannot be executed (a binary for another architecture, a script with no shebang), and nothing in the validator caught any of them. The three calls that run git now run inside_git_runs, which refusesgit-not-runnable, exit 2, naming the check and saying to put git on PATH and runscripts/validate-pins.pyagain:recorded_gitlink):the spec leg's gitlink recorded at 'spec' could not be read;git_out):spec: the pinned commit <12 hex> could not be looked up in that submodule;tree_digest):spec: the tree digest at <12 hex> could not be recomputed.The issue's own repro, at this head (
validate-manifest.pyruns no git and still passes):A
giton PATH that is not executable says the same with(Permission denied), and one that cannot be executed with(Exec format error).The rulings
git_outorrepo_shape.py, as 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's fix did invalidate-family.py.scripts/repo_shape.pyis untouched, and the change adds nothing to the names this file imports from it, so a root whoserepo_shape.pyis older than this copy still imports it.(submodule / ".git").exists()before them, a pin file or a workflow nobody may read stay exactly what they were. (b) Inside a git call the rule is the NAMEsubprocessgives the error: anyOSErrorwhosefilenameisgitcounts (a missing git, one that will not run, one that cannot be executed:ENOEXEC), and so does aFileNotFoundErrororPermissionErrorwhosefilenameis None, those two classes only (Windows'CreateProcessfailures carry no filename, so None is required there).subprocessraises the same two classes when the DIRECTORY it was to run git in cannot be entered, and names that directory where a program that will not start namesgit. Probed on Linux, Python 3.12: no git on PATH givesFileNotFoundErrorwithfilename 'git'; agitthat is not executable givesPermissionError,'git'; agitfile with no shebang givesOSError: [Errno 8] Exec format error,'git'; a missing, unreadable or non-directorycwdgives the directory's path. Any otherOSError(anEIO, an out-of-memory, a directory's own error, a nameless one of neither class) leaves the wrapper as it entered: nogit-not-runnable, no remedy about PATH. validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406's wrapper caught everyOSError; see "Follow-ups".recorded_gitlink'sexcept Refusaldoes not turn the error into "no gitlink recorded". AnOSErroris noRefusal, so it passes straight through that clause and reaches_git_runs; there is no falsepin-gitlink-absent. Tested both ways:test_a_missing_git_is_not_read_as_no_gitlink, and its controltest_a_gitlink_the_root_does_not_record_is_still_pin_gitlink_absent(git runs, records nothing, the old refusal stands). The pinned commit's lookup has the same shape one step on: its ownexcept Refusalwould saypin-commit-unresolvable("fetch that commit"), so_git_runssits OUTSIDE itstry(test_a_missing_git_is_not_read_as_an_unresolvable_commit, controltest_a_commit_git_cannot_find_is_still_pin_commit_unresolvable).git-not-runnable, the code validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406 chose for the holder, so a project and a holder say the same thing. The wording is this validator's own (an assembly root,scripts/validate-pins.py, what to do) and uses no holder words. The module docstring's EXIT CODES now names it under 2.scripts/validate-pins.pyis a pinned copy. It reaches existing roots throughupdate-shape.pyasupstream-changed, and nothing more is needed here. Checked with a root scaffolded from main 3ef059c andupdate-shape.py check --upstream <this checkout>(local path):upstream-changed scripts/validate-pins.py,unchangedon the other ten rows, nothing written.pinsrow on a machine with no git, on a root scaffolded from main and on one scaffolded from this head:FINDING pins the project's own scripts/validate-pins.py reports a finding (exit 1): FileNotFoundError: [Errno 2] No such file or directory: 'git';FINDING pins the project's own scripts/validate-pins.py refuses (exit 2): REFUSED git-not-runnable: the spec leg's gitlink recorded at 'spec' could not be read: git could not be run (No such file or directory).tests/test_shape_doctor.pyandtests/test_doctor_legs_members_next.pyhold noFileNotFoundError,No such fileor[Errno 2](the one[Errno 13] Permission deniedis a hand-builtplacementrow).tests/test_doctor_validator_rows.pyis on main now (shape-doctor.py: a validator that crashed says it crashed instead of "reports a finding", and is CANNOT ANSWER when it is the standard's template, which a standard whose path holds thePYTHONPATHseparator no longer runs at all; a neutral product's digestvalidate-pins.pyskipped is anotenaming the product, from a child that can no longer reachghor a proxy; and a root whose manifest does not load and whosescripts/repo_shape.pyis gone is told to restore that file before its gate (#278, #297, #298) #357 landed as 5678f68); its oneTracebackassertion is about an injectedRuntimeError: boom, and the file passes on the merge below.Tests and mutants
tests/test_validate_pins_without_git.py, 58 tests, in process: the validator's owngit_outand the onerecorded_gitlinkandtree_digestcall insiderepo_shapeare patched to raise what a git that will not start raises, and no PATH is changed, so each test means the same on every OS. The fake takes**kwargs, so it keeps working if #406'senv=lands ongit_out, and it picks the call to fail by the git SUBCOMMAND (subcommand()skips git's global options and the value of-C/-c), never byargs[0]: #406'srecorded_gitlinkrunsgit --literal-pathspecs ls-files ..., which the first draft'sargs[0] == "ls-files"stopped matching (Fix round 1, MUST-1).Traceback, in each of three runs (plain,--quiet,--pin-source) and for each of five errors a git that will not start raises (missing, not executable, cannot be executed, and aFileNotFoundErrorand aPermissionErrorthat name no file);--literal-pathspecs,-C <dir>,-c <k=v>), and the second gitlink listing is none of them;pins ok(the control of the whole file);EIO,ENOMEM, anENOEXECthat names no file, one that names a directory), at each of the three calls, leave the wrapper unchanged: the same exception object comes out and nogit-not-runnableis said;subprocessraises, so theexc.filenamereading is checked againstsubprocessitself and not only against this change's picture of it. It skips on Windows, where the program search is not PATH's alone.On main's validator 27 of the 58 fail (the 31 that pass are the controls, the predicate test and the seven non-git errors, which main passes through because it catches nothing).
Mutants, run on this head's bytes (scratch worktree, never committed), tests failing of 58:
OSErrorclause removedOSErrorcounts as git not runnablefilenamecheck dropped (a directory counts, and a git that cannot be executed does not)OSErrorbroad,filenamenot in (None,git) (a nameless error of any class counts)tryrecorded_gitlink(repo_shape.py) swallowsOSErroras it doesRefusalFileNotFoundError/PermissionErroronly)filenamegitcounts, Windows)FileNotFoundErroronlyENOEXECcounted by its errno, whatever file it namesargs[0] == "ls-files"args[0]subcommand()stops skipping the value of-Cand-cEvery mutant is killed. Runs (identity exported, frvenv and a
ghshim first on PATH,--basetempcarryingw412f, noGIT_ALLOW_PROTOCOL):tests/test_validate_pins_without_git.py tests/test_pin_validator.py tests/test_neutral_pin_validator.py tests/test_scaffold_pin_and_reuse.py tests/test_agents_md_codes.py: 169 passed. (tests/test_doctor_validator_rows.pyis not on this branch's base, 3ef059c.)members/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406's head e83cc21 and b71f3fc, the same five files andtests/test_doctor_validator_rows.py: 197 passed; this file alone: 58 passed.validate-pins.pyand take minutes (test_repo_hygiene,test_cli_reference,test_validator_child_encoding,test_pin_rewrite_layout,test_scaffold_e2e,test_bump_leg,test_update_shape,test_update_shape_add,test_update_shape_landing,test_doctor_legs_members_next,test_scaffold_mount_ignored): 563 passed, 1 skipped (test_bump_leg.py::test_a_tracked_workflow_respelled_by_case_moves_under_gits_spelling); a scratch triple merge with validate-pins.py (assembly-root template): a workflow file under.github/workflows/that is not UTF-8, or that this user cannot open, is ONEworkflow-unreadablerefusal naming every such file, exit 2, so the project's pins gate no longer ends in a UnicodeDecodeError traceback, exit 1, with no line naming the file (#359) #419 and scaffold-project.py, validate-pins.py, repo_shape.py:open-glassandopen_glassshare oneSHAPE_PIN_SOURCE_<PRODUCT>, so--pinrefuses the pair andvalidate-pins.pynotes a declared one and reads the variable for neither (#275) #409: 304 passed. Not repeated at b71f3fc: this round changes the rule in_git_runsand the test file, and CI's three OSes are the gate.test_shape_doctor.py(over twenty minutes at this load, and no doctor code changed) and thetest_adopt_*,test_family*,test_naming_policyand othertest_scaffold_*files that only mention the script.Fix round 1 (review of d4b371f)
members/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406. The fake chose the call to fail byargs[0] == "ls-files"(and"rev-parse","ls-tree"). validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406 makesrecorded_gitlinkrungit --literal-pathspecs ls-files -s -- <path>(GITLINK_LISTINGS), so on a merge with it the predicate never matched and 8 of the 38 tests failed (the "gitlink" cases); whichever of validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406 and this PR landed second would have gone red. Each predicate now reads the subcommand wherever it sits among git's global options, and a test of its own feeds each predicate the argv with options in front of it, so the file says so on its own head and not only on a merge. Proved both ways: this file passes 58 of 58 on this head and on a scratch merge of origin/main with validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406's head; with the predicate put back toargs[0]it fails 4 here and 15 on that merge. validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406 needs no change.gitthat exists but cannot be executed (ENOEXEC) still ended in a traceback. A binary for another architecture or a script with no shebang makessubprocessraise a plainOSErrorwhosefilenameisgit, exit 1. The rule is now "anyOSErrorwhosefilenameisgit, or aFileNotFoundError/PermissionErrorwhosefilenameis None" (None kept for those two classes only, for Windows'CreateProcess). Every exclusion the tests pin stands. With a realgitfile with no shebang first on PATH, the run now endsREFUSED git-not-runnable: ... git could not be run (Exec format error), exit 2 (checked in scratch; the committed tests use the fake). Tests added:OSError(ENOEXEC, ..., "git")in every mode and at each of the three calls, a namelessPermissionError, anENOEXECthat names no file and one that names a directory (neither is git); mutants above.tests/test_doctor_validator_rows.pyis on main (shape-doctor.py: a validator that crashed says it crashed instead of "reports a finding", and is CANNOT ANSWER when it is the standard's template, which a standard whose path holds thePYTHONPATHseparator no longer runs at all; a neutral product's digestvalidate-pins.pyskipped is anotenaming the product, from a child that can no longer reachghor a proxy; and a root whose manifest does not load and whosescripts/repo_shape.pyis gone is told to restore that file before its gate (#278, #297, #298) #357 landed as 5678f68): the sentence that said it was not is corrected above, and the file is in the merge run. The wrapper's rule now coversENOEXEC. validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406's broader wrapper is to adopt this rule in a follow-up (its NIT-2); the lasting home for one shared wrapper isscripts/repo_shape.py.Neighbours (
git merge-tree --write-tree, head b71f3fc against each, origin/main dd8d0ea at the time)fix/pin-files-not-a-listfix/pin-source-env-collisionsfix/validate-pins-workflow-not-utf8fix/validate-family-unnamed-mounts--literal-pathspecsis why the first draft did not, see MUST-1)My first draft appended to the EXIT CODES line #419 re-wraps and conflicted with it; the sentence is now a pure insertion after that line (the commit was amended before it was pushed). The edits are the docstring, two imports,
_git_runs(placed above_check_gitlink_matches_pin) and the three call sites; none is in_check_shape_pin,_workflow_refsor the neutral-pin functions.Follow-ups (named, not done here)
members/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406's_git_runsinvalidate-family.pycatches anyOSError(and wraps the whole of_check_member_tree); the review of 423fb25 (NIT-2) found it tells an operator with an unreadablemembers/<Project>to put git on PATH. validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406's wrapper is to adopt this rule in a follow-up (validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406's NIT-2): anyOSErrorwhosefilenameisgit, or aFileNotFoundError/PermissionErrorwith none. The lasting fix is ONEgit_runsinscripts/repo_shape.py, which both gates receive, the remediation's subject a parameter; that waits for validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406 and scaffold-project.py, validate-pins.py, repo_shape.py:open-glassandopen_glassshare oneSHAPE_PIN_SOURCE_<PRODUCT>, so--pinrefuses the pair andvalidate-pins.pynotes a declared one and reads the variable for neither (#275) #409 to clearrepo_shape.py.OSError[WinError 193]fromCreateProcesswith no filename, which is neither of the two classes the nameless clause counts, so it is not counted here (a reading of CPython, not run on Windows; the ruling keeps None for those two classes)._digest_from_source(a neutral product's--pin-source) keeps its ownexcept OSErrorand is left alone: it is reached only after every leg's gitlink was read, so with no git the refusal above comes first.shape currency,legs) still readcould not be run: [Errno 2] ... 'git', and the verdict readsDRIFTED (1 leg(s) not at the pin);scripts/bootstrap.pyin an assembly root ends in the same traceback, exit 1. Neither is this file.PermissionErrortraceback, exit 1, out of(submodule / ".git").exists()in_check_pin_digest(probed on Python 3.12; the same exit-code defect as 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, a different cause). That is the case validate-family.py: a gitlink inmembers/or a.gitmodulessection whose path no row names is a finding in every mode, a hand-editedpath:ismember-pathwith nothing read through it andrecorded_gitlinkreads its path literally, and no git to run refusesgit-not-runnableinstead of a traceback that exits 1 (#361, #367, #368) #406's review found its wrapper mislabelling, and here it is deliberately NOTgit-not-runnable; what it should be is a separate decision. A pin file nobody may read is alreadyyaml-unreadable, exit 2, and an unreadable workflow is validate-pins.py (assembly-root template): a workflow file under.github/workflows/that is not UTF-8, or that this user cannot open, is ONEworkflow-unreadablerefusal naming every such file, exit 2, so the project's pins gate no longer ends in a UnicodeDecodeError traceback, exit 1, with no line naming the file (#359) #419's.Closes #412
🤖 Generated with Claude Code