Skip to content

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
brettheap merged 2 commits into
mainfrom
fix/validate-pins-no-git
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #412 (comment)

What and why

An assembly root's scripts/validate-pins.py on a machine with no git ended in a FileNotFoundError traceback, 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's pins row said so in its own words.

git_out raises FileNotFoundError when there is no git on PATH, PermissionError when the git found there will not run, and a plain OSError (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 refuses git-not-runnable, exit 2, naming the check and saying to put git on PATH and run scripts/validate-pins.py again:

  • the leg's gitlink (recorded_gitlink): the spec leg's gitlink recorded at 'spec' could not be read;
  • the pinned commit's lookup in the submodule (git_out): spec: the pinned commit <12 hex> could not be looked up in that submodule;
  • the tree digest (tree_digest): spec: the tree digest at <12 hex> could not be recomputed.

The issue's own repro, at this head (validate-manifest.py runs no git and still passes):

validate-pins.py, git on PATH: exit 0      pins ok
validate-pins.py, no git: 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)
Remediation: put git on PATH and run `scripts/validate-pins.py` again. This assembly root's lockstep gate reads each leg's gitlink and recomputes its pinned tree digest through git, so without it nothing says whether the pins agree, and a gate that cannot answer is never a pass.

A git on PATH that is not executable says the same with (Permission denied), and one that cannot be executed with (Exec format error).

The rulings

  1. WHERE: in this validator, not in git_out or repo_shape.py, as 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's fix did in validate-family.py. scripts/repo_shape.py is untouched, and the change adds nothing to the names this file imports from it, so a root whose repo_shape.py is older than this copy still imports it.
    • Only a git that will not start counts, told apart two ways. (a) The block is around the three git calls alone, never a whole check: the (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 NAME subprocess gives the error: any OSError whose filename is git counts (a missing git, one that will not run, one that cannot be executed: ENOEXEC), and so does a FileNotFoundError or PermissionError whose filename is None, those two classes only (Windows' CreateProcess failures carry no filename, so None is required there). subprocess raises 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 names git. Probed on Linux, Python 3.12: no git on PATH gives FileNotFoundError with filename 'git'; a git that is not executable gives PermissionError, 'git'; a git file with no shebang gives OSError: [Errno 8] Exec format error, 'git'; a missing, unreadable or non-directory cwd gives the directory's path. Any other OSError (an EIO, an out-of-memory, a directory's own error, a nameless one of neither class) leaves the wrapper as it entered: no git-not-runnable, no remedy about PATH. validate-family.py: a gitlink in members/ or a .gitmodules section 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's wrapper caught every OSError; see "Follow-ups".
    • recorded_gitlink's except Refusal does not turn the error into "no gitlink recorded". An OSError is no Refusal, so it passes straight through that clause and reaches _git_runs; there is no false pin-gitlink-absent. Tested both ways: test_a_missing_git_is_not_read_as_no_gitlink, and its control test_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 own except Refusal would say pin-commit-unresolvable ("fetch that commit"), so _git_runs sits OUTSIDE its try (test_a_missing_git_is_not_read_as_an_unresolvable_commit, control test_a_commit_git_cannot_find_is_still_pin_commit_unresolvable).
  2. ONE CODE: git-not-runnable, the code validate-family.py: a gitlink in members/ or a .gitmodules section 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 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.
  3. LANDING. scripts/validate-pins.py is a pinned copy. It reaches existing roots through update-shape.py as upstream-changed, and nothing more is needed here. Checked with a root scaffolded from main 3ef059c and update-shape.py check --upstream <this checkout> (local path): upstream-changed scripts/validate-pins.py, unchanged on the other ten rows, nothing written.
  4. THE DOCTOR is unchanged. Its pins row on a machine with no git, on a root scaffolded from main and on one scaffolded from this head:

Tests and mutants

tests/test_validate_pins_without_git.py, 58 tests, in process: the validator's own git_out and the one recorded_gitlink and tree_digest call inside repo_shape are 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's env= lands on git_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 by args[0]: #406's recorded_gitlink runs git --literal-pathspecs ls-files ..., which the first draft's args[0] == "ls-files" stopped matching (Fix round 1, MUST-1).

  • exit 2, the code and no 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 a FileNotFoundError and a PermissionError that name no file);
  • each of the three calls named by the check it is, one failing at a time, for a missing git, a git that is not executable and one that cannot be executed;
  • each of the three predicates picks its own call and no other with global options in front of the subcommand (--literal-pathspecs, -C <dir>, -c <k=v>), and the second gitlink listing is none of them;
  • the two "not read as" tests above, each with its control where git runs;
  • the same root through the same harness says pins ok (the control of the whole file);
  • seven errors that are no git that will not start (an unreadable directory, a missing one, a non-directory, EIO, ENOMEM, an ENOEXEC that 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 no git-not-runnable is said;
  • one test with no fake, POSIX only: PATH names an empty directory and the real subprocess raises, so the exc.filename reading is checked against subprocess itself 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:

mutant failing
the OSError clause removed 27
any OSError counts as git not runnable 21 (every non-git case)
classes kept, the filename check dropped (a directory counts, and a git that cannot be executed does not) 12
OSError broad, filename not in (None, git) (a nameless error of any class counts) 9
the refusal exits 1 (every refusal / only this code) 29 / 27
the commit lookup's wrapper moved inside its try 4
the gitlink wrapper removed / the commit wrapper removed / the digest wrapper removed 20 / 4 / 3
recorded_gitlink (repo_shape.py) swallows OSError as it does Refusal 27
the remediation drops "put git on PATH" 25
Fix round 1: the new clause dropped (d4b371f's rule: FileNotFoundError/PermissionError only) 6
the nameless clause dropped (only filename git counts, Windows) 6
the nameless clause for FileNotFoundError only 3
ENOEXEC counted by its errno, whatever file it names 6
the test's gitlink predicate back to args[0] == "ls-files" 4 here, 15 on the merge with #406
the test's commit / digest predicate back to args[0] 4 / 4
subcommand() stops skipping the value of -C and -c 2

Every mutant is killed. Runs (identity exported, frvenv and a gh shim first on PATH, --basetemp carrying w412f, no GIT_ALLOW_PROTOCOL):

Fix round 1 (review of d4b371f)

Neighbours (git merge-tree --write-tree, head b71f3fc against each, origin/main dd8d0ea at the time)

branch head result
origin/main dd8d0ea clean
#343 fix/pin-files-not-a-list 9e6caeb clean
#409 fix/pin-source-env-collisions 6494714 clean
#419 fix/validate-pins-workflow-not-utf8 11dff7f clean
#406 fix/validate-family-unnamed-mounts e83cc21 clean, and semantically: this file passes 58 of 58 on the merge (the argv prefix --literal-pathspecs is 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_refs or the neutral-pin functions.

Follow-ups (named, not done here)

Closes #412

🤖 Generated with Claude Code

…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>
@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

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 @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Reviewer's Guide

The 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 handling

sequenceDiagram
    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
Loading

Flow diagram for classifying git execution failures

flowchart 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"]
Loading

File-Level Changes

Change Details Files
Translate git startup failures into an explicit exit-2 refusal instead of an uncaught traceback or a misleading pin finding.
  • Add a context manager that recognizes only FileNotFoundError or PermissionError referring to git (or no filename).
  • Emit the single git-not-runnable code with check-specific diagnostics and PATH remediation.
  • Wrap the gitlink, pinned-commit lookup, and tree-digest calls while preserving existing refusal handling and exit behavior for other OSErrors.
  • Document git-not-runnable among the validator's exit-2 refusal codes.
templates/assembly-root/scripts/validate-pins.py
Add comprehensive regression coverage for missing or non-executable git and distinguish it from unrelated filesystem failures.
  • Test exit 2, no traceback, consistent remediation, and check-specific messages across validator modes and all three git call sites.
  • Verify missing git is not misclassified as an absent gitlink or unresolvable commit, while controls preserve those existing refusals when git runs.
  • Verify directory and unrelated OSError failures pass through unchanged, and exercise a real PATH-based missing-git subprocess failure.
  • Cover the successful pins-ok path and parameterize missing-git error forms and invocation modes.
tests/test_validate_pins_without_git.py

Assessment against linked issues

Issue Objective Addressed Explanation
#412 When validate-pins.py cannot start git for a git-backed check, report a refusal with exit code 2 and code git-not-runnable, rather than a traceback that exits 1. ✅
#412 Apply the refusal consistently to the gitlink, pinned-commit lookup, and tree-digest checks, while preserving their existing findings and refusals when git runs or when an error is unrelated to starting git. ✅

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

…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>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap brettheap changed the title validate-pins.py: no git to run refuses 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) 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) Oct 12, 2026
@brettheap
brettheap merged commit 245d775 into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/validate-pins-no-git branch October 12, 2026 20:04
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment