Skip to content

adopt-project.py: check's throwaway repositories are made in the work directory with the source's object format and no hook of this machine's; a path git refuses is plan-path-git-refuses, a repository git cannot make there adopt-git-unasked (#910) - #1031

Merged
brettheap merged 10 commits into
mainfrom
fix/910-check-throwaway-repos
Oct 21, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What

adopt-project.py check asks this machine's git what the split will make of
the source in throwaway repositories: the split rehearsal (#654), the
attributes probe (#619, #530), _stored_as_written (#530 D5(a)) and the
symlink probe (#183). After this change they answer as the split will, or
check says why they cannot. They no longer stop with git-failed, and
they no longer answer about another disk or another config.

Item Change
L1-90 Every throwaway repository except the symlink probe's is made with git init --object-format=<the source's>. Source.object_format reads that format once, with git rev-parse --show-object-format. The symlink probe keeps its plain git init and asks only core.symlinks (see L1-32). A path of the source commit that git refuses as a path (.., .Git/x, notes/.git/x) gets the new finding plan-path-git-refuses. The finding names the paths and says that each clone the split makes would stop at them, so execute refuses them before it makes either leg. Its remedy is to rename or remove the path in the source and run plan again; no plan answer changes it, drop included. Beside such a path, check asks nothing more of the source's files: plan-file-reencoded is asked once the path is gone.
L1-92 The work directory: --work-dir, or the default one check makes in TMPDIR, but never TMPDIR itself. That is where execute builds the split, and check --work-dir already asks for the one execute will be given, so the answer is about the disk the split meets. The other choice, saying where they were made, would still answer about another disk. The repositories are removed afterwards.
L1-94 Every git command in the rehearsal, the attributes probe and _stored_as_written runs with -c core.hooksPath=<an empty directory beside the repository>, and their git init is given --template=<that directory>. A command-line -c outranks the config files and GIT_CONFIG_COUNT. The symlink probe's git init and git config --get run no hook.
L1-32 When the symlink probe's git init fails without an OSError (a CommandFailed or a Refusal), the probe takes git to check symlinks out as symlinks, as on a disk that holds them, and prints one NOTE with git's words. It used to print "a git or gh command failed".
new adopt-git-unasked: where git cannot make or ask a throwaway repository in the work directory, because a git init or a step of the rehearsal fails there, there is one finding that gives git's reason, and execute refuses it before any leg. Each throwaway git init names its branch (-b), as the split's own git inits do, so an init.defaultBranch git will not take (a..b) stops none of the questions asked in its throwaway repositories. Where no directory can be made there at all, adopt-work-dir-unwritable (#524) stays the only finding, and a NOTE says what went unasked.

Measured

check against a fresh source, with a stub gh, first on c7a3ff16
(before) and then on this branch (after):

Case Before After
SHA-256 source exit 2, REFUSED git-failed at the rehearsal's read-tree exit 0, plan ok
GIT_DEFAULT_HASH=sha256, SHA-1 source exit 2, REFUSED git-failed (Not a valid object name) exit 0, plan ok
.. in the source commit exit 2, REFUSED git-failed: ... error: invalid path '..' exit 1, FINDING plan-path-git-refuses
.Git/x in the source commit exit 2, REFUSED git-failed: ... error: invalid path exit 1, FINDING plan-path-git-refuses
counting hook named by core.hooksPath in GIT_CONFIG_GLOBAL 12 runs (attributes probe 2, rehearsal 10) 0 runs (control: a plain git add runs it once)
where the repositories are made TMPDIR (attributes probe x2, rehearsal x1) the work directory, which is empty again afterwards
GIT_DEFAULT_HASH=bogus, .github -> ../outside-ci exit 2, "a git or gh command failed ... unknown hash algorithm 'bogus'" exit 1, FINDING plan-root-symlink and the probe's NOTE
init.defaultBranch = a..b exit 2, REFUSED git-failed exit 0, plan ok: every question is asked (on git 2.43, execute then stops after both legs; see the follow-ups)
a work directory nobody can write in adopt-work-dir-unwritable, with the rehearsal asked in TMPDIR adopt-work-dir-unwritable and one NOTE

Why plan-path-git-refuses refuses before either leg: with the rehearsal
patched out, as before #654, execute on a source holding .. created both
legs and then stopped with REFUSED while extracting the spec leg (its clone
exited 128, invalid path). On this branch execute refuses
plan-path-git-refuses before any leg exists
(test_execute_refuses_a_path_git_refuses_before_any_leg).

Why init.defaultBranch = a..b stops none of the questions asked in its
throwaway repositories: the split's clone of the source does not stop at it
on git 2.55, which CI runs. An earlier head of this PR read it at every
throwaway git init, and so refused, as adopt-git-unasked, a split that
clones there (CI run 39404718064). On git 2.43 git clone does stop at it;
see the follow-ups.

Tests

  • tests/test_adopt_check_throwaway_repos.py, new: 29 rows, covering each
    item and its controls. On c7a3ff16's adopt-project.py, 24 fail and 5
    pass, and the 5 that pass are all controls (measured by the review,
    r1031).
  • The rows that need a throwaway git init to fail use a stub git first on
    PATH, which refuses only a git init given a --template. Which setting
    stops a git init depends on git's version, so these rows do not rely on
    one. They are skipped on Windows.
  • The head is 74c1770. git merge-tree --write-tree 74c17705 origin/main
    is clean against main 5e539a8.
  • CI's full suite on 74c1770 (Linux, git 2.55.0): 19,000 passed, 372
    skipped.
  • Gates in trial merges of 74c1770 with main, run on named files only, with
    one basetemp, a stub gh, and at most 100 tests per pytest call. The list
    is 125 test files: every one that loads adopt-project.py or names a
    function or NOTE this PR touches. A usage checkpoint stopped each run part
    of the way through the list:
    • a2d8547e (main eee089c): 33 files and part of a 34th, 2,100 passed, 2
      skipped, 0 failed;
    • 559acb12 (main 00f6d7f): 78 files and part of a 79th, 5,493 passed, 9
      skipped, 0 failed;
    • fca6ad84 (main 6c86163): 47 files and part of a 48th, 3,362 passed, 3
      skipped, 0 failed.
      The last 14 files of the list, from test_scaffold_manifest_values.py on
      (test_shape_doctor.py among them), ran in none of these merges. CI's
      full suite ran them on 74c1770.
  • The review, r1031, ran its own gate on its trial merge 3559f637 (main
    6c86163): 1,302 passed, 0 failed.
  • The landing gate adds its own counts over the main it lands on.

Not in this PR (follow-ups, filed as #1083)

Closes #910

Lane: openreposhape-1 (openRepoShape-1)

🤖 Generated with Claude Code

Summary by Sourcery

Make adopt-project.py check validate the split using isolated throwaway repositories that match the source and work environment, while reporting Git limitations as actionable findings instead of generic failures.

New Features:

  • Add source-path validation that reports paths Git refuses as plan-path-git-refuses and blocks execution before either split leg is created.
  • Add adopt-git-unasked reporting when Git cannot create or complete a throwaway check repository, including the reason and execution refusal.
  • Make throwaway checks use the source object format, the split work directory, and isolated hooks so their results match the planned split environment.
  • Improve symlink-probe failures by preserving the symlink finding while reporting Git's failure as a note.

Bug Fixes:

  • Prevent check from failing with generic git-failed errors for incompatible object formats, invalid paths, repository setup failures, and symlink-probe failures.
  • Prevent machine Git hooks and unrelated temporary-directory characteristics from altering throwaway repository checks.

Enhancements:

  • Reuse a shared throwaway-repository lifecycle for attributes, rehearsal, stored-file, and path validation checks, with cleanup after each check.

Tests:

  • Add comprehensive coverage for object formats, refused paths, work-directory placement, hook isolation, symlink-probe failures, unasked Git operations, and execution refusal behavior.

Brett Heap and others added 7 commits October 19, 2026 14:24
A SHA-256 source, or GIT_DEFAULT_HASH=sha256 in the environment, no longer
stops `check` with REFUSED git-failed at the rehearsal's read-tree.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h the source's object format and no hook; a path git refuses is a finding

L1-90, L1-92, L1-94 and L1-32 in the code; the tests are next.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the docstrings name both new codes

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of L1-90, L1-92, L1-94 and L1-32 and for adopt-git-unasked, with controls (28 passed)

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k-dir-unwritable's one finding (#524), with a NOTE of what went unasked

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hrough `hold`, and `check` asks it once, so `_reencoded_files_findings`' first `hold` is its checkout again

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rk directory, with the source's object format and no hook of this machine's, a path git refuses is the finding `plan-path-git-refuses`, and a repository git cannot make or ask there is `adopt-git-unasked`, never `git-failed` (#910)

`check` asks this machine's git what the split will make of the source in
throwaway repositories: the split rehearsal (#654), the attributes probe
(#619, #530), `_stored_as_written` (#530 D5(a)) and the symlink probe
(#183). The first three were made by a plain `git init` in TMPDIR. So a
SHA-256 source, or `GIT_DEFAULT_HASH=sha256` over a SHA-1 one, stopped
`check` with `git-failed` at the rehearsal's `read-tree`; a path git refuses
stopped it the same way; the answers were about TMPDIR's disk, not the work
directory where `execute` builds the split; and every hook this machine's
config names ran in them, 12 times in one `check`.

- L1-90: each throwaway repository is made with `git init
  --object-format=<the source's>`, which `Source.object_format` reads once
  with `git rev-parse --show-object-format`. Before anything else, the
  rehearsal asks which paths of the source commit git refuses as a path
  (`..`, `.Git/x`, `notes/.git/x`). It writes them with `update-index
  --index-info`, which leaves out each one it refuses, and reads back
  `ls-files`. Each such path is named in the new finding
  `plan-path-git-refuses`. The finding says that each clone `execute` makes
  stops at the path, after both leg repositories were created, whatever the
  plan answers. Its remedy is to rename or remove the path in the source and
  run `plan` again, because no answer in the plan changes it, `drop`
  included. `execute` refuses it before any leg
  (`_refuse_what_check_finds`). The rehearsal asks nothing more of such a
  tree, and `check` asks the path question once.
- L1-92: they are made in the work directory, `--work-dir` or its default,
  not in TMPDIR, and are removed. That directory is where `execute` builds
  the split, and `check --work-dir` already says to give it the one
  `execute` will be given, so the answer is about the disk and the
  directory the split meets. Saying where they were made, the other choice,
  would still answer about another disk.
- L1-94: every git command in them runs with `-c
  core.hooksPath=<an empty directory beside the repository>`, which outranks
  the config files and `GIT_CONFIG_COUNT`, and `git init` is given that
  directory as `--template`.
- L1-32: when the symlink probe's `git init` fails without an `OSError` (a
  `CommandFailed` or a `Refusal`), the probe takes git to check symlinks out
  as symlinks, so a finding about a symlink says what such a disk makes of
  it, and one NOTE gives git's words. It was "a git or gh command failed".
- `adopt-git-unasked`: where git cannot make or ask a throwaway repository
  in the work directory, `check` gives one finding that says why in git's
  words, and `execute` refuses it before any leg. An example is
  `init.defaultBranch = a..b`, which the split's own clone refuses too.
  Where no directory can be made there at all, `adopt-work-dir-unwritable`
  (#524) stays the one finding, and a NOTE says what went unasked.

Measured with `check` against a fresh source (stub `gh`), first on
c7a3ff1, then on this commit:

- SHA-256 source: exit 2, `git-failed` at `read-tree`; then exit 0,
  `plan ok`.
- `GIT_DEFAULT_HASH=sha256` over a SHA-1 source: exit 2, `git-failed`;
  then exit 0, `plan ok`.
- `..` in the source commit: exit 2, `git-failed: ... error: invalid path
  '..'`; then exit 1, `plan-path-git-refuses`. `.Git/x` behaves the
  same. With the rehearsal patched out, as before #654, `execute` created
  both legs and then stopped, `REFUSED` extracting the spec leg (clone exit
  128, invalid path).
- A counting hook named by `core.hooksPath` in `GIT_CONFIG_GLOBAL`: 12
  runs (2 in the attributes probe, 10 in the rehearsal); then 0. The same
  hook runs once for a plain `git add` in the control.
- Where: before, three in TMPDIR (attributes probe x2, rehearsal x1); now,
  all of them in the work directory, which is empty again afterwards.
- `GIT_DEFAULT_HASH=bogus` with `.github -> ../outside-ci`: exit 2, "a git
  or gh command failed ... unknown hash algorithm 'bogus'"; then exit 1,
  `plan-root-symlink` and the probe's NOTE.
- `init.defaultBranch = a..b`: exit 2, `REFUSED git-failed`; then exit 1,
  `adopt-git-unasked`. The split's own clone there exits 128.
- A work directory nobody can write in: before, `adopt-work-dir-unwritable`
  with the rehearsal asked in TMPDIR; now `adopt-work-dir-unwritable` and
  one NOTE.

tests/test_adopt_check_throwaway_repos.py has 28 rows, covering each item
and its controls. On c7a3ff1's adopt-project.py, 22 of them fail and 6
pass, and the 6 are controls.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates adopt-project.py check so its Git probes run in disposable repositories located in the split’s work directory, using the source’s object format and no machine hooks, while converting path and repository-operation failures into actionable findings; comprehensive tests validate the behavior and execute-time safeguards.

Sequence diagram for check Git probes in isolated throwaway repositories

sequenceDiagram
    participant Check
    participant Source
    participant WorkDir
    participant Git
    participant Probe
    Check->>Source: object_format()
    Source->>Git: rev-parse --show-object-format
    Check->>WorkDir: _throwaway(prefix, source)
    Check->>Git: git init --template=empty --object-format=<source format>
    Check->>Probe: run probes with -c core.hooksPath=empty
    Probe->>Git: check-attr / update-index / git add
    Git-->>Probe: probe result
    Probe-->>Check: findings
    Check->>WorkDir: _throwaway_removed(scratch)
Loading

State diagram for unasked throwaway repository operations

stateDiagram-v2
    [*] --> CreateScratch
    CreateScratch --> InitRepository: work directory writable
    CreateScratch --> Unasked: cannot create directory
    InitRepository --> RunProbe: git init succeeds
    InitRepository --> Unasked: git init or probe fails
    RunProbe --> Cleanup
    Cleanup --> [*]
    Unasked --> Finding: adopt-git-unasked
    Finding --> ExecuteRefusal: execute stops before either leg
    Unasked --> UnusableWorkDirectory: work directory entirely unusable
    UnusableWorkDirectory --> [*]
Loading

Flow diagram for check handling Git-refused paths

flowchart TD
    A[Source commit tree] --> B[_paths_git_refuses]
    B --> C[update-index --index-info]
    C --> D{Paths missing from index}
    D -->|No| E[Continue Git probes]
    D -->|Yes| F[FINDING plan-path-git-refuses]
    F --> G[execute refuses before either leg]
    F --> H[Rename or remove paths and rerun plan]
Loading

File-Level Changes

Change Details Files
Make all check throwaway Git repositories reproduce the split’s environment and filesystem conditions.
  • Centralize throwaway repository creation in the selected work directory.
  • Initialize repositories with the source object format and disable inherited hooks via an empty template and command-line core.hooksPath.
  • Propagate cleanup and repository-operation failures as structured unasked conditions instead of generic Git failures.
adopt-project.py
Add explicit diagnostics for source paths and Git operations that prevent the split rehearsal.
  • Detect source-tree paths Git will not hold in an index and report plan-path-git-refuses with remediation.
  • Report adopt-git-unasked when a throwaway repository cannot be created or queried, while preserving the existing unwritable-work-directory finding.
  • Prevent later rehearsal questions after a refused path or failed throwaway operation.
adopt-project.py
Make symlink probing degrade predictably when Git initialization fails.
  • Treat non-OS Git initialization failures as the disk-supporting-symlinks case.
  • Emit a NOTE containing Git’s failure reason instead of aborting check.
adopt-project.py
Add end-to-end coverage for throwaway repository fidelity and failure reporting.
  • Cover SHA-1/SHA-256 formats, refused paths, work-directory placement and cleanup, hook isolation, symlink-probe failures, unasked repositories, and execute-time refusals.
  • Verify control cases, exact findings/remediations, and that no leg is created before execute rejects invalid conditions.
tests/test_adopt_check_throwaway_repos.py

Assessment against linked issues

Issue Objective Addressed Explanation
#910 Make check's Git rehearsals use the source repository's object format, and report source-tree paths Git refuses as a finding rather than failing with git-failed. ✅
#910 Run check's throwaway repositories in the split's work directory and prevent hooks from the machine's Git configuration from affecting their results. ✅
#910 When Git cannot create or use a throwaway repository, report why instead of aborting; for a failed symlink-probe init, retain the probe's finding and explain the unasked question. ✅

Possibly linked issues


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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-20T05:56:05Z)

r1031 PARTIAL review of PR #1031 (closes #910) at head abd1b98. I stopped at the usage checkpoint, so there is no verdict yet.

State at stop:

  • The worktree /workspace/projects/openRepoShape-worktrees/r1031-check-throwaway-repos has HEAD abd1b984, and git status --short is empty.
  • No mutant is applied: every mutant run ended with git checkout -- adopt-project.py and a git diff --quiet check.
  • No process of mine is running. I stopped background task bq24pc075, and pgrep -af _lane1-scratch/r1031 then printed nothing.
  • My scratch is /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1031/ (abbreviated S/ below).

Findings so far (most severe first)

MUST-1 (PR body only): "Every throwaway repository" and "Every git command in them" are false for the symlink probe that the body lists

  • What the body says. The body's first paragraph counts the symlink probe (materialize_assembly_root writes the shape files through a root-kept symlink to anywhere on disk, and crashes with a traceback on a link loop, after both legs are pushed #183) as one of check's throwaway repositories. The L1-90 row then says "Every throwaway repository is made with git init --object-format=<the source's>". The L1-94 row says "Every git command in them runs with -c core.hooksPath=..., and git init is given --template=...".
  • What the code does. _checks_out_symlinks (adopt-project.py:9917-9934, unchanged) still runs plain run(["git", "init", "-q", str(probe)]) and then git config --type=bool --get core.symlinks. It passes no --object-format, no --template and no -c core.hooksPath.
  • The test file agrees. Its docstring at tests/test_adopt_check_throwaway_repos.py:409-411 says GIT_DEFAULT_HASH=bogus "makes the probe's git init fail, and only it: each throwaway repository names its format".
  • Evidence (probe S/probe/p1.py, linked layout).
    • The git-call log shows 6 symlinks-probe- calls, none with -c core.hooksPath or --object-format.
    • The effect is harmless: 0 hook runs in those dirs, both with core.hooksPath set in the global and system config and with init.templateDir hooks. git init and git config --get run no hook.
  • Fix (body text).
    • L1-90: "Each throwaway repository but the symlink probe's, which asks only core.symlinks and keeps its plain git init (L1-32 below), is made with git init --object-format=<the source's> ...".
    • L1-94: "Every git command in the rehearsal, the attributes probe and _stored_as_written runs with ...; the symlink probe's git init and git config --get run no hook."
  • Code docstrings (NIT level). The same overstatement is in:

SHOULD-1: a source with a registered submodule still stops check with REFUSED a git or gh command failed, exit 2, under the configs the PR turns into adopt-git-unasked or a NOTE

  • Evidence (probe S/probe/p7.py). The source is test_adopt_submodules.registered_source, with .gitmodules and upstream/ answered code. With --work-dir:
    • control: exit 0, plan ok;
    • init.defaultBranch = a..b in GIT_CONFIG_GLOBAL: exit 2, REFUSED a git or gh command failed. THE EXACT COMMAND WAS: ...;
    • GIT_DEFAULT_HASH=bogus: exit 2, the same REFUSED.
  • Likely cause (not yet confirmed). _resolved (adopt-project.py:6972-6974, openreposhape-url-) or _answers (7162-7166, openreposhape-ask-). Each runs a plain git init -q in TMPDIR. I did not capture the exact command before the stop.
  • Why it matters. The body's "new" row gives init.defaultBranch = a..b as its example of one adopt-git-unasked finding. L1-32 removed "a git or gh command failed" for the bogus hash in the symlink probe. Both stay as they were for any submodule source.
  • Scope. This is outside adopt-project.py check's throwaway repositories: a SHA-256 source, a tree path git refuses, a TMPDIR on another disk and this machine's hooks change the answers #910's named lines: _resolved and _answers are not in its State. The PR already lists these two functions as follow-ups, but only for TMPDIR and hooks.
  • Fix. Add to the body's follow-up bullet: "and a git init they cannot make (init.defaultBranch = a..b, GIT_DEFAULT_HASH=bogus) still stops check with a git or gh command failed, exit 2, for a source with a submodule."

SHOULD-2: no test pins asked()'s change from git-failed to adopt-git-unasked (mutant M6a survives)

  • The mutant. M6a replaces raise _Unasked(self._scratch.parent, ... in _SplitRehearsal.asked (adopt-project.py:11097) with raise Refusal("git-failed", .... All 28 rows still pass.
  • Why no row catches it. No row makes a rehearsal step after git init fail.
  • Fix: a row.
    • Monkeypatch _SplitRehearsal.git so that the read-tree call returns subprocess.CompletedProcess(args, 128, b"", b"fatal: r1031").
    • Call _entry_findings, or run check in-process.
    • Assert exactly one FINDING adopt-git-unasked: ... that names read-tree, and no Refusal.

NIT-1: the plan-path-git-refuses text describes a run execute no longer makes

  • adopt-project.py:10250-10258 says "Each clone execute makes of the source stops at it, unable to checkout working tree, after both leg repositories were created". But execute now refuses it before either leg exists. My measurement: exit 2, REFUSED plan-path-git-refuses, remotes/ absent and the work directory empty, for odd/GIT~1/x, the mixed source and odd/x/.
  • This is the house style (adopt-project.py check: refuse a kept file at the shape's pin path, and an unwritable --work-dir, before any leg exists #524's adopt-work-dir-unwritable says the same), and the body explains the choice.
  • Suggested wording: "Each clone the split makes of the source would stop at it, unable to checkout working tree, so execute refuses it before it makes either leg, whatever the plan answers for it."

NIT-2: a malformed tree is named "git refuses as a path", but the clone exits 0

  • Case (probe S/probe/p6.py). A tree holds blob odd/x beside tree odd/x/ (git fsck: duplicateEntries).
  • Head: FINDING plan-path-git-refuses ... odd/x`` and "Each clone ... stops at it".
  • But git clone exits 0, and git status in the clone shows D odd/x: the checkout dropped the file.
  • c7a3ff1 said plan ok, exit 0. So head is better than base here, but the finding's words are wrong for this case.
  • update-index --index-info replaced odd/x with odd/x/y; it did not refuse the path. This is rare: GitHub's fsck rejects such a push.

NIT-3: the L1-92 row says "(--work-dir or its default), not TMPDIR"

  • The default work directory is itself made in TMPDIR: _work_root, adopt-project.py:15675-15678, tempfile.mkdtemp(prefix="openreposhape-adopt-").
  • So without --work-dir, the repositories sit under $TMPDIR/openreposhape-adopt-*/. The p1 log shows .../tmpdir/openreposhape-adopt-hmaj8wiy/openreposhape-reencoded-....
  • Suggested wording: "in the work directory (--work-dir, or the default one check makes in TMPDIR), never in TMPDIR itself".

NIT-4: beside a refused path, plan-file-reencoded goes unasked

  • Evidence (probe S/probe/p5.py). The source has * text eol=lf, two kept CRLF files, and ..:
    • with ..: exit 1, only plan-path-git-refuses;
    • without ..: exit 1, plan-file-reencoded names more.txt and notes.txt.
  • This is by design: if refused: return [] at adopt-project.py:11404, the docstring, and the row test_the_rehearsal_asks_nothing_more_of_a_tree_git_refuses. The body does not say it.
  • Fix: one sentence in the body. Optionally, the finding could say that check asks about the files once the path is gone.

NIT-5 (test strength, equivalent mutants)

  • M3 (git init without --template=<empty>) survives. The -c core.hooksPath already disables hooks, so no row shows what the empty template adds, for example a template that carries info/attributes or config.
  • M17 (the attributes probe's check-attr without hooks_off) survives. It is equivalent: check-attr runs no hook, and _probe_env empties the global config.
  • M16 (_entry_findings swallowing the path question's _Unasked) survives. The later rehearsal fails the same way, so the output is unchanged. The resume steps check whether some plan skips the later rehearsal.

Verified (with evidence)

1. Nothing is left behind, and no hook runs

Probe S/probe/p1.py, results under S/p1/. TMPDIR was an empty directory per run, and a git wrapper first on PATH logged every call.

  • Configs tried:
    • core.hooksPath in both GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM, plus a core.fsmonitor hook;
    • init.templateDir in global and system, each with marker hooks;
    • GIT_TEMPLATE_DIR;
    • GIT_CONFIG_PARAMETERS;
    • GIT_CONFIG_COUNT.
  • Layouts: basic (root .gitattributes), nested (.github/.gitattributes above a pinned copy) and linked (.github -> ../outside-ci), with SHA-1 and SHA-256 sources.
  • check --work-dir: the work directory and TMPDIR were empty afterwards in every run. Hook runs: 0.
  • check with the default work directory: TMPDIR was empty afterwards (the default openreposhape-adopt-* was removed). Hook runs: 0.
  • execute: 94-110 hook runs, all in its own repositories (work-exec/Northwind, .git/modules/*, the source's receive hooks). There were 0 in any openreposhape-attributes-*, openreposhape-reencoded-* or symlinks-probe-* directory. TMPDIR was empty afterwards.
  • The -c on every call: all 117 git calls made in throwaway directories carry -c core.hooksPath=<scratch>/empty. Each git init there is init -q --template=<scratch>/empty --object-format=<fmt>.
  • Object format: with the SHA-256 source, all 6 inits were --object-format=sha256 and the result was exit 0, plan ok.

2. plan-path-git-refuses (probe S/probe/p2.py, one source per path)

Each case was compared with git clone of the same source under the same config.

  • Named exactly once, and the clone fails (exit 128):
    • .., ., .git (file), .GIT/x, odd/.gIt/x, odd/.git, odd/.., odd/.;
    • odd/GIT~1/x, git~1/x, odd/.git./x, odd/.git /x, odd/.git::$INDEX_ALLOCATION/x;
    • the symlink .gitmodules, the symlink odd/GITMOD~1, and the literal entry odd/x/ (a name with a trailing slash).
  • Not flagged, and the clone succeeds:
    • odd/GI7EBA~1/x, and by default odd/.git\u200c/x and odd/.G\u200dIT/x;
    • ..x, .gitx/x, git~2/x, ..., .github-x, the symlink .gitmodulesx;
    • names holding a line break, a tab, ", Latin-1 \xe9, \xff\xfe, \x01, \x7f or a backslash;
    • a literal entry named a/b.
  • core.protectHFS = true: both HFS names are then named, and the clone fails too.
  • core.protectNTFS = false: the NTFS names are not flagged, and the clone succeeds.
  • Mixed source: 5 refused paths (inside directories named with \n, \t, ", \ and the byte \xff) are named in the tree's order. The 4 odd but good names beside them are unflagged.
  • 13 refused paths: 10 are named and the rest are counted.
  • Agreement: the finding matched the clone in every case but NIT-2. An entry with an empty name makes plan itself refuse (ls-tree), which is outside this PR.
  • execute: on odd/GIT~1/x, the mixed source and odd/x/, it exits 2 with REFUSED plan-path-git-refuses. "no leg repository was created", remotes/ is absent and the work directory is empty.

3. adopt-git-unasked (S/p3/*.out)

Case Result
--work-dir mode 500 exit 1; one FINDING adopt-work-dir-unwritable and the one unmade NOTE; no git-failed. execute: exit 2, REFUSED adopt-work-dir-unwritable
--work-dir is a file exit 2, REFUSED adopt-work-dir-unwritable ... (File exists) (#650, before any throwaway)
--work-dir below a file exit 2, REFUSED ... (Not a directory)
init.defaultBranch = a..b exit 1, one FINDING adopt-git-unasked: ... git init there exited 128: fatal: invalid branch name: init.defaultBranch = a..b. ... on one line; the work directory is empty afterwards
the same, default work directory the same finding, naming $TMPDIR/openreposhape-adopt-*; TMPDIR is empty afterwards
the same, execute exit 2, REFUSED adopt-git-unasked; remotes/ is absent

The exception is SHOULD-1 (submodule sources).

4. The index refused_paths writes (S/p4)

  • The source's .git/index sha256 (203bfd2c...) and its mtime are identical before and after check.
  • The only GIT_INDEX_FILE any git call saw was <work>/openreposhape-reencoded-*/repository/.git/index-paths, for -c core.hooksPath=... -c core.fsmonitor=false update-index -z --index-info.
  • git 2.43 update-index -z --index-info: it prints Ignoring path .. and exits 0. It keeps a\nb, t\tab, q"uote, \xff\xfe, \x01ctl and back\slash (raw probe in S/gitt/t1).
  • core.ignorecase = true (global, Linux, a proxy for the case-insensitive follow-up): case twins odd/Read/odd/read and odd/D/x/odd/d/x are not flagged. A separate plan-file-reencoded from the rehearsal's checkout is pre-existing behaviour.
  • _reencoded_files_findings with a non-empty refused: it asks nothing (NIT-4).

5. Base check

  • On c7a3ff16's adopt-project.py the new file gives 22 failed and 6 passed. The 6 that pass are exactly the controls:
    • [control-a-sha1-source];
    • test_a_path_git_takes_beside_them_is_no_finding;
    • test_the_counting_hook_runs_where_this_machines_config_reaches x2;
    • test_a_probe_git_makes_prints_the_same_finding_and_no_note;
    • test_the_splits_own_clone_refuses_that_setting_too.
  • Afterwards adopt-project.py was restored with git checkout --, and the status is clean.

7. The PR body (so far)

8. Style

  • git diff --check c7a3ff16 abd1b984 is clean.
  • The 996 added lines hold no line over 79 columns, no non-ASCII and no tab. adopt-project.py has 42 lines over 79 columns at the base and at head.
  • The new file has 0 lines over 79 columns and no compound assert a and b.

Test counts

Each run used /usr/bin/python3 -m pytest -p no:cacheprovider, basetemp S/bt with retention failed, a stub gh first on PATH, GH_TOKEN=bogus-r1031, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false, LANES_LANE unset, and EMAIL set to the brief's r1031 address.

At head abd1b984:

  • new file 28, test_adopt_reencoded_files.py 53, test_agents_md_codes.py 47, test_repo_hygiene.py 100, test_readme_windows_quoting_sentence.py 44, test_scaffold_leg_path_collisions.py 104: 376 passed;
  • test_shape_doctor.py in 4 shards: 118, 110, 117, and 100 + 3 skipped, so 445 passed, 3 skipped;
  • total 821 passed, 3 skipped, 0 failed.

On the main trial merge:

  • the same six files: 376 passed;
  • set 2 (test_adopt_execute_refusals.py, test_adopt_plan_value_order.py, test_adopt_plan_yaml_quoting.py, test_adopt_template_values.py, test_adopt_nested_attributes.py, test_adopt_root_symlinks.py) was stopped at about 85% for the checkpoint: 888 passed, 0 failed so far.

Mutants (22 run; each against the new file, then restored)

Id Mutant Result Rows that killed it
M1 the throwaway back in TMPDIR (dir=None) killed, 3 the work-dir row, the unasked row, the unmade row
M2 hooks_off = [] killed, 2 both no-hook rows
M3 no --template=<empty> SURVIVED equivalent while hooksPath is set
M4 no --object-format killed, 6
M5 the refused finding dropped killed, 5
M6a the rehearsal's asked() raises git-failed SURVIVED SHOULD-2
M6b a failed throwaway git init raises git-failed killed, 2
M6c _Unasked's code is git-failed killed, 2
M7 update-index --index-info without -z killed, 14
M8 ls-files without -z killed, 14
M9 _reencoded_files_findings ignores refused killed, 5
M10 the probe no longer catches CommandFailed/Refusal killed, 3
M11 the rehearsal's throwaway not removed killed, 3
M12 the rehearsal's git() without hooks_off killed, 2
M13 _stored_as_written without hooks_off killed, 2
M14 object_format always sha1 killed, 4
M15 the unmade case is a finding, not a NOTE killed, 1
M16 _entry_findings swallows the path question's _Unasked SURVIVED pending, resume step 2
M17 the attributes probe's check-attr without hooks_off SURVIVED equivalent
M18 _throwaway lets the OSError through killed, 1
M19 the probe's NOTE not printed killed, 1
M20 refused_paths answers nothing killed, 6

The script is S/probe/mut.py, with logs in S/mut/*.log and S/mut/batch2.txt.

Merges

git merge-tree --write-tree abd1b984 <X>:

X Exit Tree
main 5b41d44 0 4526352524d67ced3c4c577aebc82d6d3c6d17f3
900 18cc659 0 0ac6fd0d306508816cce686e0ba934f9a25322b8
901 2ede510 0 3837a195a8976de353c6d02b4bee60b0122a2217

The main merge is built in a git clone --shared of /workspace/projects/openRepoShape at S/merged-main: commit 5b98cedfe3db (parents abd1b984 and 5b41d44f), tree 45263525. Its tests are above.

Resume steps (after the 10:00Z reset)

  1. Run git status --short in the worktree (expect it empty, HEAD abd1b984) and df -i /workspace.
  2. SHOULD-1: re-run p7 into a new base dir and print the whole REFUSED block, to name the command:
    S/probe/env.sh /usr/bin/python3 S/probe/p7.py <worktree> S/p7b, with the print widened from l[:330] to the full stderr.
    Expect _resolved's git init -q <TMPDIR>/openreposhape-url-*.
  3. M16: read the early returns of _reencoded_files_findings (adopt-project.py:11383-11406). Find a plan that never reaches with _SplitRehearsal (one that keeps no file to ask about) and run it under init.defaultBranch = a..b. If head gives adopt-git-unasked and M16 gives plan ok, that is a SHOULD with a row.
  4. Re-run main-merge set 2 in S/merged-main, split into two calls under 9 minutes:
    • (a) test_adopt_execute_refusals.py, test_adopt_plan_value_order.py, test_adopt_plan_yaml_quoting.py;
    • (b) test_adopt_template_values.py, test_adopt_nested_attributes.py, test_adopt_root_symlinks.py.
  5. Optional: build the 900 merge (tree 0ac6fd0d) the same way and run the new file there.
  6. Re-fetch main. If it has moved past 5b41d44f, run merge-tree again.
  7. Decide the verdict and write c1031-r1031.md with the full report.

Lean so far: the only MUST is a body sentence (MUST-1), and the code behaves as #910 asks on every case I ran.

brettheap added a commit that referenced this pull request Oct 20, 2026
… holding U+202E or another character the launcher refuses, by code point and in the launcher's words, before it prints the value or runs `git clone` (#953) (#1043)

`setup-project.py`'s self-bootstrap refuses an `$OPENREPOSHAPE_REPO` holding a character the launcher refuses (U+202E and the rest of `MISDRAWN_CHARACTERS`). It refuses by code point, in the launcher's words, before it prints the value or hands it to git.

Head `c42afa30`, one commit on origin/main `5b41d44f`. Only `setup-project.py` and a new `tests/test_setup_project_repo_misdrawn.py` change. No flag, no `--help` line and no doc changes.

**`setup-project.py`** (section 0, just above `self_bootstrap`):
- `MISDRAWN_CHARACTERS` is the launcher's list (`openRepoShape:502` at `5b41d44f`), entry for entry, in the package's tuple form. It is a copy, for `checked_value`'s reason: self-bootstrap mode runs before there is a checkout to read the launcher from.
- `misdrawn_characters(value)` names the characters a value holds as `U+XXXX`, in code point order. It is the package's `standard.misdrawn_characters`, and it looks for characters, as `CONTROL_RE` does.
- `checked_repo(value)` asks that first. It refuses with the launcher's `checked_repo` sentence, which names the code points and nothing else of the value. Then it calls `checked_value(..., PATH_RE)`, unchanged, for a leading `-` and a control character.
- `self_bootstrap` reads a non-empty `$OPENREPOSHAPE_REPO` through `checked_repo`. Unset or empty still means the default. The check stays where `--org` is asked, so the order is the same: `--org` first, then the repository, then `openRepoShape setup` is printed, then `mkdtemp` and `git clone`.
- The value's raw bytes are never printed. The refusal goes through `die`, so it is `REFUSED: ...` on stderr with exit 2.

**`tests/test_setup_project_repo_misdrawn.py`**, 22 tests:
- **Parity:** `test_the_misdrawn_table_is_the_launchers_list` reads `MISDRAWN_CHARACTERS=(...)` out of `openRepoShape` and compares the tuple. It also checks that `misdrawn_characters` names all of the launcher tests' `MISDRAWN`, each once and in order. `test_misdrawn_characters_names_the_launchers_and_nothing_else` runs the launcher's `SHARING_ITS_BYTES`. These follow #947's two tests for the package.
- **Direct path, 12 tests:** two runs (`Atlas --org acme`, which is the issue's run, and `--org acme` alone) times six values: U+202E, U+00AD, U+200B, U+E0001 (two, three and four UTF-8 bytes), two characters at once, and a tab before U+202E (the launcher asks for the code point before the control character).
  - Each test runs a lone copy of the file under `call_loggers`' logging git, gh and curl.
  - Each checks the launcher tests' `assert_refused_saying`: exit 2, the launcher's sentence, an empty stdout, and no listed character, escape or `forged` in either stream. So no `E2 80 AE` gets out.
  - The git log must hold exactly one call: the checkout probe.
- **Values that still go on, 7 tests:** the launcher's `NOT_CONTROLS` after `acme/`: e-acute, a no-break space, U+200C and U+200D where a script or an emoji needs them, U+2026, U+FF21 and a subdivision flag. Each is printed in the clone line and handed to `git clone` as before.
- **Package path, 1 test:** a tree with no `.git` (the package's modules under `src/openreposhape/`, plus `setup-project.py`, `scaffold-project.py`, `shape-doctor.py` and `contracts/repository-naming.yaml`). `openRepoShape Atlas --org acme` is run from that tree's `src` with the issue's value. The package prints its one line (`the standard is the checkout this package sits in, <tree>.`) and hands over. The tree's `setup-project.py` refuses: exit 2, the launcher's sentence, no U+202E and no `forged` in either stream, and only the checkout probe in the git log.
- Tests that use the logging stub are skipped on Windows. There `subprocess` starts `git.exe` and never the stub's `.cmd`, so the stub would log nothing. CI is Linux only (#597).
- `setup.sh` has no twin of these tests. Its guard covers control characters and the two separators and no other listed character, by #940's design: the launcher refuses the rest first where it is the way in.

**"No git call", stated exactly.** The refused run makes one git call, `git -C <dir> rev-parse --show-toplevel`. That is `is_shape_checkout`, which runs before `self_bootstrap` on every run past `--help` and `--preflight`. It asks whether the file stands in a checkout, which is how the run knows it is self-bootstrapping at all, and it names nothing of the value. No git call names the value, and there is no clone. Moving the check above the probe would also refuse the value on the developer path, which never reads it. It would also leave `setup-project.py` ordered differently from `setup.sh`, which probes first and guards only on its self-bootstrap path.

These run `$Q/v118/w953/probe.py`. Each case uses a fresh scratch folder, a logging git stub that then runs the real git, a gh that fails, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`. "Before" is `git archive 5b41d44`; "after" is the worktree. Results are the same under git 2.43.0 and 2.47.1.

| Case | Before (`5b41d44f`) | After |
|---|---|---|
| package, ZIP tree, `Atlas --org acme`, `acme/r[U+202E]forged` | exit 128; `E2 80 AE` on stdout and stderr; git: probe, then `clone --quiet -- acme/r[U+202E]forged /tmp/openreposhape-shape-...` | exit 2; no `E2 80 AE`; git: probe only; stdout is the package's one line |
| direct, `Atlas --org acme`, same value | exit 128; raw bytes in the clone line; probe + clone | exit 2; refusal names `U+202E`; probe only |
| direct, `--org acme`, same value | exit 128; probe + clone | exit 2; probe only |
| direct, `acme/r[U+200B]forged` | exit 128; probe + clone | exit 2; names `U+200B` |
| direct, `acme/r[U+E0001]forged` | exit 128; probe + clone | exit 2; names `U+E0001` |
| direct, `acme/r[U+2026]b` (not listed) | exit 128; probe + clone | the same: probe + clone, exit 128 |

The "after" refusal, from the probe:

```
REFUSED: OPENREPOSHAPE_REPO holds a character a terminal draws as something other than itself (U+202E): one that is invisible, is drawn as another character, ends a line or reorders the text around it. This tool will not put it on a `git clone` command line. Set it to a URL or a path.
```

The new file was run on main's `setup-project.py` (`git show 5b41d44:setup-project.py`, put back byte for byte afterwards): **15 failed, 7 passed**.
- Failed: both parity tests (`AttributeError`: no `MISDRAWN_CHARACTERS`, no `misdrawn_characters`), all 12 direct tests, and the package test.
- Passed: the 7 `NOT_CONTROLS` tests. They pass at main by design, because they guard against a check that is too broad.

Each mutant was applied alone (`$Q/v118/w953/mutate.py`), the new file was run (22 tests), and both files were put back. **All 18 are killed.**

| Mutant | Failed |
|---|---|
| m01 check removed (main's read) | 13 |
| m02a check moved after the `(0) self-bootstrap` line | 13 |
| m02b check moved after the clone line | 13 |
| m03a only the value's first character checked | 15 |
| m03b only the table's first entry checked | 13 |
| m04 only U+202E checked | 8 (U+00AD, U+200B, U+E0001, two-at-once) |
| m05 refusal exits 1 | 13 |
| m06a table drops `E0001` | 4 |
| m06b parity compares the first five entries + table drops `E0001` | 4 (parity's second assert, `SHARING_ITS_BYTES`, both U+E0001 tests) |
| m06c parity compares the first five + table adds `200C` | 2 (`SHARING_ITS_BYTES`, the Persian/U+200C test) |
| m06d parity compares the first five + `2060-206F` narrowed to `206E` | 2 |
| m06e parity uses set inclusion + table drops `E0001` | 4 |
| m07 a range read without its last code point | 15 |
| m08 control check asked before the code points | 2 (both tab-then-U+202E tests) |
| m09 characters named in the value's order | 3 |
| m10 every character past ASCII refused | 7 (every `NOT_CONTROLS` test) |
| m11 refusal quotes the value back | 13 |
| m12 check skipped on a hand-over (`OPENREPOSHAPE_SELF_BOOTSTRAP=1`) | 1 (the package test alone) |

All at `c42afa30`, one pytest process at a time: `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp, `EMAIL=service@opensoft.dev`, a stub gh first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`.

| Run | git | Files | Result |
|---|---|---|---|
| h1 | 2.43.0 | `test_setup_project_repo_misdrawn.py` (22), `test_setup_project_ref_parts.py` (27), `test_openreposhape_package.py` (213), `test_readme_windows_quoting_sentence.py` (44), `test_release_install_check_children.py` (30), `test_repo_hygiene.py` (100), `test_agents_md_codes.py` (47), `test_cli_reference.py` (23) | 506 passed in 94.06s |
| h2 | 2.43.0 | `test_setup_project_py.py` | 340 passed in 122.68s |
| h3 | 2.43.0 | `test_setup_sh.py` | 191 passed in 96.62s |
| h4 | 2.47.1 | `test_setup_project_repo_misdrawn.py`, `test_setup_project_ref_parts.py`, `test_setup_project_py.py` | 389 passed in 128.13s |
| h5 | 2.43.0 | `test_no_auto_maintenance.py`, `test_setup_project_dry_run_offers.py`, `test_validator_child_encoding.py`, `test_setup_project_name_edges.py`, `test_setup_project_typed_name.py` | 353 passed, 2 skipped in 73.92s (the two skips: `GIT_CONFIG_GLOBAL` was set) |

No `shell_quoted` call was added (count 0), so the README's and the handbook's Windows-quoting sentences and `NAMED` are unchanged. `setup-project.py` is not a pinned copy. The wheel takes it at build time (`pyproject.toml` `force-include`), and the tests that read its source (`test_setup_project_py.py`, `test_release_install_check_children.py`, `test_repo_hygiene.py`) pass.

- `git merge-tree --write-tree` of `c42afa30` with origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`) is clean, tree `59de4c261c228274a5cff18aff953211de7002e3`.
- No open PR touches `setup-project.py` or the new test file. Every open PR's head was still merged in, and all ten are clean: #1041 `4a23c6e2`, #1039 `5f924500`, #1038 `fd1de5ab`, #1031 `eea70be1`, #1030 `9b4845bb`, #1028 `0c9eac6c`, #1027 `052f1e24`, #1025 `f83ca618`, #1023 `4d0aa1cf`, #954 `dc7fd7ea`.

- **RV947's companion is left out.** That companion is the package's scaffold run asking the repository even beside a checkout.
  - Once this lands, no package run prints the value or hands it to git. Beside a git checkout, the hand-over takes `setup-project.py`'s developer path, which never reads `$OPENREPOSHAPE_REPO`. `self_bootstrap` and `standard.py` are the only shipped Python that read it. Beside a tree with no `.git`, this check refuses it.
  - The companion would change `standard.prepare`, which `--doctor` and the four tools share. They would then refuse a value they never use. That is the launcher's "checked once, before every run but `--help`" policy, and it is a decision about the package of its own.
  - `src/openreposhape/standard.py` is also open in #1038.
- **Found while measuring, not changed here:** `CONTROL_RE` and `PATH_RE` hold `\x00-\x1f\x7f` only, so a C1 control passes `checked_value`. The launcher and the package refuse C1 (U+0080 to U+009F) in a repository. With `acme/r[U+009B]31mforged`, the clone line prints the raw `C2 9B`, before and after this change. `--into` and `--local-remote-dir` go through the same `PATH_RE` (read in the code, not measured). That would be a follow-up for `checked_value`, which every flag shares.

Review: rv953, lane 5's independent reviewer, not the writer (w953): READY at `c42afa30`, the head that lands (issuecomment-6284626889), with no MUST. It rules #953's Verify met in substance: the refused run's only git call is the checkout probe every run makes, which names nothing of the value, and there is no clone. Its SHOULD is #459's documented C1 known limit (U031, L4-24), left with its test NIT for a follow-up on a ruling. CI (Brett Heap's ruling 27): H0 is the head c42afa3 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 5b41d44; and the local gate passed on its predicted squash tree e146bb5 over main e656199: the setup_project_repo_misdrawn, repo_hygiene, agents_md_codes, cli_reference sets, 192 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #953.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t-git-unasked`, and the `plan-path-git-refuses` text and two docstrings say what `check` and `execute` do, naming the symlink probe's plain `git init`

r1031's partial review of #1031 at abd1b98:

- SHOULD-2: no row made a rehearsal step fail after its `git init`, so
  mutant M6a, which puts `raise Refusal("git-failed", ...)` in
  `_SplitRehearsal.asked` in place of `raise _Unasked(...)`, passed all 28
  rows. A new row,
  `test_a_step_git_fails_in_the_rehearsal_is_one_finding_in_its_words`,
  makes the rehearsal's `read-tree` exit 128 (`fatal: r1031`). It asserts
  that `_entry_findings` raises no `Refusal`, and that it returns exactly
  one finding, `adopt-git-unasked`, naming `git read-tree <commit>` and
  git's words. The work directory is empty afterwards. Under M6a this row
  fails with `REFUSED git-failed` and the other 28 pass.
- NIT-1: `plan-path-git-refuses` said that each clone `execute` makes
  stops at the path after both leg repositories were created. `execute`
  now refuses it before either exists. The finding says each clone the
  split makes would stop at it, so `execute` refuses it before it makes
  either leg.
- MUST-1's code side: the `check` paragraph of the module docstring, the
  `_entry_findings` docstring and the test module's docstring said every
  throwaway repository is made with the source's object format and no
  hook. #183's symlink probe keeps its plain `git init` and `git config
  --get core.symlinks`, neither of which runs a hook. Each text now names
  the probe as the exception, as does the section comment above
  `PATH_GIT_REFUSES`. The probe itself is unchanged.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 20, 2026
…hain link before an `=` that leaves a path after it, so the offer the gate prints for a link holding `=` verifies that link when typed as printed (#980) (#1044)

For a declared-unverified chain link whose name holds `=`, the naming gate offers only sources that verify that link's tree when typed as printed. The gate offered `open=y` the source `--link-source open=y=<path>`, and read every `--link-source` key at the value's first `=`: the key was `open`, which no chain names, so the value was a bare PATH for the first link, `open=y` stayed declared-unverified under the same offer, exit 0.

**The issue's direction (a)**, in `scripts/validate-repository-naming.py`. `LinkSources._read_source` reads a `--link-source` key the way `_read_remedy` reads a `--link-remedy` link since #949: the longest text before an `=` that names a link of a chain this run reads, by `pin_product_key`, and leaves a path after it. `__init__` and `no_path` both take the key and the path from it, so the path refused as empty is the one after the key's own `=`. For a chain whose links hold no `=`, every value is read exactly as before: a text holding `=` can only name a link that holds one.

**What (a) keeps from the documented rules, and how each case was checked** (probes below, and one test row or more each):
- A link holding `=`: `open=y=<tree>` verifies `open=y`, as printed and in capitals (`OPEN=Y=<tree>`, by `pin_product_key` as every key is).
- Two links where one is a prefix of the other (`open` and `open=y` in one chain): `open=y=<tree>` is `open=y`'s and `open=<tree>` is `open`'s, in either order. On main both were `open`'s and the later replaced the earlier, so `open=y` read none.
- A path that itself holds `=`: `open=y=<dir>/a=b/tree` is `open=y`'s with that whole path. A text holding `/` names no link, since a link's name is `repo_basename`'d, so an `=` after a `/` never ends the key. A path that begins like a longer link's name is given after `./`: `open=./y=dir` is `open`'s in a chain naming `open=y` too.
- A key that names a link but leaves an empty path: `open=y=` is `PRODUCT=` for `open=y`, refused as `link-source-no-path`, exit 2, as `openXdox=` is (#442). On main it was a bare PATH asked for `openXdox`, a link the person did not name. Where a shorter link leaves a path (`open=y=` with `open` in the chain too), that link is the key, `open` with the path `y=`, as `--link-remedy openXdox=y=` gives `openXdox` the words `y=` where a chain names `openXdox=y`: the longest link that leaves a path wins, and `open=y` leaves none.
- A value that is a real directory spelled like the whole value: `open=y=x` beside a directory `open=y=x` is still `open=y`'s, decided by the chain and not the disk, with `_also_a_directory`'s line; `./open=y=x` asks for the directory, as a bare PATH for the first link.
- The key is read whole, where a remedy's link is stripped and cut at its last `/`: ` open=y=<tree>` and `opensoft/open=y=<tree>` name no link and stay bare PATHs, as on main.

**One difference from `--link-remedy`, deliberately.** `--link-remedy open=y=` is a NOTE (its link falls back to the first `=`, `open`). `--link-source open=y=` is refused, because the #442 rule refuses `PRODUCT=` for every link, and `--link-remedy`'s own `openXdox=` refusal is made by argparse before the chains are known, which is why it cannot catch `open=y=`. `_read_source`'s docstring says so.

**Not (b).** (a) makes the offer the gate already prints true, so the person who types it gets the verification; (b) would only stop offering it. Setup's step (4) is unchanged: it passes the gate its `--pin-source` as an absolute path, with a separator before every `=`, so no text before one names a link.

**Docs.** The module docstring (a new paragraph, "A KEY IS THE LONGEST LINK A VALUE NAMES"), the `LinkSources`, `_link_remedy` and `_read_remedy` docstrings, the `--link-source` help, and `docs/cli.md` regenerated from it. `setup-project.py`'s `as_the_plan_reads_it` docstring stated the first-`=` rule, and now states this one. README, the handbook and AGENTS.md state no rule for how a `--link-source` key is read (README names `--link-source PRODUCT=<path>` only), so they are unchanged. The class docstring records that `validate-pins.py` still ends a `--pin-source` key at the first `=`; no scaffold writes a pin holding one (`--pin` names are `[A-Za-z0-9_.-]`), and it is left as its own change.

Measured from a root scaffolded at main `5b41d44f` (local remotes, no network):
- `update-shape.py check --upstream <this checkout> --at c64e85f` lists `upstream-changed scripts/validate-repository-naming.py`, with `upstream-changed 1  unchanged 10`.
- `update-shape.py apply --yes --branch ...` exits 0, commits `contracts/shape-pin.yaml`, `project.yaml` and the copy, and the project's validators print `pins ok` and `manifest ok`. A fresh `check` prints `unchanged 11`, and the root's own naming gate exits 0.
- The new sha256 of `scripts/validate-repository-naming.py`: `ec167fb0e259f90705db895a1a37f4adebd4cbf03ca2d5ae18f6d9a8df896548` (78664 bytes). No other pinned copy and no template changes.

Run from empty folders, `<D>` a fresh folder, trees as named (`trees/y` declares `openInk`, `trees/x` declares `open=y`; for the chain with `open`, `trees/x2` declares `open` and `trees/o` declares `open=y`). The gate prints an arrow between links; it is written `->` here.

| Probe | At main `5b41d44f` | At head `c64e85fe` |
|---|---|---|
| P1, the issue's row: `--referent-chain openXdox,open=y,openInk --link-source open=y=<D>/trees/y` | `link-source-unreadable ... could not answer for openXdox`, "It was read as a PATH: `open` is not a link", and `open=y` declared-unverified with the same offer; exit 0 | only `openXdox` declared-unverified (no source named it); no `link-source-unreadable`; exit 0 |
| P2, P1 plus `openXdox=<D>/trees/x`, `--explain` | NOTE: the value not used, read as a PATH; `CHAIN openXdox -> open=y -> openInk [declared-unverified]` | `[verified]`, nothing warned or noted |
| P3, chain `openXdox,open,open=y,openInk`, `open=<o>` then `open=y=<y>` | `link-source-unreadable: y=<D>/trees/y (--link-source open=...)`, NOTE `open=<o>` replaced; 2 links declared-unverified | `[verified]`, nothing warned or noted |
| P4, `open=y=` (chain without `open`) | bare PATH `open=y=` asked for `openXdox`; exit 0 | `REFUSED link-source-no-path: --link-source open=y= names no path for open=y`; exit 2 |
| P5, `open=y=` (chain with `open`) | `y= (--link-source open=...) could not answer for open` | the same |
| P6, `open=y=<D>/trees/a=b/y` | read as a PATH, `[declared-unverified]` | `[verified]` |
| P7, `open=y=x` beside a directory `open=y=x` | bare PATH, read as the directory for `openXdox` | `link-source-unreadable: x (--link-source open=y=...) could not answer for open=y`, then "`open=y=x` is also a directory; `--link-source ./open=y=x` reads it as one." |
| P7b, `./open=y=x` | the directory, for `openXdox` | the same |
| P8, chain with `open`: `open=./y=dir`, `open=y=<y>` | `open=y=<y>` read for `open`, replacing `open=./y=dir` | `[verified]` |

Eleven new rows in `tests/test_chain_remedy_callers.py`, section "#980", reusing its helpers (`naming`, `tree`, `gate_warnings`, `UNREACHED`, `GATE_OFFER`, `EQUALS`), with no assert that joins conditions with `and`:
- `test_the_offer_for_a_link_holding_an_equals_verifies_it[as-printed, in-capitals, a-path-holding-an-equals]`: reads the offer the gate prints for `open=y`, types it with a tree, and asserts the one WARNING left is `openXdox`'s, with no `link-source-unreadable` and no NOTE.
- `test_each_link_reads_its_own_source_where_one_name_begins_another[open-first, open=y-first, a-path-that-begins-like-the-longer-link]`
- `test_a_value_that_ends_at_a_link_holding_an_equals_names_no_path`
- `test_a_shorter_link_that_leaves_a_path_is_the_key`
- `test_a_link_holding_an_equals_wins_over_a_directory_spelled_like_it`
- `test_a_key_is_read_whole[a-blank, an-owner]`

**Failing at main.** The test file copied onto a `git archive` of `5b41d44f`: 8 of the 11 fail, each on the defect (the offer row's `open=y` WARNING and `link-source-unreadable`, the prefix rows' `[declared-unverified]`, `open=y=` exiting 0, the keyed reading not winning over the directory). The other 3 pass at main by design: they pin behaviour main already had (`test_a_shorter_link_that_leaves_a_path_is_the_key`, `test_a_key_is_read_whole[a-blank]` and `[an-owner]`), and each kills a mutant below.

**Mutants**, each applied alone to the fixed gate in a scratch repository, then `test_chain_remedy_callers.py`, `test_link_source_chain_defaults.py`, `test_naming_policy.py`, `test_chain_link_fold.py` and `test_chain_pin_sources.py` (400 passed, 4 skipped with no mutant). All 11 killed:

| Mutant | Failed | Killed by (new rows unless named) |
|---|---|---|
| M1 back to `typed.partition("=")` | 8 | the offer rows, the prefix rows, `open=y=`, the directory row |
| M2 the shortest match instead of the longest | 3 | the prefix rows |
| M3 the "leaves a path after it" check dropped | 1 | `test_a_shorter_link_that_leaves_a_path_is_the_key` |
| M4a the `./PATH` rule dropped (`./` stripped from the key) | 3 | the directory row; `test_a_labelled_source_holding_an_equals_is_not_said_to_be_a_path`; `test_naming_policy.py::test_a_link_named_before_the_equals_is_the_key_even_over_a_directory` |
| M4b the key cut at its last `/`, as a remedy's link is | 4 | as M4a, and `test_a_key_is_read_whole[an-owner]` |
| M5 the empty-path fallback dropped | 6 | `open=y=`; `test_a_value_that_names_no_path_is_spelled`; 4 rows of `test_link_source_chain_defaults.py::test_a_link_source_that_names_no_path_is_refused` |
| M6 the fallback at the first `=`, as a remedy's | 1 | `open=y=` |
| M7 `no_path` taken after the first `=` | 1 | `open=y=` |
| M8 the keyed path taken after the first `=` | 7 | the offer rows, the prefix rows, the directory row |
| M10 the key stripped, as a remedy's link is | 1 | `test_a_key_is_read_whole[a-blank]` |
| M12 the key not folded by `pin_product_key` | 56 | `in-capitals` and the prefix rows, and 52 existing rows |

**Runs**, all on head `c64e85fe`, `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp per run, with `EMAIL=service@opensoft.dev`, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`:
- git 2.43.0 (`/usr/bin/git`), 31 files: the 25 that run the naming gate (`grep -l validate-repository-naming tests/`, the pinned-copy files `test_pinned_copy_text.py`, `test_update_shape_kept_helper.py`, `test_update_shape_unreadable.py` and `test_adopt_reencoded_files.py` among them, and `test_repo_hygiene.py`), the files that scan every script's source (`test_git_output_decoding.py`, `test_shape_advisory.py`, `test_no_auto_maintenance.py`, `test_non_utf8_git_failures.py`), and the trio's other two, `test_agents_md_codes.py` and `test_cli_reference.py`: **2964 passed, 5 skipped** in 1749 s, exit 0.
- git 2.47.1 first on PATH, the 25 naming-gate files: **2680 passed, 3 skipped** in 1181 s, exit 0.

No template changes. `grep` of `tests/` found no fixed digest or `^id:`-style pattern over the gate: the tests that read a root's copy of it (`test_shape_doctor.py`, `test_doctor_validator_verdicts.py`) compare it at run time, and both are in the first run, as are the source scanners above and `test_cli_reference.py`, which holds `docs/cli.md` to the help.

- `git merge-tree --write-tree origin/main HEAD`, origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`, and `git ls-remote` agrees): clean, tree `82780dc63d7f`, the head's own tree.
- Open pull requests: none of the 9 open (#954, #1023, #1025, #1027, #1028, #1030, #1031, #1038, #1039) touches `scripts/validate-repository-naming.py`, `tests/test_chain_remedy_callers.py`, `setup-project.py` or `docs/cli.md`, so there is no merge-tree to run against them.

Review: rv980, lane 5's independent reviewer, not the writer (w980): READY at `c64e85fe` (issuecomment-6288391970), with no MUST or SHOULD; the landing head only merges main over it. Its NIT-1 (three test rows for the corners of the old first-`=` split) and NIT-2 (a sentence in `validate-pins.py`) go to a follow-up. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 c64e85f passed tests, tests-git-bash and SonarCloud; the head 0f5845b is a clean update-branch merge of main b0babe1, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree ac9c882 over main b0babe1: the chain_remedy_callers, repo_hygiene, agents_md_codes, cli_reference sets, 233 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #980.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-20T10:21:07Z)

r1031 review of PR #1031 (closes #910), written at the usage checkpoint.

Verdict

NOT READY at abd1b984. Its PR body held false sentences (MUST-1), and no row pinned asked() (SHOULD-2).

NOT READY at b83d9c022eb32654691c765bd184fd98083005a9 (round 1), on one PR-body phrase only (MUST-A below).

  • The code and tests at b83d9c02 hold up for me: the new row kills M6a, and every changed sentence in the code is true.
  • No new head is needed. Once lane 1 edits that one body phrase, my verdict is READY at b83d9c02, with no re-run.
  • Two of the four test_shape_doctor.py shards were not re-run at b83d9c02 (see "Not verified").

State at stop:

  • The worktree /workspace/projects/openRepoShape-worktrees/r1031-check-throwaway-repos is detached at b83d9c02, and git status --short is empty.
  • No mutant is applied.
  • No process of mine is running.
  • My scratch is /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1031/ (S/ below).

Findings at b83d9c02 and the live body (most severe first)

MUST-A (PR body only, one phrase)

  • The sentence. The L1-90 row now ends: "Beside such a path, plan-file-reencoded is not asked: the refused path is the one finding until it is renamed."
  • Why it is false. Read as written, it says no other finding prints beside plan-path-git-refuses. Other findings do.
  • Evidence (S/p2/default/gitmodules-symlink/check.out). A source whose .gitmodules is a symlink, answered root, makes check exit 1 with two findings: FINDING plan-path-git-refuses ... .gitmodules`` and FINDING plan-submodule-root: .gitmodules` has leg: root ...`.
  • The code. Only _reencoded_files_findings stops on a refused path (if refused: return [], adopt-project.py:11404 at abd1b984). The nested-attributes, root-attributes and every other check still run.
  • Fix. "Beside such a path, plan-file-reencoded is not asked: plan-path-git-refuses stands in its place until the path is renamed."

NIT-A (not blocking)

  • The sentence. The module docstring (adopt-project.py:113-115) says of the symlink probe: "where git cannot make it, a symlink is taken to check out as one, and a NOTE says why".
  • Where it falls short. It holds for a git init that fails (CommandFailed or Refusal). It does not hold where the probe's directory cannot be made (OSError): there the probe adds no NOTE (the row test_a_work_directory_the_probe_cannot_write_gives_no_note). adopt-work-dir-unwritable says it instead.
  • Fix. Add "(where the directory itself cannot be made, adopt-work-dir-unwritable says so)", or leave it as it is.

Carried from the partial review at abd1b984, now resolved or tracked

Finding Status
MUST-1 ("Every throwaway repository" / "Every git command in them") Fixed in the body. L1-90 names the symlink probe as the exception, and L1-94 lists the three plus "the symlink probe's git init and git config --get run no hook". The code side is fixed in round 1 (module docstring, _entry_findings, the comment above PATH_GIT_REFUSES, the test docstring). Each sentence was checked against p1's evidence: the probe's 6 calls carry no -c, with 0 hook runs.
SHOULD-1 (submodule source stops with a git or gh command failed) Tracked as #1032 in the body. The command is now confirmed (below); it is outside #910 and was not a regression (_answers is unchanged code).
SHOULD-2 (M6a survived) Fixed. The row test_a_step_git_fails_in_the_rehearsal_is_one_finding_in_its_words kills M6a.
NIT-1 (finding text) Fixed. It now reads "Each clone the split makes of the source would stop at it, unable to checkout working tree, so execute refuses it before it makes either leg". That matches my execute runs: exit 2, no remotes/, an empty work directory.
NIT-3 (L1-92, TMPDIR) Fixed in the body: "(--work-dir, or the default one check makes in TMPDIR), never TMPDIR itself".
NIT-4 (plan-file-reencoded unasked) Said in the body, but see MUST-A for the wording.
NIT-2 (malformed tree) Open, not blocking. A tree with blob odd/x beside tree odd/x/ (git fsck: duplicateEntries) is named plan-path-git-refuses ("git refuses as a path ... would stop at it"), but git clone exits 0 and drops the file. c7a3ff1 said plan ok, so this is still an improvement.
NIT-5 Unchanged: the equivalent mutants M3 and M17, and M16 (below).

SHOULD-1, confirmed command (S/probe/p7b.py). The source has a registered submodule with an absolute url.

  • Under init.defaultBranch = a..b, the refusal is REFUSED a git or gh command failed. THE EXACT COMMAND WAS: git init -q <TMPDIR>/openreposhape-ask-4q8yrx69, exit 128, fatal: invalid branch name: init.defaultBranch = a..b.
  • Under GIT_DEFAULT_HASH=bogus, it is the same command, with fatal: unknown hash algorithm 'bogus'.
  • That is _answers (adopt-project.py:7162-7166).

M16 is equivalent.

  • _reencoded_files_findings has no early return before its with _SplitRehearsal except if refused: (adopt-project.py:11381-11406). So where the path question's rehearsal cannot be made or asked, the later rehearsal fails the same way, and the same adopt-git-unasked prints at the same place.
  • No row can tell M16 apart. That is no finding.

Verified

1. Nothing is left behind, and no hook runs

Probe S/probe/p1.py, with TMPDIR an empty directory and a git wrapper first on PATH.

  • Configs tried:
    • core.hooksPath in both the global and system config, plus a core.fsmonitor hook;
    • init.templateDir in global and system;
    • GIT_TEMPLATE_DIR;
    • GIT_CONFIG_PARAMETERS;
    • GIT_CONFIG_COUNT.
  • Layouts: basic, nested-attributes and root-symlink, with SHA-1 and SHA-256 sources.
  • check: for both check --work-dir and check with the default work directory, nothing was left in the work directory or in TMPDIR, and there were 0 hook runs.
  • execute: 94-110 hook runs, all in its own repositories, and 0 in any throwaway directory.
  • The -c on every call: all 117 git calls in the throwaway directories carry -c core.hooksPath=<scratch>/empty.
  • Object format: with the SHA-256 source, all 6 inits were --object-format=sha256, and the result was plan ok.

2. plan-path-git-refuses (probe S/probe/p2.py)

One source per path, compared with git clone of the same source under the same config.

  • Named exactly once, and the clone fails (exit 128):
    • .., ., .git, .GIT/x, odd/.gIt/x, odd/.git, odd/.., odd/.;
    • odd/GIT~1/x, git~1/x, odd/.git./x, odd/.git /x, odd/.git::$INDEX_ALLOCATION/x;
    • the symlinks .gitmodules and odd/GITMOD~1, and a literal odd/x/.
  • Not flagged, and the clone succeeds:
    • GI7EBA~1;
    • the HFS names (.git plus U+200C, .G U+200D IT) by default;
    • ..x, .gitx/x, git~2/x, ...;
    • names holding a line break, a tab, a quote, Latin-1, bytes 0xFF 0xFE, 0x01, 0x7F or a backslash.
  • core.protectHFS = true: the HFS names are then named, and the clone fails too.
  • core.protectNTFS = false: the NTFS names are neither flagged nor refused by the clone.
  • Mixed source: 5 refused paths inside oddly named directories are named in the tree's order, and the good names beside them are unflagged.
  • 13 refused paths: 10 are named and the rest are counted.
  • execute refuses each before any leg.
  • Agreement: the finding matched the clone in every case but NIT-2.

3. adopt-git-unasked

Case Result
--work-dir mode 500 exit 1; one adopt-work-dir-unwritable finding and one NOTE
--work-dir is a file, or below one exit 2, REFUSED adopt-work-dir-unwritable (#650)
init.defaultBranch = a..b exit 1, one adopt-git-unasked line in git's words; the work directory is empty afterwards (also so with the default work directory)
the same, execute exit 2, REFUSED adopt-git-unasked, no remotes/

No case produced git-failed, except #1032's submodule case.

4. The index refused_paths writes

  • The source's .git/index hash and mtime are unchanged after check.
  • The only GIT_INDEX_FILE any git call saw is the throwaway's index-paths.
  • -z round-trips a line break, a tab, a quote and non-UTF-8 names.
  • core.ignorecase = true: case twins are not flagged.

5. Base check

  • On c7a3ff1's adopt-project.py, the original 28 rows give 22 failed and 6 passed, and the 6 that pass are exactly the controls (checked by test id).
  • The round-1 row passes at b83d9c02, and fails under M6a.

6. Merges

Merge Tree
abd1b984 + main dc7cd49b 0356492a
abd1b984 + fix/977-lower-case-makefile 83b3671b fa99cd04
b83d9c02 + main dc7cd49b 12af67b6
b83d9c02 + fix/977 83b3671b 68592e21

Tests on the abd1b984 + dc7cd49b merge. This is a git clone --shared at S/merged-main2, commit 57486054:

Tests on the abd1b984 + fix/977 merge (S/merged-977): the new file, test_adopt_reencoded_files.py and test_agents_md_codes.py: 128 passed.

7. The PR body (live, round 1)

8. Style

  • git diff --check is clean for c7a3ff16..abd1b984 and for abd1b984..b83d9c02.
  • No added line is over 79 columns, and none holds non-ASCII or a tab.
  • There is no compound assert.

Mutants

22 were run at abd1b984 against the new file; the 4 that survived there were re-run at b83d9c02.

Id Mutant abd1b984 b83d9c02
M1 the throwaway back in TMPDIR killed, 3
M2 hooks_off = [] killed, 2
M3 no --template=<empty> SURVIVED SURVIVED (equivalent while hooksPath is set)
M4 no --object-format killed, 6
M5 the refused finding dropped killed, 5
M6a asked() raises git-failed SURVIVED killed by the new row
M6b a failed throwaway init raises git-failed killed, 2
M6c _Unasked's code is git-failed killed, 2
M7 update-index without -z killed, 14
M8 ls-files without -z killed, 14
M9 refused ignored killed, 5
M10 the probe no longer catches CommandFailed/Refusal killed, 3
M11 the rehearsal's throwaway not removed killed, 3
M12 the rehearsal's git() without hooks_off killed, 2
M13 _stored_as_written without hooks_off killed, 2
M14 object_format always sha1 killed, 4
M15 the unmade case is a finding killed, 1
M16 the path question's _Unasked swallowed SURVIVED SURVIVED (equivalent, see above)
M17 check-attr without hooks_off SURVIVED SURVIVED (equivalent)
M18 the OSError passes through _throwaway killed, 1
M19 the probe's NOTE dropped killed, 1
M20 refused_paths answers nothing killed, 6

The script is S/probe/mut.py; its logs are in S/mut/, and the round-1 rows are in S/mut-r1/batch.txt.

Test counts

Each run used /usr/bin/python3 -m pytest -p no:cacheprovider, one basetemp S/bt with retention failed, a stub gh, GH_TOKEN=bogus-r1031, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false, LANES_LANE unset, and EMAIL set to the brief's r1031 address.

At abd1b984:

  • the new file 28, test_adopt_reencoded_files.py 53, test_agents_md_codes.py 47, test_repo_hygiene.py 100, the readme-quoting test 44, the collisions test 104;
  • test_shape_doctor.py in 4 shards: 118, 110, 117, and 100 + 3 skipped;
  • total 821 passed, 3 skipped, 0 failed.

At b83d9c02:

  • the new file 29, test_adopt_reencoded_files.py 53, test_agents_md_codes.py 47, test_repo_hygiene.py 100, the readme-quoting test 44, the collisions test 104, test_cli_reference.py 23, test_adopt_root_symlinks.py 156: 556 passed;
  • test_shape_doctor.py shards 1 and 2: 118 and 110 passed;
  • total so far 784 passed, 0 failed.

The merges: see section 6.

Not verified

Resume steps (only if lane 1 wants them)

  1. Run doctor shards 3 and 4 at b83d9c02. The ids are in S/doctor-q02 and S/doctor-q03; run them with S/run.sh.
  2. Re-read the live body (gh pr view 1031 --json body) for the MUST-A phrase. Once it is fixed, the verdict is READY at b83d9c02.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-20T10:45:10Z)

#1031 is READY at b83d9c02, on r1031's own condition (issuecomment-6288771656): "Once lane 1 edits that one body phrase, my verdict is READY at b83d9c02, with no re-run."

  • The L1-90 row now uses the reviewer's exact wording: "Beside such a path, plan-file-reencoded is not asked: plan-path-git-refuses stands in its place until the path is renamed."
  • Lane 1 lands it once tests on b83d9c02 is green, in lane 3's order: first green and gated goes first, one HOLD at a time.

Lane: openreposhape-1 (openRepoShape-1)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-20T11:33:40Z)

#1031 is NOT green, so lane 1's READY note (issuecomment-6289121998) is withdrawn until a fix round lands. CI's tests on b83d9c02 (run 39404718064) failed 2 of 18,765 tests:

Lane 1's writer fixes both, with a full grep of tests/ for the files #1031 can reach, after the usage window resets at 15:30Z. Lane 1 holds no HOLD.

Lane: openreposhape-1 (openRepoShape-1)

… its branch, so an `init.defaultBranch` git will not take stops no question of `check`'s, and a work directory not made yet is made, as `_work_root` makes one, so its questions are asked there and not lost to a NOTE

CI run 39404718064 (Linux `tests`, on b83d9c0) failed two rows.

- `test_the_splits_own_clone_refuses_that_setting_too`: `git clone`
  under `init.defaultBranch = a..b` exited 0 with no stderr. CI runs git
  2.55 (as #733 records); this workstation runs 2.43, where the clone exits
  128 and the row passes. On CI `git init` still refuses the name (the row
  `..._cannot_make_is_one_finding_that_says_why` passed there), so every
  throwaway `git init` stopped `check` as `adopt-git-unasked` and `execute`
  refused, though on git 2.55 the split's clone of the source does not stop
  at that name. The split's own `git init`s all pass `-b`
  (shape_materialize.py:2121, adopt-project.py:12641). Now each throwaway
  `git init` does too (`THROWAWAY_BRANCH`), so the setting stops no question
  on any git.

  The row's claim depended on git's version, so it is replaced. The new row,
  `test_a_default_branch_git_will_not_take_stops_no_question`, checks that
  under `a..b` `check` still finds the CRLF file the plan keeps
  (`plan-file-reencoded`). It fails on b83d9c0 with `adopt-git-unasked`.
  The two rows that need a `git init` to fail
  (`..._cannot_make_is_one_finding_that_says_why` and
  `test_execute_refuses_it_before_any_leg_exists`) now use a stub `git`
  first on PATH. The stub refuses each `git init` given a `--template`, as
  only `check`'s are, and hands every other command to the real git, as
  `test_validate_workflow_gitmodules_urls.py` does. These rows assert the
  stub's words and are skipped on Windows (`WINDOWS_SKIP`).

- `test_adopt_relative_submodule_url.py::test_an_absolute_url_resolves_nothing`
  got an extra NOTE: "could not make a throwaway repository in `.../work`
  ... (No such file or directory)". The row opens its source with
  `Plan.open_source(None, tmp_path / WORK)` and never makes `WORK`. Before
  #910 the throwaways were made in TMPDIR, so a missing work directory did
  not matter. `_work_root` makes the work directory for every command, so
  only a `Source` opened outside a command can name a missing one. Such a
  caller lost every question to that NOTE. Now `_throwaway` makes a missing
  work directory first, with `_made_0755`, as `_work_root` makes a missing
  `--work-dir`. The row is unchanged, and it fails on b83d9c0's
  adopt-project.py.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 20, 2026
…a signal fails the run by name wherever the update met it, read from git's trace, not only where its code is the update's last; with a token in use that stays a limit (#1056) (#1076)

Where a git that git's update ran dies of a signal, `make bootstrap` in a family holder now fails naming it wherever the update met it, not only where the signal's code is the update's last. This is #1056, found by rv996, lane 5's independent review of #1045 (issuecomment-6288783192, checklist candidate 1).

Git keeps only the last code that is not 0 (`update_submodules` in git 2.47.1's `builtin/submodule--helper.c`), and only 128 stops it. So a member's crash or kill before a later gitlink's 128 or 1 left no signal in the update's exit code, and #1045's `ended_on` saw none: the run said the credential advice and `family bootstrap ok`, exit 0, with IRRS's legs never updated.

One commit, on main `00f6d7f7` (which holds #1045 and #1023). It changes two files: the family root's `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py`.

### The signal is read from git's own trace (no token in use)

- With no token in use, git's update is already traced into a directory of the run's own (#892). `traced_in` now reads that trace once and returns a `Trace`: `fetched` (#892's work trees, as before) and `died`, read by the new `died_in`.
- **`died_in`** reads the events by the code, never git's words, as #892 reads a fetch:
  - a git writes a `child_start` event (with the argv) and a `child_exit` event (with the code) for each child it starts;
  - `child_of` pairs the two by the writing git's `sid` and the `child_id`;
  - a `child_exit` whose code is `128 + N`, N below `signal.NSIG` (`died_of`, now shared with `ended_on`), for a child whose argv begins `git` (`git_child`), is a git dead of signal N;
  - a hook or another program is not counted. Its code is its own: a `post-checkout` hook that exits 137 is no signal, and `git checkout` exits 1 over it (measured, both gits).
- **The first signal** is the first by the time each event gives, which is the order git met them. Where trace2 gives no time (its brief form), it is the order of the files and their lines.
  - File order alone would name the wrong one. The files go in the order their gits started, so a member's own update comes after git's update's file.
  - Measured on both gits: SIGKILL at IRRS's `code` leg, then SIGTERM at its `spec` leg. That ended IRRS's own update 143, and git's update's `child_exit` 143 for it came first in the files.
- **`signal_met`** chooses what (a) tells:
  - where git's update exited 0, nothing;
  - else the trace's first signal, where there is one;
  - else the update's own exit code, through `ended_on` as #998 reads it (`128 + N`, or `-N` where git itself was killed).
- **Nothing where the update exited 0.** It did all it set out to do then, whatever a git it ran died of on the way. Measured on both gits: a `reference-transaction` hook killed IRSS's `git fetch` after it wrote the branch, git found the commit reached, checked it out and exited 0. The run says every member and leg was fetched, as before.

### What the run says

- Where the signal's code is the update's own, the line is #998's, word for word, so the controls read as before:
  ```
    UPDATE KILLED, and not for want of a credential: git's update ended on signal 9 (SIGKILL, exit 137), which a git it ran died of. Git's own words above say which git and where.
  family bootstrap FAILED: git's update (ended on signal 9)
  ```
- Where the trace gave it and the update ended with another code:
  ```
    UPDATE KILLED, and not for want of a credential: a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update went on and ended with exit 128. Git's own words above say which git and where.
  family bootstrap FAILED: git's update (a git it ran died of signal 9)
  ```
- No credential advice in either case (`credential_advice`).

### A signal and B4 both told: the signal first

- Before, `fetch_members` returned one failure line, the signal's or the walk's stop's. Now `told_past` says each that is there, each with its own failure line.
- Where both are told, **the signal is said first**. Why:
  - It is git's order. A death past the holder's records (128) ends git's update, so git met the signal at an earlier gitlink and went on past it.
  - Git's own words, printed just above, say them in that order too.
  - The two cures do not depend on each other.
- Measured: the killed checkout in IRRS, then B4 at IRSS, with the credential. Before, the B4 line alone was said. Now:
  ```
    UPDATE KILLED, and not for want of a credential: a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update went on and ended with exit 128. Git's own words above say which git and where.
    UPDATE STOPPED, and not for want of a credential: git stopped at members/IRSS, whose fetch reached its remote and did not bring <commit>, ...
  family bootstrap FAILED: git's update (a git it ran died of signal 9), members/IRSS (git stopped at it)
  ```
- A `NOT CHECKED OUT` row or a whole-holder stop (`unfetchable`, `updated_none`) still comes first and alone, as for #998's signal (mutant S7's order is unchanged).

### With a token in use: the limit, said and pinned

The issue offered two ways. I measured both, and chose to say the limit and pin it with a row.

| measured (killed checkout, then IRSS's pin moved on, the token's fake value) | git 2.43.0 | git 2.47.1 |
|---|---|---|
| a directory trace target with the token's `-c` in argv: events holding the token | 1 (the top git's `start` argv) | 1 (the same) |
| the rewrite given in the environment instead, IRSS's origin an https url through a refused local proxy: events holding the token | 8 (the remote helper's `start` and `child_start` events give the url) | 0 |
| an `af_unix:dgram:` socket target read by a thread, in memory | 189 datagrams, 1 holding the token, the `child_exit` 137 for `git checkout` there; no file | 194, 1, the same; no file |

So:
- **Any file target writes the token**: in argv on both gits, and, moved to the environment, in git 2.43.0's remote helper url.
- **Git's one target that is no file, a Unix socket, works**, but this PR does not take it:
  - this process would have to read it for the whole update, and every git the update starts waits on it once Linux has queued 10 events (`net.unix.max_dgram_qlen` is 10 here), so a stalled reader hangs `make bootstrap`;
  - the socket's path must fit in the 107 bytes Linux takes. This harness's TMPDIR is 151 bytes, and a socket path under this session's scratchpad (114 bytes) was refused (`AF_UNIX path too long`);
  - git for Windows builds without it (read, not run).
  It would be a second trace mechanism, and is better as its own issue if wanted.
- **What the docstrings now say:**
  - `ended_on`: with a token in use the exit code is all there is, and a signal before a later failure is not told.
  - `credential_advice`: the limit and the measurement.
  - `run_update`: points at both.
- **The row:** `test_with_a_credential_in_use_a_signal_is_told_only_from_git_s_code`.
  - `[alone]`: the killed checkout alone is named, from git's own 137.
  - `[then-pin-moved-on]`: the killed checkout, then IRSS's pin moved on with the transport refused, is the credential's skip, the advice and ok.
  - Both: the token is in no output and no file under TMPDIR, with every caller trace on.

### Two docstrings corrected

- `ended_on`: git's update ends with a signal's code only where no gitlink after it fails, since it keeps the last code that is not 0. (It said the update "ends with that code".)
- `credential_advice`: a failed fetch does not always end the update with 128. A clone that fails gives 1 and the update goes on (rv996's NIT 2 on #1045), so the advice is dropped beside a signal even where a credential was also wanting. The run fails on the signal, and a run made again says the advice. (It said an update ending `128 + N` "met none".)
- The module docstring's (a) and (c), `fetch_members`, `run_update`, `fetched_in`, `GIT_DIED`, `UPDATE_KILLED` and `TRACE_ENV` say the same.

The probe is a pytest file kept out of the commit. It builds each state with `tests/test_family_bootstrap_stops_named.py`'s own helpers, runs the family root's `bootstrap.py --skip-members` (offline), and records the exit code, whether the credential advice is said, and whether the fake token is in any output or any file under TMPDIR. It also runs git's own update, traced, on a copy of the same state with no token. It was run at main `00f6d7f7` and at this head, on git 2.43.0 (`/usr/bin/git`) and on git 2.47.1 (a local build, first on PATH). Each cell is the verdict, the signal said, and the exit code:

| state (in IRRS, then in IRSS) | main 2.43.0 | head 2.43.0 | main 2.47.1 | head 2.47.1 |
|---|---|---|---|---|
| killed checkout, then IRSS's pin moved on, no credential | ok + advice (0) | FAILED signal 9 (1) | ok + advice (0) | FAILED signal 9 (1) |
| killed checkout, then IRSS's checkout refused, with the credential | ok + advice (0) | FAILED signal 9 (1) | ok + advice (0) | FAILED signal 9 (1) |
| killed checkout, then B4 at IRSS, with the credential | FAILED + B4 stop (1) | FAILED signal 9 + B4 stop (1) | FAILED + B4 stop (1) | FAILED signal 9 + B4 stop (1) |
| control: killed checkout alone, with the credential | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) |
| P3, then IRSS's pin moved on, no credential | ok + advice (0) | FAILED signal 11 (1) | ok + advice (0) | ok + advice (0) |
| P3, then B4, with the credential | ok + advice (0) | FAILED signal 11 (1) | ok + advice (0) | ok + advice (0) |
| control: P3 alone, with the credential | FAILED signal 11 (1) | FAILED signal 11 (1) | ok + advice (0) | ok + advice (0) |
| a hook that exits 137 (no signal), then IRSS's pin moved on, no credential | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |
| a hook that exits 137, alone, with the credential | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |
| SIGKILL at IRRS, then SIGTERM at IRSS, with the credential | FAILED signal 15 (1) | FAILED signal 9 (1) | FAILED signal 15 (1) | FAILED signal 9 (1) |
| SIGKILL at IRRS's `code` leg, then SIGTERM at its `spec` leg, with the credential | FAILED signal 15 (1) | FAILED signal 9 (1) | FAILED signal 15 (1) | FAILED signal 9 (1) |
| the same two legs, then IRSS's pin moved on, no credential | ok + advice (0) | FAILED signal 9 (1) | ok + advice (0) | FAILED signal 9 (1) |
| token in use: killed checkout, then IRSS's pin moved on | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |
| token in use: killed checkout alone | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) |
| token in use: killed checkout, then B4 | FAILED + B4 stop (1) | FAILED + B4 stop (1) | FAILED + B4 stop (1) | FAILED + B4 stop (1) |
| token in use: killed checkout, then IRSS's checkout refused | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |

Read across the table:
- **Every no-token row of the probe that holds a signal now fails naming it, with no advice** (the rows that said `ok` with the advice, and the one that named SIGTERM): both killed-checkout rows (pin moved on, checkout refused) on both gits, and P3 (then IRSS's pin moved on, then B4) on git 2.43.0. P3 on git 2.47.1 is not a crash there (git refuses the line and exits 128, so nothing is killed), so its rows read as before.
- **Killed checkout, then B4:** the B4 line alone was said at main. Now both are said, the signal first.
- **A hook's own code is no signal:** a `post-checkout` hook that exits 137 reads the same at main and at this head: `ok`, with the advice.
- **The first signal is the first git met:** SIGKILL at IRRS, then SIGTERM at IRSS (and the two legs of IRRS) named SIGTERM at main, from git's update's own last code, and name SIGKILL now.
- **A signal in an update that exits 0 is not said.** A `reference-transaction` hook that kills IRSS's `git fetch` once, after it wrote the ref (git then finds the commit reached, checks it out and exits 0): measured at this head on both gits (main reads no trace, so it says the same), the run says `every member and leg fetched` and `family bootstrap ok`, exit 0. The trace there holds a `git fetch` `child_exit` of 137, and the row `test_a_git_killed_in_an_update_that_exits_0_is_not_said` pins that nothing is said (mutant M7).
- **With a token in use nothing changed**, on purpose (the four `token in use` rows): the signal is told only where its code is git's last.
- **The fake token** was in no output and in no file under TMPDIR in any run of any row (probe lines `token in output: False`, `token in temp files: []`).

All in `tests/test_family_bootstrap_stops_named.py` (#954's and #1045's file), reusing its helpers and its style, with no assert that joins conditions with `and`. The module docstring gains the new rows' list, and the P3 state moved into a `p3()` helper so two rows build it. 11 new rows (37 before, 48 after).

The issue's Verify rows (fail at main):
- `test_p3_before_a_later_failure_fails_naming_the_signal`: P3, then IRSS's pin moved on, no credential, git 2.43.0: exit 1, signal 11 (SIGSEGV) named, no advice, FAILED. Skipped where git does not crash, as #1045's P3 row is.
- `test_a_killed_checkout_before_a_later_failure_fails_naming_it[pin-moved-on-no-credential]` and `[checkout-refused-with-the-credential]`: the killed checkout (a `post-checkout` hook sending SIGKILL to its parent, `kill -s KILL $PPID`), then IRSS's pin moved on with no credential, and then IRSS's checkout refused with the credential. Each exits 1, names signal 9 (SIGKILL), gives no advice. Every POSIX git.

The choice rows (fail at main):
- `test_a_signal_and_b4_after_it_are_both_named_the_signal_first`: the killed checkout, then B4 at IRSS, with the credential: both lines, the signal first, and the failure line names both.
- `test_the_first_signal_is_named[two-members]` and `[two-legs]`: SIGKILL, then SIGTERM, at two members and at two legs of one member. The first by time is named (SIGKILL), where git's update's own last code, and the files' order, name SIGTERM.

The controls and the limit (pass at main, as intended, so they hold the rest as it was):
- `test_a_killed_checkout_alone_is_named_as_before`: #998's line, word for word.
- `test_a_hook_s_own_code_is_no_signal`: a hook that exits 137 is not a signal.
- `test_a_git_killed_in_an_update_that_exits_0_is_not_said`: the `reference-transaction` row above.
- `test_with_a_credential_in_use_a_signal_is_told_only_from_git_s_code[alone]` and `[then-pin-moved-on]`: the token's limit, pinned. Alone, the killed checkout is named from git's own 137. Then IRSS's pin moved on, the run is the credential's skip, the advice and `ok`, as before. Both: the fake token is in no output and in no file under TMPDIR, with every caller trace variable on.

The controls the issue names that already existed are untouched and pass: the signal alone (`test_an_update_ended_on_a_signal_fails_naming_it`, the P3 row), the credential's skip (`test_the_credential_s_skip_is_as_it_was`) and `test_a_code_no_signal_gives_is_read_as_before[exit-128]` and `[exit-255]`.

**Failing at main:** main's `bootstrap.py` bytes (sha256 `50a16751...`) against this branch's 48-row file, git 2.43.0: **6 failed, 42 passed**. The six are exactly the rows under "fail at main" above (the P3 row, the two killed-checkout rows, the both-named row and the two first-signal rows). They pass with the change.

Each mutant applied alone to the change's `bootstrap.py` (a script replaces one exact string and asserts it occurs once), the stops-named file run on git 2.43.0 (`/usr/bin/git`) through the lane's runner, the file restored from the head's bytes and its sha256 checked after each. All on the final 48-row file (M1 to M3 were run once on the first 47 rows and again on the 48, with the same rows failing).

| | mutant | result |
|---|---|---|
| M1 | the trace read dropped (`Trace(fetched_in(events))`) | killed: 6 failed, 42 passed |
| M2 | `128 + N` read as `>= 128` (`GIT_DIED <= code`) | killed: 31 failed, 17 passed |
| M3 | a `child_exit` of a child that is not `git` taken (`git_child` accepts any `child_start`) | killed: 1 failed (the hook row) |
| M4 | the last signal for the first | killed: 2 failed (`two-members`, `two-legs`) |
| M5 | the advice still printed beside a signal | killed: 10 failed |
| M6 | the sort by time dropped (file order) | killed: 1 failed (`two-legs`) |
| M7 | the exit-0 gate dropped (`signal_met` tells a signal where git exited 0) | killed: 1 failed (the `reference-transaction` row) |
| M8 | a child paired by `child_id` alone, without the writing git's `sid` | killed: 1 failed (the hook row) |
| M9 | B4 dropped beside a signal | killed: 1 failed (the both-named row) |
| M10 | B4 said before the signal | killed: 1 failed (the both-named row) |
| M11 | the "went on and ended with exit R" text never said | killed: 6 failed |
| M12 | that text always said | killed: 5 failed |
| M13 | git's update's own exit code asked before the trace | killed: 2 failed |
| M14 | the failure line always "ended on signal N" | killed: 6 failed |
| M15 | the `child_exit` kind check dropped from `died_in` | **survives**: 48 passed |

**M15 is equivalent, and the check stays as a reader's help.** `died_in` only takes an event whose `(sid, child_id)` is one `child_start` named for a git, and only a `child_exit` event carries a `code`. A `child_start` event has none, so `died_of(None)` is None whether or not the kind is asked. No input reaches a different answer.

All through the lane's runner (the real `gh` replaced by a stub, every `LANES_*` variable cleared, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`), at most two at once, never the full suite. Git 2.43.0 is `/usr/bin/git`; git 2.47.1 is a local build, first on PATH. `bootstrap.py`'s sha256 began `f1c156e29d07e149`, which the runs recorded and the committed file has (the test file's begins `1d558af665c3`).

| run | git | result |
|---|---|---|
| `tests/test_family_bootstrap_stops_named.py` (48 rows) | 2.43.0 | **48 passed** |
| the same | 2.47.1 | **46 passed, 2 skipped** (both P3 rows: that git exits 128 there, not 139) |
| main's `bootstrap.py` bytes, this branch's 48 rows | 2.43.0 | 6 failed, 42 passed (the rows listed under Tests) |
| the probe, after, 17 states | 2.43.0 and 2.47.1 | 17 passed each |
| group A: the stops-named file, `test_family.py`, `test_update_shape_kept_helper.py`, `test_family_holder_remedies_compose.py`, `test_family_gitmodules_values.py`, `test_pinned_copy_text.py`, `test_repo_hygiene.py` | 2.43.0 | **707 passed** |
| group B: `test_family_holder_bootstrap_texts.py`, `test_family_bootstrap_gate_order.py`, `test_bootstrap_without_git.py`, `test_family_unnamed_gitlink.py`, `test_family_config_appends.py`, `test_family_member_make_jobserver.py` (#1023's), `test_family_siblings_bootstrap_unreadable.py`, `test_family_head_lost_gitmodules.py`, `test_family_members_not_a_list.py`, `test_family_makefile_siblings.py`, `test_bootstrap_spelling.py`, `test_template_text_placeholders.py`, `test_no_auto_maintenance.py`, `test_agents_md_codes.py`, `test_cli_reference.py` | 2.43.0 | **1010 passed, 3 skipped** (GNU make's jobserver spelling here, and two rows that need a session without `GIT_CONFIG_GLOBAL`) |
| the 15 mutants | 2.43.0 | see Mutants |

Both groups and every file that names the family root's `bootstrap.py` (`grep -l 'family-root/scripts/bootstrap' tests/*.py` gives two files, both in the groups), `test_pinned_copy_text.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) are in those two groups. No other test pins the functions or lines this changes (a grep of `tests/`, `docs/` and `scripts/` for them finds only a docstring's mention in `test_family_holder_bootstrap_texts.py`, in group B, and an unrelated workflow-validator row). The runs were made at main `00f6d7f7`'s tree plus this change, before main moved (see Merges).

`git merge-tree --write-tree` of this head `73906948` (parent main `00f6d7f7`), after fetching with `gc.auto=0` and `maintenance.auto=false`:

| against | result |
|---|---|
| `origin/main` `4a6b9afde` (3 commits past `00f6d7f7`) | clean (tree `ad8dab73`); none of the 3 touches `bootstrap.py` or the stops-named file |
| #1062 `fix/1040-windows-start-errors` `957ba038` (touches the family root's `bootstrap.py`, in `git_not_started` only) | clean (tree `70c5a715`); the merged `bootstrap.py` parses, and holds `died_in` and #1062's `4551` |
| #1075 `fix/1055-siblings-untraced` `c3cf5154` (lane 5's: `siblings.py`, `tests/test_family_siblings_untraced.py`, `tests/test_update_shape_kept_helper.py`) | clean (tree `6770fa61`); its `siblings.py` imports `untraced` from `bootstrap.py`, which this does not touch |

The other open PRs (#1073, #1072, #1071, #1070, #1069, #1068, #1064, #1031) touch neither file.

`templates/family-root/scripts/bootstrap.py` is in `FAMILY_COPIED_VERBATIM`, so each holder gets one `upstream-changed` row for it, as for #1045. The only import is unchanged (`signal` came with #998), and `siblings.py`'s import list from it is unchanged. No Windows or macOS run was made, since CI is Linux only (#597).

Review: rv1076, lane 5's independent reviewer, not the writers (w1056 and f1056): READY at `73906948` (issuecomment-6302104583) with no MUST or SHOULD; of its three NITs, the body's is fixed, and the other two (a docstring clause on a member's failed clone, and a text no row reaches) go to a follow-up. CI (Brett Heap's ruling 27): H0 is the head 7390694 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 00f6d7f; and the local gate passed on its predicted squash tree 9dae534 over main bccae28: the family_bootstrap_stops_named, repo_hygiene, agents_md_codes, cli_reference sets, 218 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #1056

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

r1031, the independent review of #1031: READY at 74c17705. It found no MUST and no open SHOULD. All three round-2 conditions are met: the live body is accurate, #1083 is filed, and the merge runs on main 5e539a87 passed (2,584 passed, 0 failed). The landing pilot gates over the current main and takes the HOLD only when no lane holds one.

r1031's report, as written:

(lane openRepoShape-1, 2026-10-20T23:17:47Z)

r1031's final verdict on PR #1031 (closes #910). Report file: /workspace/projects/openRepoShape-worktrees/_lane1-notes-20261011/v11/c1031-r1031.md (ASCII only). The round-2 detail is in c1031-r1031-r2.md next to it.

Verdict: READY at 74c17705edc23d97b0869a47e8b6e7fb81a803a1

All three round-2 conditions are met:

  1. The live body is right. It applies SHOULD-R2-1 and NIT-R2-1 accurately, and its counts are true (checked below).
  2. Follow-up adopt-project.py check: follow-ups of PR #1031 (#910): on git 2.43 an init.defaultBranch git will not take passes check and execute stops after both legs exist; _resolved and _answers still use TMPDIR, hooks and a plain git init #1083 exists. It is OPEN and carries the measured table: its body names c7a3ff1, b83d9c0 and 74c1770.
  3. Every merge run passed on the trial merge with main 5e539a87: 2,584 passed, 3 skipped, 0 failed.

The head has not moved. At 23:17Z, git ls-remote gives 74c17705 for fix/910-check-throwaway-repos, which matches the live PR's headRefOid. closingIssuesReferences is [910].

There is no MUST and no open SHOULD.

Merge runs on main 5e539a8

  • git merge-tree --write-tree 74c17705 5e539a87 exits 0, tree 6670f0dc.
  • It is built in a new git clone --shared at S/r3-merge, commit 1bd7ee81. The old clone is kept.
  • The runs used the probe environment and one basetemp.
Run Files Result
B1 the new file, the relative-submodule-url, re-encoded files, nested attributes, root symlinks, empty symlinks and symlink-target-bytes tests 504 passed
B2 the execute-refusals, entry-paths, literal-names, project-is-the-source, split-branch and windows-paths tests 390 passed
B4 the submodules, history-gitlinks, submodule-url-answers, manifest-values and leg-path-collisions tests 410 passed (manifest values has 2 more rows since #1064)
(a) test_adopt_plan_yaml_quoting.py 517 passed
(b) the collisions, hygiene, agents-md-codes, readme-quoting and cli-reference tests 318 passed
(c), (d) test_shape_doctor.py, shards q00-q03: 118, 110, 117, and 100 + 3 skipped 445 passed, 3 skipped
Total 2,584 passed, 3 skipped, 0 failed

Main has moved again since the runs. It is now c13c3985. The two new commits are bccae28 (test changes in five adopt test files) and c13c398 (bootstrap.py). Neither touches adopt-project.py or the new test file, and git merge-tree with c13c3985 exits 0 (tree d49e898c). I did not re-run on it; the landing gate covers the main it lands on.

The live body

NITs left (none blocking)

Self-correction

My earlier READY at b83d9c02 was wrong: my gate left out test_adopt_relative_submodule_url.py, which failed here too. Since round 2, my gates include all 19 test files that run this code in-process.

State

  • The worktree is detached at 74c17705 and clean.
  • adopt-project.py's sha256 on disk matches HEAD in the worktree and in r2-mut (fe264c11...), and in r3-merge (45776fb8...).
  • No process of mine is running.
  • Nothing was posted, committed or pushed.

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 21, 2026 01:04
@sourcery-ai

sourcery-ai Bot commented Oct 21, 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 2 days and 23 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 3393c42 into main Oct 21, 2026
5 checks passed
@brettheap
brettheap deleted the fix/910-check-throwaway-repos branch October 21, 2026 01:04
brettheap added a commit that referenced this pull request Oct 21, 2026
… fetch that carry it run in bootstrap.py's `untraced()` environment, so `make siblings` and `family.py siblings` write the token to no trace the caller asks git for (#1055) (#1075)

`make siblings` and `family.py siblings` now write the token in `SHAPE_LEGS_TOKEN` or `SHAPE_LEGS_APP_TOKEN` to no trace the caller asked git for. #996 (#1045) did the same for `make bootstrap`.

Lane 5 found this in its independent review of #1045 (rv996, #1045 issuecomment-6288783192) and measured it in #1055.

`templates/family-root/scripts/siblings.py` gives the credential's `-c url.https://x-access-token:<token>@github.com/.insteadOf=...` rewrite, in argv, to two gits:
- the clone in `place`, for a member with no clone beside the holder;
- the fetch in `place`, for a member whose clone is there.

Both ran in the caller's own environment. So every trace2 target the caller named wrote the token in the `start` event of each, one line per member. `scrub` kept it out of the output, and only that part held.

Now:
- **`rewrite_environment(token)`**, a new function in `siblings.py`:
  - with a token in use, it returns `untraced()` from the `bootstrap.py` beside it. That is every `GIT_TRACE*` variable taken out, and `GIT_TRACE2`, `GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF` set to `0`, which also turns off a target the global or the system config names;
  - with no token, it returns None, so the two gits keep the caller's environment and stay traced where the caller asked, as before.
- **The clone and the fetch** each run with `env=rewrite_environment(token)`. Nothing else in the file changes environment.
- **The member's own `scripts/bootstrap.py`**, which `place` runs after a clone, is given no token in its argv, and no `env`, so it keeps the caller's environment and its traces. That environment holds `SHAPE_LEGS_TOKEN` and `SHAPE_LEGS_APP_TOKEN` as before; the shipped member bootstrap (`templates/assembly-root/scripts/bootstrap.py`) reads neither. A test row holds this, and so does `tests/test_validator_child_encoding.py`, which reads that call as handing no `env`.
- **#1023's jobserver handling is untouched**: `dispatch` still runs each member's make with `member_make_environment(make)`, and no `MAKEFLAGS` reading changed.
- **The docstrings** now say the promise in full: `git_prefix` says the token is "never written into `.git/config`, never persisted, never printed, and never traced". `place`'s ABSENT paragraph in the module docstring says the same, and `rewrite_environment` says what was measured.

**One thing the issue did not ask for.** `GIT_TRACE_PERFORMANCE` writes a git's whole command line, `-c` included, on git 2.43.0 and 2.47.1 alike. So at main it held the token too, two lines per run, and the rows below set it. `untraced()` already takes it out: every `GIT_TRACE*` variable goes.

This follows #1045's `untraced()` (#996): the clone and the fetch run in the very environment `make bootstrap` gives every git it hands the token.

`siblings.py` imports `untraced` from the `bootstrap.py` beside it, next to `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path` and `members_of`.

The issue left this to the writer, because a holder may keep a `bootstrap.py` older than its `siblings.py` (`update-shape.py apply --accept-local`), and an import of a name that copy lacks would stop `make siblings`, `park` and `resume` with an `ImportError`. The issue's text was written against main `1a47ed07`, where `siblings.py` still kept its own copy of the `MAKEFLAGS` readings. Since then #1023 (`9e154d87`, lane 4's) has decided the question for this pair of files:
- it replaced that copy with an import of `member_make_environment`, because a copy in each script failed SonarCloud's duplication gate;
- it named the price, in the import's comment and its commit: a holder that keeps its own `bootstrap.py` while `apply` writes `siblings.py` is refused, `update-accept-local-missing-name` (#536, #604), before a byte is written, and merges `bootstrap.py` by hand first.

`untraced` takes the same road for the same reasons:
- **One rule.** Which traces a command carrying the token is kept out of has one answer, for `make bootstrap` and `make siblings` alike. A copy here could drift from `bootstrap.py`'s, and the drift would be a token in a file.
- **A kept old `bootstrap.py` is refused in the one-step case, and not in a two-step one.** The guard reads names. `untraced` is new with #996 (#1045), so a kept `bootstrap.py` from before it is refused by that name, where `--accept-local` names it in the same `apply`. Measured with `update-shape.py`'s own readers, in process:
  - `siblings.py` at this head takes `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path`, `members_of` and `untraced` from `bootstrap`;
  - the holder's `bootstrap.py` at main `00f6d7f7` lacks none of them;
  - the holder's `bootstrap.py` at `6476c170` (main just before #1045) lacks `untraced` alone, which is the line the refusal names.
- **The one-step case, measured end to end (rv1075).** A holder cut at `6476c170`, and `apply --at c3cf515 --accept-local scripts/bootstrap.py`: exit 2, `scripts/bootstrap.py (kept) lacks untraced, which scripts/siblings.py imports from it`, and the holder's files, HEAD and status unchanged.
- **Two applies in a row are not refused (rv1075's SHOULD 1, filed as #1082).** A holder whose earlier apply kept a `bootstrap.py` without `untraced` (rv1075 applied `--at 00f6d7f --accept-local scripts/bootstrap.py`: that target's `siblings.py` imports no `untraced`, so there was nothing to refuse), and then an apply at this head without `--accept-local`, gets `siblings.py` copied. `scripts/siblings.py` then fails with `ImportError: cannot import name 'untraced' from 'bootstrap'`, and so do `make siblings`, `park` and `resume`. The cause is the guard in `update-shape.py` (`:4288-4289`): it checks only the files `--accept-local` names in the same run. That is the gap of #536's guard, and it is on main already for #1023's `member_make_environment`; this change puts `untraced` into it and does not widen it. The text above this paragraph said no `ImportError` could reach a holder, which was too strong; #1082 is the follow-up, and it is not done here.
- **No copy, so no test to hold two copies to one answer.** `tests/test_update_shape_kept_helper.py`'s `test_what_siblings_takes_from_the_holders_bootstrap_is_found_defined` already holds that every name `siblings.py` takes is defined in the holder's `bootstrap.py`. Its docstring now says six names, not five.

**Setup:**
- git 2.43.0 (`/usr/bin/git`) and git 2.47.1, Python 3.12.3;
- the family of `tests/test_family.py` (two members, local bare remotes, the file transport allowed through `GIT_CONFIG_COUNT`), laid out as `<tmp>/InkRouter/InkRouter`;
- offline: a stub `gh` first on PATH, a bogus `GH_TOKEN`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`;
- the holder's own `scripts/siblings.py` was main's (sha256 `86bf57e0...`) or this change's;
- the fake token `w1055-a-token-no-forge-takes`.

**Each case was run three ways:**
- `make siblings`, which clones both members;
- `make siblings` again, which fetches both;
- `family.py siblings --family-root <holder>` in a fresh layout, which clones both.

**Traces were asked for in one of three ways:**
- the seven variables `GIT_TRACE`, `GIT_TRACE2`, `GIT_TRACE2_EVENT`, `GIT_TRACE2_PERF`, `GIT_TRACE_PACKET`, `GIT_TRACE_CURL` and `GIT_TRACE_PERFORMANCE`, each set to a file under the run's TMPDIR;
- `trace2.eventTarget`, `normalTarget` and `perfTarget` in a scratch `GIT_CONFIG_GLOBAL`;
- the same three in a scratch `GIT_CONFIG_SYSTEM`.

Each way was run with `SHAPE_LEGS_TOKEN`, with `SHAPE_LEGS_APP_TOKEN`, and with no token.

That is 108 runs at main and at the change, 54 for each git. Each run's TMPDIR and the whole family folder (the clones' `.git/config` included) were searched for the token.

| traces asked by | main, a token set (both variables, both gits) | this change, a token set | no token, either |
|---|---|---|---|
| the seven variables | `trace2-event.json`, `trace2-normal.txt`, `trace2-perf.txt`, `trace-performance.txt`: 2 lines each, in the clone, the fetch and `family.py` alike | none | no token anywhere; the same files written |
| the global config | `cfg-event.json`, `cfg-normal.txt`, `cfg-perf.txt`: 2 lines each | none | the same |
| the system config | `sys-event.json`, `sys-normal.txt`, `sys-perf.txt`: 2 lines each | none | the same |

**What the table rests on:**
- Every one of the 108 runs (and the 54 runs again on this head's final bytes, sha256 `b1e7bb21...`) exited 0 and ended `siblings ok: 2 member(s) beside the holder in ...`, with the token in neither stdout nor stderr.
- The 2 lines in each file are the `start` (or `performance:`) line of the git given the `-c`, one for each member. Each carries the token twice, once for each spelling the rewrite covers.
- With every member a local path, `GIT_TRACE`'s file was written and held no token, and `GIT_TRACE_CURL` wrote nothing, with no http remote. An https member is a different case, measured below.
- With the change and a token set:
  - every trace file but `GIT_TRACE_PACKET`'s was still written, by the run's other gits and the member's own bootstrap;
  - the packet trace was not written, since only the clone and the fetch write one here.

**An https remote, measured offline (rv1075, then the row of Round 2).** The rewrite reaches only `github.com`, and the probes above are offline, with local paths. A member whose `.gitmodules` url is `https://github.com/<org>/<repo>.git` is reached by it, and can be tried offline behind a dead proxy, `http.proxy=http://127.0.0.1:9`, so that nothing leaves the host:
- rv1075 set one member's url so, with `make siblings`, a fake `SHAPE_LEGS_TOKEN`, and the seven variables set to files, on git 2.43.0 and 2.47.1. Main's `siblings.py` (`86bf57e0`) held the token in `trace.txt`, `trace2-event.json`, `trace2-normal.txt`, `trace2-perf.txt` and `trace-performance.txt`; this head held it in no file and no output. The clone failed cleanly, `git clone exited 128`, and `make siblings` exited 2.
- `GIT_TRACE`'s file holds it too, in the `run_command: git remote-https origin https://x-access-token:<token>@github.com/...` lines: 3 lines on git 2.43.0, 4 on 2.47.1 (rv1075).
- Round 2 below holds this in a test row.

**Still not measured:** a github.com clone or fetch that succeeds, since no run here reaches the network.

`tests/test_family_siblings_untraced.py` is new. It uses `tests/test_family.py`'s `family` and `estate`, `offline` from `tests/test_family_add_refusals.py`, `files_holding` and `CREDENTIALS` from `tests/test_family_bootstrap_stops_named.py`, and `NO_CREDENTIAL` and `needs_make` from `tests/test_siblings_family_name.py`. No assert joins conditions with `and`. The token is the fake `w1055-a-token-no-forge-takes`.

**The rows.** Each entry point is crossed with each way traces are asked for:
- entry points: `make siblings` cloning, `make siblings` again fetching (after a first run with no token and no trace), and `family.py siblings` cloning;
- ways: the seven variables, the three targets in a scratch global config (a copy of the session's, which keeps git's automatic maintenance off), and the three targets in a scratch system config.

**Three tests** (the third is round 2's):
- **`test_with_a_token_in_use_the_token_is_in_no_trace`**: 18 rows (3 entries x 3 ways x 2 variables). Each asserts:
  - exit 0 and `siblings ok: 2 member(s) ...`;
  - the token in neither stdout nor stderr;
  - the token in no file under the test's directory: TMPDIR, every trace and config file, and the clones and their `.git/config`;
  - every trace file the way asks for written: 5 for the variables, 3 for each config;
  - the member's own bootstrap traced where a member was cloned: a git traced in a leg of each clone, which only that bootstrap runs. Where nothing was cloned, nothing ran there.
- **`test_with_no_token_the_clone_and_the_fetch_are_traced_as_asked`**: 9 controls (3 entries x 3 ways). Each asserts exit 0, `siblings ok` and the same trace files written. It also asserts the trace2 event file holds `siblings.py`'s own git at each member, read from the events: `git clone -q --no-local ...` to each member's slot, or `git fetch --quiet origin` in each member's clone.

**Fails on main.** The file was run against main's `siblings.py` (sha256 `86bf57e0...`, put in place and then restored, sha256 checked):
- **18 failed, 9 passed**: every row with a token failed, and the 9 controls passed;
- the variables rows found the token in `trace-performance.txt`, `trace2-event.json`, `trace2-normal.txt` and `trace2-perf.txt`;
- the global-config rows found it in `cfg-event.json`, `cfg-normal.txt` and `cfg-perf.txt`;
- the system-config rows found it in `sys-event.json`, `sys-normal.txt` and `sys-perf.txt`.

- **`test_an_https_member_with_a_token_in_use_is_in_no_trace`**: 3 rows, one for each of `CALLERS_CONFIG` (round 2 and round 3, below).

Each mutant was applied alone to the worktree's file and the new test file run (git 2.43.0). The bytes were restored and checked by sha256 after each:
- `siblings.py` sha256 `b776c69b...`: this head's code; it differs from the head only in two docstring lines;
- `bootstrap.py` sha256 `50a16751...`, main's, unchanged here.

All are **killed**.

| mutant | result |
|---|---|
| M1 the token condition dropped: `untraced()`'s environment always | killed, 9 failed (every control) |
| M1b the condition inverted | killed, 27 failed |
| M1c the clone untraced always, the fetch as it is | killed, 6 failed (the clone controls) |
| M2 the clone left in the caller's environment | killed, 12 failed (every clone row with a token) |
| M3 the fetch left in the caller's environment | killed, 6 failed (every fetch row with a token) |
| M4a `GIT_TRACE2_EVENT` not set to `0` (`TRACE2_OFF`) | killed, 12 failed (the global- and system-config rows) |
| M4b `GIT_TRACE2` not set to `0` | killed, 12 failed (the same) |
| M4c `GIT_TRACE2_PERF` not set to `0` | killed, 12 failed (the same) |
| M5 the `GIT_TRACE*` removal dropped (`env = dict(os.environ)`) | killed, 6 failed (the variables rows, by `GIT_TRACE_PERFORMANCE`) |
| M6 the prefix narrowed to `GIT_TRACE2` | killed, 6 failed (the same) |
| M7 the member's own bootstrap run untraced too | killed, 12 failed (the clone rows' bootstrap check) |

M5 and M6 are killed only because the variables rows set `GIT_TRACE_PERFORMANCE`. With a local-path remote, of the issue's six variables none but trace2's writes the `-c`, and trace2's three are set to `0` whatever the prefix does. With an https remote `GIT_TRACE` writes it too, in the `run_command: git remote-https` lines; that is what the row of Round 2 holds, and the mutant R4 below, which keeps the caller's `GIT_TRACE`, passed all 27 earlier rows.

**How every run was made:**
- through lane 5's runner: `/usr/bin/python3 -m pytest -q -rs -p no:cacheprovider -o tmp_path_retention_policy=failed`, one basetemp per run;
- `LANES_*` cleared, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`;
- `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null` at the start of the session.

| git | files | result |
|---|---|---|
| 2.43.0 | `test_family_siblings_untraced.py`, on this change before the docstring fix | 27 passed |
| 2.43.0 | the same file against main's `siblings.py` | 18 failed, 9 passed |
| 2.43.0 | gate shard A: 19 of the 41 files below | 1623 passed, 4 skipped, in 19:51 |
| 2.43.0 | gate shard B: the other 22 of the 41 files | 2415 passed, 8 skipped, in 15:03 |
| 2.47.1 | `test_family_siblings_untraced.py` | 27 passed, in 1:14 |

**What the 41 gate files are:**
- the new file;
- `test_family_member_make_jobserver.py`;
- every file `grep -l siblings tests/` names;
- the pinned-copy and upgrade-row files: `test_pinned_copy_text.py`, `test_update_shape.py`, `test_update_shape_add.py`, `test_update_shape_kept_helper.py`, `test_no_auto_maintenance.py`, `test_template_text_placeholders.py`, `test_bootstrap_spelling.py`;
- the trio: `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`.

**The gate runs** (both shards at once, then the 2.47.1 run) were made on this change as it is committed: `siblings.py` sha256 `b1e7bb21...` and the new test file sha256 `7ba7a416...`, at main `00f6d7f7` (this branch's base; no rebase was needed, see Merges). The runner's `START` line read `dirty=2`, the two tracked files this commit changes, and a sha256 check after the runs found `siblings.py` unchanged (neither it nor the test files were modified after the runs started). The first pair of shards was stopped part way and re-run whole; only the re-runs count here.

**The 41 files, by shard:**
- shard A (19): `test_shape_doctor.py`, `test_validator_child_encoding.py`, `test_family_py_unreadable.py`, `test_pinned_copy_text.py`, `test_siblings_inputs.py`, `test_siblings_family_name.py`, `test_family_member_make_jobserver.py`, `test_update_shape_add.py`, `test_adopt_printed_sentences.py`, `test_family_printed_quoting.py`, `test_family_makefile_siblings.py`, `test_template_text_placeholders.py`, `test_park_resume_targets.py`, `test_windows_paths.py`, `test_doctor_gitmodules_not_utf8.py`, `test_local_remote_dir_urls.py`, `test_family_siblings_untraced.py`, `test_setup_family_folder.py`, `test_bootstrap_refusal_exit.py`;
- shard B (22): `test_repo_hygiene.py`, `test_doctor_legs_members_next.py`, `test_update_shape.py`, `test_family.py`, `test_update_shape_kept_helper.py`, `test_yaml_reader_spaces.py`, `test_bootstrap_spelling.py`, `test_agents_md_codes.py`, `test_family_siblings_placement.py`, `test_no_auto_maintenance.py`, `test_cli_reference.py`, `test_family_siblings_bootstrap_unreadable.py`, `test_bootstrap_branch_bytes.py`, `test_family_init_asks_every_value.py`, `test_validate_workflow_gitmodules_urls.py`, `test_adopt_contracts_dir.py`, `test_doctor_unfinished_merge.py`, `test_family_members_not_a_list.py`, `test_family_init_landing.py`, `test_family_docs_one_spelling.py`, `test_doctor_members_spelled.py`, `test_pins_child_kind_and_siblings_root_spelling.py`.

**The skips are the environment's, each named by its reason:**
- shard A: 3 in `test_shape_doctor.py` (the PowerShell line is printed, and `powershell.exe` exists, only on a Windows host) and 1 in `test_family_member_make_jobserver.py` (`make -j2` here writes `--jobserver-auth=3,4`, not GNU make 4.4's named pipe);
- shard B: 2 in `test_doctor_legs_members_next.py` (this git crashes on `submodule init`, exit 139, as git 2.43 does on a bare `branch`), 4 in the same file (this git dies on a `shallow` of -2**31, as git 2.43 does), and 2 in `test_no_auto_maintenance.py` (the session began with `GIT_CONFIG_GLOBAL` set, and its file includes that one);
- the 2.47.1 run of the new file skipped none.

The full suite was not run.

**Static checks:**
- flake8 finds in `siblings.py` only the three lines it finds at main (two E501, one E127, each moved down by the added lines);
- in the new test file it finds only the F811s every fixture-importing file has;
- both files are ASCII, apart from the dashes and the CJK example already in `siblings.py`.

Fetched with `-c gc.auto=0 -c maintenance.auto=false`, on 2026-10-20 at about 21:03Z. Round 1, `git merge-tree --write-tree` of the first head `c3cf5154` against (round 2's are in its own section):
- **origin/main `6c861635`**: clean, exit 0, tree `225b2a99`.
  - Main has moved two commits past this branch's base `00f6d7f7`: `9cbba9d5` (#1063: `scripts/family.py`, `scripts/shape_materialize.py`, `docs/cli.md` and two tests) and `6c861635` (#1065: `adopt-project.py` and three tests).
  - Neither touches `siblings.py`, `bootstrap.py`, `tests/test_update_shape_kept_helper.py` or the new test file, so there was no rebase and the gate runs above stand.
- **#1062** (`fix/1040-windows-start-errors`, head `957ba038`), the one open PR that touches a family-root script this change reads (`templates/family-root/scripts/bootstrap.py`, with the assembly root's and `validate-family.py`, `validate-pins.py` and three tests): clean, exit 0, tree `84d4b54a`.
  - Its diff does not touch `untraced()`.
  - In the merged tree `bootstrap.py` still defines `untraced` (and the five other names `siblings.py` takes from it), and `siblings.py` is byte-identical to this head's (sha256 `b1e7bb21...`).
- **The other 9 open PRs** (#1073, #1072, #1071, #1070, #1069, #1068, #1067, #1064, #1031): none touches `siblings.py`, `tests/test_update_shape_kept_helper.py`, or a file the new test imports from (`conftest.py`, `test_family.py`, `test_family_add_refusals.py`, `test_family_bootstrap_stops_named.py`, `test_siblings_family_name.py`), read from `gh pr list --state open --json number,headRefName,headRefOid,files`.

`templates/family-root/scripts/siblings.py` is in `FAMILY_COPIED_VERBATIM`, so each holder gets one `upstream-changed` row for it, as #1045 gave one for `bootstrap.py`:
- a holder that takes it with `bootstrap.py` from #1045 or later needs nothing more;
- a holder that keeps an older `bootstrap.py` with `--accept-local` in the same apply is refused, `update-accept-local-missing-name`, naming `untraced`, before anything is written;
- a holder whose earlier apply kept a `bootstrap.py` without `untraced`, and that then applies this change without `--accept-local`, is not refused: `siblings.py` is copied, and `make siblings`, `park` and `resume` meet an `ImportError`. That is the gap of #536's guard, which `member_make_environment` has on main already, and it is #1082's.

No Windows or macOS run was made, since CI is Linux only (#597).

rv1075, lane 5's independent reviewer, found `c3cf5154` READY (#1075 issuecomment-6302090874): no MUST, two SHOULDs and a NIT. One commit on top of it, `041f7831`, with no rebase: main has touched none of this PR's files.

**The three findings:**
- **SHOULD 1** (the body said no `ImportError` reaches a holder, and two applies in a row make one): answered in the body above, in "Where the helper comes from" and "The pinned copy". The gap is #536's guard's, it is on main for `member_make_environment` already, and it is #1082's; this PR does not change `update-shape.py`.
- **SHOULD 2** (every row used a local file remote, so a leak through `GIT_TRACE` on an https member had no test, and rv1075's mutant R4 passed all 27 rows): answered by the new row below.
- **NIT** (three places said the member's own bootstrap "is given no token"): `siblings.py`'s module docstring, `rewrite_environment`'s docstring and the test file's docstring now say it is given no token in its argv. It inherits `SHAPE_LEGS_TOKEN` and `SHAPE_LEGS_APP_TOKEN` in its environment, because `run_member_bootstrap` passes no `env`, and the shipped one reads neither. `rewrite_environment`'s docstring also names the https measurement.

**The code of `siblings.py` is unchanged.** Its syntax tree with the docstrings taken out is the same at `c3cf5154` and at `041f7831` (compared with `ast`); the file's sha256 moved from `b1e7bb21...` to `19107ad4...` over comments and docstrings only. So round 1's two gate shards (1623 passed, 4 skipped; 2415 passed, 8 skipped) were not re-run; the targeted runs below are.

**The new row,** `test_an_https_member_with_a_token_in_use_is_in_no_trace` (the file has 28 rows now). Its setup:
- the estate of `tests/test_family.py`, with `IRRS`'s url in the holder's `.gitmodules` given as `https://github.com/InkRouter/IRRS.git`, and `IRSS` still a local path;
- `http.proxy=http://127.0.0.1:9`, a dead proxy, in git's command scope after the entries the session holds, and `NO_PROXY` and `no_proxy` empty, so nothing leaves the host;
- a fake `SHAPE_LEGS_TOKEN` and the seven trace variables set to files;
- `make siblings`, cloning.

It asserts:
- the proxy is set where the row sets it: `http.proxy=http://127.0.0.1:9` is in the command scope the run's git reads (`command_scope_at`, which runs `git config --show-origin --list` and writes nothing);
- exit 2 and `IRRS: git clone exited 128:` on stderr, which is how the head fails (rv1075 measured the same);
- `IRSS` was cloned and `IRRS` was not;
- the token is in neither stdout nor stderr, and in no file under the test's directory;
- the five trace files the variables ask for are written, by the run's other gits.

No assert names the proxy in git's message. The git 2.47.1 this lane builds has no `git-remote-https` (`git: 'remote-https' is not a git command`, `fatal: remote helper 'https' aborted session`), so there the clone fails at once without reaching the proxy, still with 128. `GIT_TRACE` has written the `run_command: git remote-https origin https://x-access-token:<token>@github.com/...` line before that, so the row finds the leak on both gits. A first local version asserted the proxy's message and failed on 2.47.1 for that reason; the assert was dropped, and the commit amended, before anything was pushed.

**It fails on main.** The file was run against main's `siblings.py` (sha256 `86bf57e0...`, put in place and then restored, sha256 checked each time):
- git 2.43.0, the whole file: **19 failed, 9 passed**, the 18 rows of round 1 and the new row. The new row fails at `assert files_holding(tmp_path, TOKEN) == []`, with 5 files holding the token: `temp/trace-performance.txt`, `temp/trace.txt`, `temp/trace2-event.json`, `temp/trace2-normal.txt` and `temp/trace2-perf.txt`. The round 1 rows find 4 of those, since a local path leaves `temp/trace.txt` clean;
- git 2.47.1, the row alone: **1 failed**, the same 5 files.

**It kills R4.** R4 is rv1075's mutant, in `rewrite_environment`: `return {**untraced(), **{k: v for k, v in os.environ.items() if k == "GIT_TRACE"}} if token else None`, which keeps the caller's `GIT_TRACE` for the clone and the fetch. Applied alone, then restored and sha256-checked:
- git 2.43.0, the whole file: **1 failed, 27 passed**: the new row alone, with `temp/trace.txt` the one file holding the token;
- git 2.47.1, the row alone: **1 failed**, the same one file.

**The runs on the pushed head `041f7831`** (the runner's `START` line read `dirty=0`), through the same runner as round 1:

| git | files | result |
|---|---|---|
| 2.43.0 | the new file, `test_update_shape_kept_helper.py`, `test_family_makefile_siblings.py`, `test_family_member_make_jobserver.py`, `test_pinned_copy_text.py`, and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 589 passed, 1 skipped (the jobserver file's named-pipe row, as in round 1), in 5:09 |
| 2.47.1 (`git version 2.47.1`, printed from the exact path first on PATH) | the new file | 28 passed, in 2:13 |
| 2.43.0 | the new file against main's `siblings.py` | 19 failed, 9 passed |
| 2.47.1 | the new row against main's `siblings.py` | 1 failed |
| 2.43.0 | the new file with R4 | 1 failed, 27 passed |
| 2.47.1 | the new row with R4 | 1 failed |
| 2.43.0 | on a predicted merge with main (below): the new file, `test_update_shape_kept_helper.py`, `test_pinned_copy_text.py`, `test_family_makefile_siblings.py` | 305 passed, in 2:50 |

The full suite was not run. flake8 finds in `siblings.py` the same three lines as at round 1, and in the test file only F811s, from the fixtures it imports. Both files are ASCII, apart from the dashes and the CJK example already in `siblings.py`.

**The merges of round 2.** Fetched with `-c gc.auto=0 -c maintenance.auto=false` at about 23:00Z; `git merge-tree --write-tree` of `041f7831` against:
- **origin/main `c13c3985`** (#1076's squash, which touches `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py`, among others): clean, exit 0, tree `8ae10d0c`. None of the three files this PR changes is among the files main changed since the base `00f6d7f7`, so there is no rebase. In that tree `siblings.py` is byte-identical to this head's, `untraced()` has the same syntax tree as at the base, and `CREDENTIALS` and `files_holding`, which the new file imports from `test_family_bootstrap_stops_named.py`, are still defined. The 305-passed run above is on that tree;
- **#1062** (`fix/1040-windows-start-errors`, head `957ba038`): clean, exit 0, tree `e6ed1ba2`. Its `untraced()` has the same syntax tree as main's;
- **the other open PRs** (#1031, #1069, #1070, #1072, #1073, #1081, #1084): none touches `siblings.py`, `tests/test_update_shape_kept_helper.py`, `bootstrap.py`, or a file the new test imports from, read from `gh pr list --state open --json number,headRefName,headRefOid,files` at about 23:00Z. #1076 (`fix/1056-signal-anywhere`, head `73906948`) was open when the runs were made, and merged clean with this head then (tree `6ae3e65f`); it has landed since as `c13c3985`, above.

**The failure.** Lane 5's landing gate (log `c1075.log`, #1075 issuecomment-6304830183) failed the https row of round 2 at `041f7831`: 1 failed, 356 passed. The change in `siblings.py` was not at fault; the row depended on the caller's git config.
- The gate ran with neither `GIT_CONFIG_GLOBAL=/dev/null` nor `GIT_SSH_COMMAND=false`, which the writers' runs and round 2's runs had.
- `conftest.py`'s session global file includes the caller's global config (or `~/.gitconfig`, where `GIT_CONFIG_GLOBAL` is unset). The operator's `~/.gitconfig` has `url.git@github.com:.insteadOf=https://github.com/`.
- Git keeps the first of two `insteadOf` prefixes of the same length, and the config files are read before the command line, so that rewrite beat the token's `-c` rewrite of the same prefix. Measured with scratch files and a stand-in token, on git 2.43.0: under a global file with that rewrite, `git -c url.https://x-access-token:T@github.com/.insteadOf=https://github.com/ ls-remote --get-url https://github.com/InkRouter/IRRS.git` printed `git@github.com:InkRouter/IRRS.git`.
- So the row's `https://github.com/InkRouter/IRRS.git` went over ssh to `InkRouter/IRRS`, a real private repository. The dead proxy never applied, the clone succeeded, `make siblings` exited 0 and the placed members were both there, and the row failed `assert result.returncode == 2`.

**The fix** is in `tests/test_family_siblings_untraced.py` only. No other file changed in this round, and `siblings.py` is byte-identical to `041f7831`'s (sha256 `19107ad4...`).
- **`hermetic_config`** gives the https row's git a global file of its own, `session_global_config("")` (`conftest.py`'s own function: automatic maintenance off, and no include of the caller's file), `GIT_CONFIG_NOSYSTEM=1` and `GIT_SSH_COMMAND=false`.
  - It is the least invasive way: only the one row with an https url sets it, `conftest.py` and every other row stay as they were, and the other rows' remotes are local paths, which no rewrite of GitHub's url reaches.
  - `GIT_CONFIG_GLOBAL` set takes git's two user-level files out of the reading, and `GIT_CONFIG_NOSYSTEM` the system file whatever `GIT_CONFIG_SYSTEM` says (checked on git 2.43.0 and 2.47.1 with scratch files).
  - `GIT_SSH_COMMAND=false` is a second wall: a url somehow sent over ssh runs `false`. No mutant can show it needed, since the runner exports it too.
- **`url_after_config`** is a new assert before the clone: `git ls-remote --get-url <url>` (it applies the config's `insteadOf` and asks nothing of the network) must give the url unrewritten under the row's environment. A config that reaches the row now fails by name, before any clone.
- **The row is parametrized by `CALLERS_CONFIG`**: `none` (round 2's row), and `global` and `system`, each a stand-in for a caller's config that would send the url somewhere reachable. `as_the_callers_config` writes a scratch file of the test's own, `url.<file URI of the family's own IRRS remote>.insteadOf=https://github.com/InkRouter/IRRS.git`, into this process's `GIT_CONFIG_GLOBAL` (the session file includes it) or `GIT_CONFIG_SYSTEM`. It is built from tmp files only: no network, and never the real `~/.gitconfig`. The file now has 30 rows.
- With the fix, in each of the three, the clone fails at the proxy with git's 128, `make siblings` exits 2, `IRSS` is placed and `IRRS` is not, and the token is in no output and in no file under tmp_path.

**The variants fail without the fix.** Each was applied alone to the worktree's test file (or `siblings.py`) and run through the runner, with the file's sha256 checked after it was restored:
- **`041f7831`'s file as pushed, under a caller's global config.** Lane 5's runner now honours `L5_GIT_CONFIG_GLOBAL=<scratch file>` as the caller's global config, with ssh impossible. The scratch file rewrote `https://github.com/InkRouter/IRRS.git` to the run's own IRRS remote (a `file://` url under the run's basetemp). The whole file, git 2.43.0: **1 failed, 27 passed**, the https row at `assert 0 == 2`, the gate's failure reproduced with no network.
- **T2, `041f7831`'s row plus the variants:** the new file with `hermetic_config` and the `url_after_config` assert taken out. The https rows, git 2.43.0: **2 failed, 1 passed**, `[global]` and `[system]` at `assert 0 == 2` (the clone went to the local remote and exited 0), and `[none]` passes. On git 2.47.1 the same.
- **T1,** the row's global file dropped and the assert kept: `[global]` fails at the assert, which sees `file:///...IRRS.git` for the url; the other two pass.
- **T3,** `GIT_CONFIG_NOSYSTEM` dropped and the assert kept: `[system]` fails at the assert; the other two pass.

**R4 stays killed.** rv1075's R4 (`rewrite_environment` keeps the caller's `GIT_TRACE`), git 2.43.0, the whole file: **3 failed, 27 passed**, the three https rows.

**The runs on the pushed head `962ae43c`** (`START` read `dirty=0`), through the same runner as before:

| git | files | result |
|---|---|---|
| 2.43.0 | the new file, `test_update_shape_kept_helper.py`, and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 359 passed, in 3:05 |
| 2.47.1 (`git version 2.47.1`, printed from the exact path first on PATH) | the new file | 30 passed, in 1:51 |
| 2.43.0 | the new file, with `L5_GIT_CONFIG_GLOBAL` naming that scratch file | 30 passed, in 2:21 |
| 2.43.0 | `041f7831`'s file, with the same | 1 failed, 27 passed |
| 2.43.0 / 2.47.1 | T2, the https rows | 2 failed, 1 passed / 2 failed, 1 passed |
| 2.43.0 | T1, T3, the https rows | 1 failed, 2 passed each |
| 2.43.0 | R4, the whole file | 3 failed, 27 passed |

The full suite was not run. flake8 finds in the test file only F811s from the fixtures it imports, and nothing over 79 columns; the file is ASCII.

**The merge-tree of round 3.** Fetched with `-c gc.auto=0 -c maintenance.auto=false` at about 10:53Z; `git merge-tree --write-tree` of `962ae43c` against:
- **origin/main `2a119f28`** (#1092): clean, exit 0, tree `b5ea0114`. Main has landed #1076 (`c13c3985`) and #1062 (`ff0c72eb`) since the last round, and has changed `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py` among others, but none of this PR's three files. In that tree `siblings.py` is byte-identical to this head's, `untraced()` has the same syntax tree as at the base `00f6d7f7`, and `CREDENTIALS` and `files_holding` are still defined where the new file imports them from.
- **#1088** (`fix/1085-nit-texts`, head `88af1b7b`), which touches the same two files of main's: clean, exit 0, tree `365df092`, with the same findings.
- **The other open PRs** (#1087, #1090, #1091, #1097): none touches `siblings.py`, `bootstrap.py`, `tests/test_update_shape_kept_helper.py` or a file the new test imports from, read from `gh pr list --state open --json number,headRefName,headRefOid,files` at about 10:53Z.

Lane: openreposhape-5 (openRepoShape-5)

Review: rv1075, lane 5's independent reviewer, not the writers (w1055 and f1055): READY at `c3cf5154` (issuecomment-6302090874) with no MUST; its two SHOULDs and its NIT were taken in one new commit, and the re-check is READY at `041f7831` (issuecomment-6303900180), with no MUST or SHOULD; its one NIT, a merge-tree number in the body, is fixed in the body. CI (Brett Heap's ruling 27): H0 is the head 962ae43 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 00f6d7f; and the local gate passed on its predicted squash tree fc2afb3 over main 16f9a0e: the family_siblings_untraced, update_shape_kept_helper, repo_hygiene, agents_md_codes, cli_reference sets, 359 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #1055

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 21, 2026
… holds without its commit is one it cannot answer for, `check` says a commit it cannot ask was not asked, and a pinned product YAML 1.1 or 1.2 reads as no text is quoted (#912) (#1090)

Each pin question is answered from the commit actually pinned, with words that say why, and a pinned product is written so that any YAML reader reads it back as text. This is #912, which groups three lines of #457's release-commit triage: L1-81, L1-82's second case, and L2-14.

One commit, on main `5e539a87`. It changes `scripts/shape_materialize.py`, `adopt-project.py` and `scripts/bump-leg.py`, and six test files.

The issue's line numbers are at `5017830`. Each claim was measured again by name, on main `6c861635` first and again on `5e539a87`:

- **L1-81 held.** `commit_in_clone` (`scripts/shape_materialize.py:3650` at `6c861635`, `:3809` at `5e539a87`) asks `rev-parse -q --verify <id>^{}` of an annotated tag. In a clone that holds the tag but not its commit, git answers `error: missing object referenced by '<tag>'` (git 2.43 and 2.47.1), and the id was refused as `is an annotated tag that names no commit`. `bump-leg.py`'s `peeled_target` (`:972`, now `:977`) did the same.
- **L1-82's second case held.** `_recomputed_pin_finding` (`adopt-project.py:872`, now `:871`) asks a recorded `commit:` what it names only of a recorded clone that is still at its path and is no partial clone. Where the clone is gone or partial, or `gh api` read the digest, `check` exits 0 on a tag's id with a NOTE that names only the digest.
- **L2-14 held, in a new place.** `_neutral_pin_entry` is no longer `adopt-project.py`'s own. Since #924 both tools call `shape_materialize.neutral_pin_entry` (`:524`), and it still asked only the project's reader. Its docstring even said that a bare `yes`, `on`, `.5` or `.inf` stays bare, naming this issue.
- **Main moved under the change** while it was being tested. The commit was rebased from `6c861635` onto `5e539a87` with no conflict, and its patch is the same (`git patch-id --stable` `427e9e35fd2464cb` both times):
  - #1068 (`6797ca70`) changes `bump-leg.py`'s `_leg_fetch_refspecs` and `fetch_leg`'s docstring, and moves a refspec helper into `shape_materialize.py`. It does not touch `peeled_target`, and tag following under `refs/tags/`, which the new bump row relies on, is unchanged.
  - #1064 (`5e539a87`) changes `shape_materialize.py`'s manifest readers and `adopt-project.py`'s `_manifest_findings` and one import. It does not touch `commit_in_clone`, `neutral_pin_entry` or `_pin_digest_findings`.
  - Every row and every mutant below was run again on the rebased head, and the new rows were run again on `5e539a87`'s code.

### L1-81: a tag whose commit the clone lacks is a clone that cannot answer

- `commit_in_clone` returns `(oid, "")` where a tag's peel cannot be read (`names is None`), as it already does for an id the clone does not hold. `plan` and the scaffold then read the digest there. `git ls-tree` finds no tree at the id, so they refuse it `pin-source-unreadable`, whose remediation says to point `--pin-source` at a clone that HAS the pinned commit, "`git fetch --all` first". That is what main said before #651. `check` says `plan-pin-source-unreadable`.
- `peeled_target` returns the id as typed there. The reachability verdict answers it, as it answers an id the leg does not hold: `bump-leg-commit-not-on-remote`, "<repository> has no branch containing <id>", from both runs.
- A tag of a tree or a blob, which the clone can peel, is still `pin-not-a-commit` or `bump-leg-target-not-a-commit` by name.

### L1-82: `check` says it did not ask a recorded commit it could not ask

- The forge is not asked. `check` and `execute` read no network for a pin (#273's review S3, #449), `execute --local-remote-dir` needs no `gh`, and the rows that hold that (`gh` never run) stay. In a partial clone, git would fetch an id it lacks from the promisor remote.
- So the NOTE says it, in the words the issue's second option asks for. It was:
  `NOTE <entry>: digest NOT rechecked here: <why>. The root's own `validate-pins.py` recomputes it wherever it can read the product.`
  It is now:
  `NOTE <entry>: digest NOT rechecked here, nor its `commit:` asked whether it names a commit: <why>. The root's own `validate-pins.py` recomputes the digest wherever it can read the product; `plan` asks what the commit names where it reads the digest.`
- `<why>` is unchanged for each of the four cases: gone, partial, `gh api`, and a `digest_source:` that names no clone. No refusal code changes, so `AGENTS.md`'s table is unchanged. README, `docs/` and the handbook quote neither sentence.

### L2-14: a product YAML 1.1 or 1.2 reads as no text is double-quoted

- New `shape_materialize.YAML_NOT_TEXT_RE`. Of the plain scalars `PLAIN_SCALAR_RE` takes, which begin with a letter, `_` or `.`, it matches the ones YAML 1.1 or YAML 1.2's core schema resolves to a bool, a null or a float:
  - YAML 1.1's bools `y`, `n`, `yes`, `no`, `on` and `off`, and both versions' `true` and `false`, in their three cases;
  - `null` in its three cases;
  - `.inf` and `.nan` in their three cases;
  - a point before digits, as YAML 1.1's pattern is written (`\.[0-9.]*` with a signed exponent, so `.`, `..` and `.5.5` too);
  - and as its own example `685.230_15e+03` and its readers read it, with `_` among the digits (PyYAML takes `.5_` for 0.5, ruamel.yaml takes `._` for a float), with YAML 1.2's unsigned exponent `.5e3`.
- Nothing else either version resolves to no text is a plain scalar `PLAIN_SCALAR_RE` takes: `~`, the empty null, `<<`, `=`, and ints, timestamps and floats that begin with a digit or a sign.
- `neutral_pin_entry` writes a name bare only where it is a plain scalar, the new rule does not match it, and the project's reader reads it back. So both tools double-quote these names. Every other name is the line it was, `openGlass` byte for byte.
- The plan's `product:`, which `y` writes and only this tool reads, is unchanged. The pin file's `product:` was always double-quoted.

The probe is `probe912.py` (not committed). It runs with a stub `gh` that exits 97 first on PATH, a bogus token, an empty `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_SSH_COMMAND=false`. The `lacking` clone holds the tags `v1` and `v1-again` but not their commit.

| Case | main `6c861635` and `5e539a87` | This head |
| --- | --- | --- |
| `commit_in_clone(lacking, <tag>)` | `(<tag>, 'is an annotated tag that names no commit')` | `(<tag>, '')` |
| `peeled_target(lacking, <tag>)` | `REFUSED bump-leg-target-not-a-commit: --to <tag> is an annotated tag that names no commit in the leg` | returns `<tag>` |
| scaffold `--dry-run` and `plan`, `--pin openGlass@<tag> --pin-source lacking` | exit 2 `REFUSED pin-not-a-commit: ... is an annotated tag that names no commit in the clone at <lacking>, not a commit` | exit 2 `REFUSED pin-source-unreadable: <lacking> could not answer for opensoft/openGlass @ <tag>: ... exited 128: fatal: not a tree object`, remediation "`git fetch --all` first" |
| `check`, recorded clone `lacking` | exit 1 `FINDING plan-pin-not-a-commit: ... which is an annotated tag that names no commit` | exit 1 `FINDING plan-pin-source-unreadable: ... cannot answer for it` |
| `check`, a tag's id, clone whole (control) | exit 1 `plan-pin-not-a-commit`, "an annotated tag of commit <c>" | the same |
| `check`, a tag's id, clone partial / gone / `gh api` / none named | exit 0, NOTE names only the digest | exit 0, the NOTE above; `gh` never ran |
| names `--pin` admits of up to three characters (278915) | 231714 written bare; PyYAML misreads 132 of them, ruamel.yaml 0.19 (YAML 1.2) 132 | 231543 bare; 0 misread by either |

#457 counted 138 at `3dd806f`. On `6c861635`, PyYAML misread `yes`, `no`, `on` and `off` in three cases each, `.0` to `.99`, and `.0_` to `.9_`. ruamel.yaml misread the dot forms, `._`, `._0` to `._9` and `.__`, and its YAML 1.1 mode also reads `y` and `n` as bools.

**Found while testing.** git 2.43's `fetch` follows a remote tag whose object is already in the store, and brings the tag's commit with it. Its tag-following check counts the tag object itself, not only the commit. So in `bump-leg.py` a tag that the leg holds without its commit is a tag the leg's remote does not advertise, or one met after a fetch that failed. The bump row uses a tag that was never pushed.

New rows, in each file's style and with its helpers:

- `tests/test_pin_annotated_tag.py`:
  - a `lacking` fixture (`git hash-object -t tag -w` of the tags' bytes);
  - `commit_in_clone` answers `(id, "")` (2 rows);
  - `plan` and the scaffold's `--dry-run` refuse `pin-source-unreadable` with no "annotated tag" in the output (4);
  - `check` finds `plan-pin-source-unreadable` and `execute` refuses before any leg (1);
  - the NOTE for a tag's id, clone gone, partial or `gh api`, with `gh` never run (3).
- `tests/test_bump_leg_dry_run_truth.py`: a tag the leg holds without its commit is `bump-leg-commit-not-on-remote` in both runs, with nothing written, through `--local-remote-dir` and `origin` (2).
- `tests/test_neutral_pin_entry_one_spelling.py`:
  - each of 25 YAML 1.1 and 1.2 words and 20 dot floats is double-quoted and reads back (45);
  - the same 45, with the project's reader stubbed to read everything back, are double-quoted by the rule alone (45);
  - 20 look-alikes (`openGlass`, `openYes`, `yEs`, `.e5`, `._.` ...) stay bare (20);
  - the whole enumeration, read by PyYAML, ruamel.yaml 1.2 and ruamel.yaml 1.1 where they are installed (1; skipped without either reader, so CI holds the rule by the rows above).
- Changed rows, whose old expectation was the bug:
  - `SPELLED["yes"]`, and the entry-equals-adopt's-old-spelling row, which now expects exactly `.`, `..`, `.0`, `.9`, `._` and `yes` to differ;
  - `.5`, `.inf`, `yes` and `off` move out of the bare groups in `test_scaffold_pin_and_reuse.py` and `test_adopt_pins.py` into `STOCK_MISREAD`;
  - the NOT-rechecked constants in `test_adopt_pins.py`, and one line in `test_pin_source_top_level.py`.

No assert joins conditions with `and` or `or`.

**The rows fail on main.** The six test files were run on main's code, the scripts as on main with the new tests beside them:

- on `6c861635`: 96 failed, 98 passed, and 16 failed, 232 passed;
- on `5e539a87`: 112 failed, 330 passed.

Every failure is a new or changed row:

- spelling 85;
- tag file 10;
- top-level partial-clone NOTE 1;
- `test_adopt_pins.py` 10;
- bump 2;
- scaffold entry 4.

Each mutant was applied alone to a scratch clone of the head and its rows were run. The results were the same on `6c861635` and on the rebased head.

| Mutant | Result |
| --- | --- |
| M1 `commit_in_clone` without the `names is None` return | killed, 3 failed |
| M2 `peeled_target` without it | killed, 2 failed |
| M3 `if not peeled` in place of `if names is None` | survives: equivalent. The two differ only where `rev-parse <id>^{}` prints an id and `cat-file -t` of that id fails, and rev-parse parsed that object to print it. |
| M4 the NOTE's old words | killed, 3 failed |
| M5 `neutral_pin_entry` without the rule | killed, 85 failed |
| M6 rule without `y`/`n` | killed, 9 |
| M7 without `yes`/`no`/`on`/`off` | killed, 28 |
| M8 without `true`/`false`/`null` | killed, 9 (the reader-stubbed rows) |
| M9 without `.inf`/`.nan` | killed, 12 |
| M10 without YAML 1.1's pattern as written | killed, 9 |
| M11 without the `_`-digits pattern | killed, 14 |
| M12 that pattern's exponent signed only | killed, 4 |
| M13 no end anchor | killed, 15 |

All runs go through lane 5's `run9.sh`: a stub `gh` first on PATH, a bogus token, every `LANES_*` variable cleared, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, and one basetemp per run.

- **The six test files on the head `d5389eb6a71e`:**
  - git 2.43: 442 passed;
  - git 2.47.1 (first on PATH): 442 passed.
  - They also passed on `6c861635` before the rebase: 442 on 2.47.1, and on 2.43 in groups of 115, 31, 24, 149 and 123.
- **git 2.43, every other test file that runs a changed script.** These are the files that name `adopt-project`, `ADOPT`, `shape_materialize`, `bump-leg` or `BUMP`, plus `test_shape_doctor.py`, `test_scaffold_leg_path_collisions.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`), less the six. That is 177 test files in two shards, 179 paths with `tests/conftest.py` and `tests/gh_guard.py`:
  - shard a, 88 files: 6371 passed, 33 skipped (`END 2026-10-20T23:52:07Z rc=0`);
  - shard b, 91 files: 6296 passed, 3 skipped (`END 2026-10-20T23:51:58Z rc=0`).

`git merge-tree --write-tree` of the head against origin/main and against each open PR that touches a file this changes. Each merge is clean, and no PR's added lines quote the changed words or name the changed functions:

| Against | Head | Result |
| --- | --- | --- |
| origin/main | `5e539a878fc9` | clean, tree `95f9b53262fc` (the head's own tree) |
| #1073 `l3-914-adopt-record-and-doctor` | `b58a4e2ccae0` | clean, `70f85d401ede` |
| #1072 `l3-918-source-and-locale` | `2ffd82356c7b` | clean, `2e341d250ea1` |
| #1071 `l3-916-adopt-test-strength` | `24273dd47e42` | clean, `0d183517f958` |
| #1070 `fix/979-named-link-one-finding` | `20d2d4ba67f6` | clean, `a5beb7be1408` |
| #1031 `fix/910-check-throwaway-repos` | `74c17705edc2` | clean, `324855e1983d` |
| #1081 `fix/1060-pin-lane-in-process` | `7d4f2d82df4b` | clean, `fc9fe407b03b` (it touches none of these files) |

Main moved after the runs above, which were made on `5e539a87`: #1071 (`bccae280`), #1076 (`c13c3985`), #1081 (`06cbd478`) and #1072 (`1e7971cd`) have landed. #1071 and #1072 touch two of this change's files, `tests/test_adopt_pins.py` and `adopt-project.py`. The head merges clean with origin/main `1e7971cd` (tree `ffb8249e`, from `git merge-tree --write-tree`), and the landing gates the predicted squash tree over the main of the day.

#1068 and #1064 have landed and are under this commit. `fix/904-kept-links` was not on origin when this was pushed; it is now #1091, open at `8f5fd0fd`, and merges with this head clean (tree `0153f3e7e3c9`, measured by rv1090). #1062 touches none of these files.

- rv1090, lane 5's independent reviewer, who wrote none of this: READY at `d5389eb6` (issuecomment-6316009648), with no MUST.
- Its SHOULD is a missing row: no row has an exponent of two or more digits, and the head quotes those names correctly.
- Its two code NITs: the no-`origin` bump refusal for a tag the leg holds, and an `else` nothing reaches. With the SHOULD, they are filed as #1103, to land after the v1.2.0 tag.
- Its body NITs are fixed above: 177 test files, one `Claim:` line, and #1091's state.

Review: rv1090, lane 5's independent reviewer, not the writer (w912): READY at `d5389eb6` (#1090 issuecomment-6316009648) with no MUST; its SHOULD (a missing row for exponents of two or more digits, which the head quotes correctly) and its two code NITs are filed as #1103 for after the v1.2.0 tag, and its body NITs are fixed. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 d5389eb passed tests, tests-git-bash and SonarCloud; the head 1ad7605 is a clean update-branch merge of main 7e00834, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree e182fde over main 7e00834: the adopt_pins, bump_leg_dry_run_truth, neutral_pin_entry_one_spelling, pin_annotated_tag, pin_source_top_level, scaffold_pin_and_reuse, repo_hygiene, agents_md_codes, cli_reference sets, 613 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #912

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 21, 2026
…e_checkout_writes_is_never_written_through` says the throwaway repository is made in the work directory, as #910 makes it, and no longer in `TMPDIR` (#1083)

The last item of #1083's (c): the docstring still said the throwaway
repository the link is asked in is in `TMPDIR`. Since #910 it is made in
the work directory, which is as deep below `tmp_path` as the `TMPDIR` the
row sets, so the row asks the same question and only its words change.
(The issue's `adopt-project.py:466` names `_pin_row_problem` at 74c1770;
this docstring is the one PR #1031's body named.)

Gate on the merged tree (main 6da757c): see the pull request body.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 21, 2026
…keeps refs, and asks a pin's file through the links the root keeps, so a link into `.git` or onto a pin's file is refused before any leg or adopted with the copy beside it (#904) (#1091)

A symlink the plan keeps in the root, where it leads into the clone's own `.git` or through a kept link to a pin's file, is refused before any leg exists for a reason that is true, or adopted with the shape's copy beside it (#904, lines L1-J1, L1-J2, L1-K2 and L1-K9 of #457, with L1-K8 as the rider).

#904's line numbers are at `5017830`. Each claim was measured again by name on main `6c861635`, and again on `5e539a87` after #1064 landed (#1071 and #1076 since then touch neither `adopt-project.py` nor `scripts/shape_materialize.py`).

- **L1-J1 held.** `CLONE_GIT_TREES` still counted `.git/objects`, `.git/refs`, `.git/logs` and `.git/modules` whole. `CLAUDE.md -> .git/refs/remotes/origin/main`, `-> .git/logs/refs/heads/nope` and `-> .git/modules/nope`: `check` 0 (`plan ok`), `execute` 0, `adoption verified`, 2 leg repositories, and the work clone's `.git/<target>` holds the shape's `CLAUDE.md`. Changed: `-> .git/objects/zz/nothing` now ends `REFUSED committing the split. materialize-blocked: ... there is no directory there to write it in`, still after both legs, where `5017830` printed a bare `REFUSED committing the split.`
- **L1-J2 held.** `CLAUDE.md -> .git/refs/../HEAD` and `-> .git/../AGENTS.md`: `check` 1, `plan-root-symlink` "it passes through `.git/refs` (`.git`), which is not a directory in the assembly root", 0 legs.
- **L1-K2 held**, and the same is true of the shape's own pin. `contracts -> governance`, `governance/` kept, `--pin openGlass@<commit>`: a file `governance/openglass-pin.yaml` gives `check` 0, `execute` 0, `beside shape/contracts/openglass-pin.yaml`, `adoption verified`, then the first bootstrap exits 2 with `REFUSED yaml-unparsable`; a link from it to `README.md` gives `REFUSED neutral-pin-unreadable`. With no pin declared and a file `governance/shape-pin.yaml`: `check` 0, `execute` 1 with `FINDING adopt-shape-pin-unreadable` after both legs.
- **L1-K9 held.** `shape/README.md -> ../README.md` kept: `check` 1 says "and `execute` would write the copy through it after both leg repositories were created and pushed" ("over it" for a file), and `execute` exits 2 before any leg. `place` asks `copy_in_the_way`, which stops at a link there, dangling or not, and writes nothing there.
- **L1-K8 is real.** Measured with git 2.47.1 and `GIT_DEFAULT_REF_FORMAT=reftable`: `CLAUDE.md -> .git/reftable` and `-> .git/reftable/tables.list` are refused (`plan-root-symlink`), though the clone holds both; `-> .git/packed-refs` checks ok and `execute` writes the shape's `CLAUDE.md` into `.git/packed-refs`; `-> .git/logs/HEAD` checks ok and stops after both legs. The clone's ref format comes from `GIT_DEFAULT_REF_FORMAT` or `init.defaultRefFormat`, never from the source, and git 2.43 ignores both.

What the clone's `.git` holds when the shape is written was listed from inside `execute` (the materializer wrapped), on git 2.43.0 and 2.47.1, files and reftable. Edge cases were measured too: a source whose HEAD is detached gets a guessed branch, and `core.logAllRefUpdates=false` (global or `GIT_CONFIG_*`) leaves no `.git/logs`.

All in `adopt-project.py`; the tests in `tests/test_adopt_root_symlinks.py` and `tests/test_adopt_root_shape_paths.py`.

- **Named, not counted whole (L1-J1).** `_CloneGit` replaces `CLONE_GIT`, `CLONE_GIT_FOLDED` and `CLONE_GIT_TREES`. It names what git makes there: `.git`, `HEAD`, `config`, `index`, `objects`, `objects/info`, `objects/pack`, `refs`, and `modules` with each leg's store at `modules/<mount path>`. On the files backend it adds `packed-refs`, `refs/heads`, `refs/tags` and the three loose refs `git clone` and `execute` write (the branch the clone checks out, `refs/remotes/origin/HEAD` and the split branch), with a log of each and `logs/HEAD` where git writes reflogs. On the reftable backend it adds `reftable`, `reftable/tables.list` and a `refs/heads` that is a file, and there is no `packed-refs` and no `logs` (L1-K8).
  - A name inside the object store, inside a leg's store, inside the reftable tables, or under `refs/heads` and `refs/remotes` when the source's HEAD is on no branch is neither there nor not there. `check` refuses a link to one, saying that it reads nothing there, so whether `place` writes the copy beside it or the shape's file into `.git` cannot be told before any leg exists.
  - Any other name in `.git` is nowhere. The finding says the shape would write its file through the link into `.git`, where no commit holds it, or would find no directory there and stop after both legs: whichever is true of its directory.
- **How git keeps refs is asked of the machine (L1-K8).** `_clone_ref_store` asks a throwaway repository in the work directory whether it is on the reftable backend and whether it writes reflogs, as `_checks_out_symlinks` asks about `core.symlinks`. `_SymlinkProbe.ref_store` makes that probe the first time a walk is led into the clone's `.git`, and keeps the answer. `check` makes it once, and `execute` twice, as it asks `_checks_out_symlinks` twice: once for `check`'s findings and once for `_refuse_symlinks_checked_out_as_files`. Without a work directory, or where no repository can be made, git's defaults are taken. `_entry_findings` now makes its one probe before `_leg_findings`, so the pins' walks share it.
- **`..` in `.git` (L1-J2).** For a link AT a path the shape writes, a `..` steps back out of a directory `_CloneGit` names, as the end of a target is found there. A link ABOVE one is still never followed into `.git` (D9); its finding now says so, for a `..` and for a target, where it said "not a directory in the assembly root" or "not in the source tree".
- **A pin's file through the kept links (L1-K2).** `_kept_pin_findings` asks each pin's file, the declared pins' and the shape's own, where the root walk reaches it through the links the plan keeps above it (`_Walk.reached`). Where that walk is stopped, the walk's own `plan-root-symlink` still names the link (`PIN_THROUGH`), so one link is one finding. The finding names the link and its remedy: "at `contracts/openglass-pin.yaml` through the symlink `contracts` to `governance` ... send `contracts` to a leg or `drop` it, or rename `governance/openglass-pin.yaml` in the source and re-run `plan`". The code is still `plan-root-holds-pin`, and `_pin_findings` and `_leg_findings` take `source` and `probe` from `_entry_findings`.
- **What the materializer does (L1-K9).** `plan-root-blocks-shape-copy` now says "the materializer writes no copy through it" (or "over it", "cannot write a file where a directory is", "cannot make a directory where a file is"), then ": it would stop there (`materialize-copy-blocked`), writing nothing there, after both leg repositories were created and pushed, so `execute` refuses this plan before either exists." The comment at "A SYMLINK OR A SUBMODULE" and the test's docstring and assertion say the same.
- **Codes kept.** No refusal code changed. Only tests pin the changed words: the README, the handbook mirror, `docs/cli.md` and `AGENTS.md` name none of these sentences.

The probes use #457's setup: a stub `gh` exiting 97, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, local remotes, and every null `leg:` answered `root`. They ran on git 2.43.0 unless stated.

| Case | main `5e539a87` | this branch |
|---|---|---|
| `CLAUDE.md -> .git/refs/remotes/origin/main` | check 0, execute 0, verified, 2 legs, the shape's file in that `.git` file | check 1 `plan-root-symlink`, execute 2, 0 legs |
| `-> .git/logs/refs/heads/nope`, `-> .git/modules/nope` | the same as above | check 1, execute 2, 0 legs |
| `-> .git/objects/zz/nothing` | check 0, execute 2 after 2 legs | check 1 (object store not read), execute 2, 0 legs |
| `-> .git/refs/../HEAD`, `-> .git/../AGENTS.md` | check 1 "not a directory in the assembly root", 0 legs | check 0, execute 0, `beside shape/CLAUDE.md`, verified |
| `scripts -> .git/refs/..` (above) | check 1, 0 legs | check 1, 0 legs, saying a link above is never followed into `.git` |
| K2: `governance/openglass-pin.yaml` a file | check 0, execute 0, bootstrap `yaml-unparsable` | check 1 `plan-root-holds-pin`, execute 2, 0 legs |
| K2: it links to `README.md` | check 0, execute 0, bootstrap `neutral-pin-unreadable` | check 1 `plan-root-holds-pin`, execute 2, 0 legs |
| K2: it links nowhere | check 1 `plan-root-symlink`, 0 legs | unchanged |
| K2: nothing kept there (control) | check 0, execute 0, bootstrap 0 | unchanged |
| shape's own pin, `governance/shape-pin.yaml` | check 0, execute 1 `adopt-shape-pin-unreadable` after 2 legs | check 1 `plan-root-holds-pin`, execute 2, 0 legs |
| K9: `shape/README.md -> ../README.md`, and a file there | check 1 "`execute` would write the copy through (over) it" | check 1 "the materializer writes no copy through (over) it: it would stop there ..." |
| git 2.47.1 reftable: `-> .git/reftable`, `-> .git/reftable/tables.list` | check 1, 0 legs | check 0, execute 0, `beside shape/CLAUDE.md`, verified |
| git 2.47.1 reftable: `-> .git/packed-refs` | check 0, execute 0, the shape's file in `.git/packed-refs` | check 1, execute 2, 0 legs |
| git 2.47.1 reftable: `-> .git/logs/HEAD` | check 0, execute 2 after 2 legs | check 1, execute 2, 0 legs |

These names also adopt with the copy beside them, as on main: `.git/refs/heads/main`, `.git/refs/remotes/origin/HEAD`, `.git/refs/heads/adopt/three-repo-shape`, `.git/logs/refs/heads/main`, `.git/modules/code`, `.git/objects/info` and `.git/refs/tags`.

**Rows that fail on main.** The two test files of this branch were run on a clone of main `5e539a87` (whose `adopt-project.py` and `scripts/shape_materialize.py` are those of main `c13c3985f`): 42 failed, 277 passed, 3 skipped.

The 42 failures are every row this branch adds or changes, except those that hold on main too:
- L1-K9: the three #262 rows, the case-aside row, `test_a_symlink_at_the_copy_itself_is_in_the_way` and `test_a_link_at_the_copy_is_one_finding_by_the_rule_that_sees_it`.
- L1-J1, refused: the D9 row and the `.git/description` and `.git/hooks` rows (new words), four `J1-at-a-path-to-no-*` rows, `at-a-path-to-no-directory-in-dot-git`, `J1-at-a-path-into-the-object-store` and `at-a-path-into-a-legs-store`.
- L1-J2: three refused `J2-*` rows, two accepted `J2-*` rows, and the end-to-end `J2-back-out-of-refs-and-origins-head`.
- L1-K2: three `HELD_THROUGH` rows and the shape's own pin.
- The ref store: three no-probe rows, the probe's answer, no work directory, eight `ON_REFTABLE` rows, no reflogs, a detached source HEAD, and the probe made once.

The rows that pass on main too are kept as guards:
- the ten `GIT_NAMED` accepted rows and the end-to-end `the-cloned-branch-and-the-split-branchs-log`, names that were counted whole and are now named;
- the no-pin-file control;
- `test_the_materializer_stops_at_a_link_at_the_copy`, which is L1-K9's evidence that the materializer stops there.

The three rows that need git 2.45 or later were skipped there (git 2.43).

**Mutants.** Each mutant was applied alone to a copy of this head, and the rows that hold it were run on git 2.43. All 21 were killed, each by an `AssertionError`, with failed and selected counts as shown. None was equivalent.

| Mutant | Failed / selected |
|---|---|
| M1 an unread name counts as there | 2/2 |
| M2 the cloned branch is not named | 2/2 |
| M3 the split branch is not named | 2/2 |
| M4 reflogs always | 1/1 |
| M5 reftable never | 6/8 |
| M6 a `..` above is followed into `.git` | 1/1 |
| M6b no `..` out of a `.git` directory | 4/6 |
| M7 a link above is taken as one at | 1/2 |
| M8 a pin's file is not asked through links | 4/7 |
| M9 asked through a stopped walk too | 4/4 |
| M10 K9's old sentence | 2/47 |
| M11 the probe says reflogs always | 1/1 |
| M12 "written into `.git`" and "no directory" swapped | 5/6 |
| M13 the cloned branch is the branch read | 1/1 |
| M14 the clone's `.git` asked eagerly | 3/3 |
| M15 the ref probe's answer not kept | 1/1 |
| M16 no work directory gives no defaults | 1/1 |
| M17 the through-link remedy is the entry's | 4/7 |
| M18 the pins are asked without the source | 4/7 |
| M19 `reached` is taken from any part | 1/7 |
| M20 the `.git` reasons are gone | 9/9 |

**Runs** (`run9.sh`, one shared stub `gh`, `EMAIL`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`):
- git 2.43.0, both own test files: 319 passed, 3 skipped (the reftable rows).
- git 2.47.1 first on PATH, both own test files: 322 passed.
- git 2.43.0 gate: every test file that runs `adopt-project.py` (`grep -l` in `tests/`), with `test_shape_doctor.py`, `test_scaffold_leg_path_collisions.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`. That is 122 files in two shards of 61: shard A, 4075 passed, 10 skipped (`END 2026-10-20T23:41:03Z rc=0`); shard B, 4713 passed, 9 skipped (`END 2026-10-20T23:53:03Z rc=0`).

`git merge-tree --write-tree` of this head against:
- origin/main `c13c3985f`: clean. Main moved after the runs above, which were made on `c13c3985`: #1081 (`06cbd478`) and #1072 (`1e7971cd`) have landed, and #1072 touches `adopt-project.py`. Against origin/main `1e7971cd` the head is clean too, tree `cf4e2e68`.
- #1073 (`l3-914-adopt-record-and-doctor`, `b58a4e2cc`): clean. #1072 (`l3-918-source-and-locale`, `2ffd82356`) was clean too, and has landed since as `1e7971cd` (above).
- #1031 (`fix/910-check-throwaway-repos`, `74c17705e`): conflicts in `adopt-project.py`, 3 hunks. Both moved the one `_SymlinkProbe` in `_entry_findings`, and both added attributes to `_SymlinkProbe.__init__`. To resolve, make the probe before `_leg_findings` as this branch does, keep #1031's `unasked`, `linked` and `asked` lines, and keep both sets of attributes.
- #1070 (`fix/979-named-link-one-finding`, `20d2d4ba6`): conflicts in `adopt-project.py`, 7 hunks. Both added parameters to `_kept_pin_findings` and `_pin_findings` and changed their docstrings, the `_leg_findings` docstring and the "A SYMLINK OR A SUBMODULE" comment. To resolve, take both parameters (`named_links`, then `source` and `probe`) and both texts, and in that comment keep this branch's sentence on what `place` does at the copy, with #979's clause after it.
- #1081 and the other open pull requests touch none of these files; lane 5's #1090 (`fix/912-pin-questions`, `d5389eb6`, for #912) also changes `adopt-project.py`, and the head merges with it clean, tree `0153f3e7`.

Main moved to `2a119f28` while this was in review. Two of the pull requests that landed change `adopt-project.py`: #1031 (#910, the throwaway repositories `check` asks) and #1070 (#979, a named link at a copy or a pin is one finding). The branch was merged with main, not rebased, as `1cc9180fd` (parents `8f5fd0fd3` and `2a119f281`). Ten hunks of `adopt-project.py` conflicted, seven with #1070 and three with #1031. Each was resolved by reading both sides:

- **#1070.**
  - `_kept_pin_findings` and `_pin_findings` take both sets of parameters: `named_links` first, as main passes it, then `source` and `probe`, which the calls pass by keyword.
  - #979's `named_links` check stands in this branch's body as it is. A link `named_links` holds at the pin's file is left out. A walk that meets one through a link above has stopped, so the pin's file is asked at its literal path, where nothing is found, and the walk's own finding is left out too (`walk.empty`).
  - Both docstrings are kept, #979's paragraph first. `_leg_findings` passes `named_links, source=source, probe=probe`.
  - The "A SYMLINK OR A SUBMODULE" comment keeps this branch's sentence, that `place` stops at a link AT the copy and writes nothing through it. Main's "and as a write THROUGH it" is dropped, since it is not true. #979's clause follows.
- **#1031.**
  - In `_entry_findings`, #1031's `_paths_git_refuses` question comes first. The one `_SymlinkProbe` is then made before `_leg_findings`, because the walks of the pins' files ask it. #1031's `linked`, `asked` and `unasked_notes` lines and its `probe.notes()` are kept.
  - `_SymlinkProbe` keeps #1031's `unasked` beside this branch's `_refs`. `ref_store` now does what #1031 has `symlinks` do: where `git init` fails otherwise than with an `OSError`, it takes git's defaults (the files backend, with reflogs) and keeps git's words in `refs_unasked`. Before the merge, that failure raised out of `check` as "a git or gh command failed".
  - `notes()` gives each NOTE. The symlink NOTE keeps #1031's words, and the ref NOTE is new. Both take git's words from one `_init_refused`.
- Nothing else #1031 or #1070 changed is called by this branch's code. #1070's other hunks are the docstrings of `_links_at_a_copy` and `_links_at_a_pin`. #1031 leaves `_PlannedRoot`, `_checks_out_symlinks` and `_refuse_symlinks_checked_out_as_files` as they were.

New rows in `tests/test_adopt_root_symlinks.py` cover the ref probe's failed `git init`, raised as a `CommandFailed` and as a `Refusal`. Further rows check that an `OSError` gives no NOTE, and that `check` under `GIT_DEFAULT_HASH=bogus` says where git would not make the ref probe.

**Runs on `1cc9180fd`** (`run9.sh`, as before):
- git 2.43.0, both own test files: 323 passed, 3 skipped (the reftable rows).
- git 2.47.1 first on PATH, both own test files: 326 passed.
- The two test files on main `2a119f28`'s code: 46 failed, 277 passed, 3 skipped. These are the 42 of before plus the four new ref-probe rows.
- Mutants, re-run where the resolution touched them, each killed:
  - M8, M9, M15, M16, M17, M18 and M22 by an `AssertionError`.
  - New: M21, the ref probe's failed `git init` raising as before. It fails two rows because the `CommandFailed` or `Refusal` it lets out escapes, and one row by an `AssertionError`.
  - M23 removes #979's `named_links` skip at a pin's file. Only #979's own `test_a_named_link_at_a_copy_or_a_pin_is_one_finding` holds it, and that test fails by a `ValueError`, not an `AssertionError`: it unpacks exactly one finding (`tests/test_adopt_symlink_target_bytes.py:667` and `:818`), and the mutant gives two.
- Gate, git 2.43.0: the 125 test files the merged tree has for `adopt-project.py`, with `test_shape_doctor.py`, `test_scaffold_leg_path_collisions.py` and the trio, in two shards. Shard B (62 files): 4507 passed, 15 skipped, `END 2026-10-21T13:14:42Z rc=0`. Shard A (63 files) was running at the checkpoint: at 87% at 13:43Z, with no failure so far.

Commit `160de1186`, on top of the merge, answers rv1091's review of `8f5fd0fd`. rv1091 re-checked it: SHOULD 1, SHOULD 2 and NITs 1-3 are fixed, and the two test files gave 326 passed, 3 skipped.

- **SHOULD 1: "writing nothing" was false.** `place` writes the shape's files in order and stops only at the blocked copy, so every copy before it is written. rv1091 measured 0, 5, 9 and 12 files written before the stop, at `shape/README.md`, `shape/CLAUDE.md`, `shape/Makefile` and `shape/.github/workflows/validate.yml`. `plan-root-blocks-shape-copy` now says "it would stop there (`materialize-copy-blocked`), writing nothing there, after both leg repositories were created and pushed". That matches the materializer's own "and nothing was written there". The comment above the sentence says the same, and `STOPS` in `tests/test_adopt_root_shape_paths.py` pins the new words.
- **SHOULD 2: SonarCloud's two `python:S5778`.** Both are in rows this PR adds, and each made a second call inside `pytest.raises`. `template_values()` (the materializer's link-at-the-copy row) and `loaded.open_source(None, work)` (the row that checks the ref probe is made once) are now made before the block.
- **NIT 1: `python:S3776` on `_kept_pin_findings`.** Sonar's analysis of the merged function gives 30. The finding for one pin's file on one disk is now `_kept_pin_finding`, and the through-link words and their fix are `_through_the_kept_link`. The nested conditional in `_shape_pin_held`'s argument is now a statement. Sonar's analysis of the new functions (`analyze_code_snippet`, project `opensoft_openRepoShape`) finds no issue in them.
  - Each return value stands for what the loop did before. None asks the next disk, `[]` is #979's named link, and a list of one line is the finding.
- **NIT 2: what a guessed refs directory holds.** The case is a source whose HEAD is on no branch. The text "where `git clone` writes the branch it checks out" was given for `.git/refs/remotes`, `.git/logs/refs/heads` and `.git/logs/refs/remotes` as well as `.git/refs/heads`. `_guessed_refs` now says what `git clone` writes in each one:
  - in `refs/heads`, the branch it checks out;
  - in `refs/remotes`, `origin/HEAD` for that branch;
  - in the two `logs/` directories, the reflog of either.

  This was measured on git 2.43.0 and 2.47.1, for a source detached at a branch's tip and for one detached off any tip. In the first case git checks out `main` and writes `refs/remotes/origin/HEAD` (`ref: refs/remotes/origin/main`) and the reflogs of both. In the second it writes neither, and `refs/heads` holds only the split branch. Each link is still refused, as "cannot be told". The detached-HEAD row is now four rows, one per directory.
- **NIT 3: the ref probe is made "once per command".** That was false for `execute`. Counted in process with `CLAUDE.md -> .git/HEAD`, `check` makes the probe once, with or without a kept `.gitattributes`, and `execute` makes it twice. The first is for `check`'s findings, which `_refuse_what_check_finds` asks. The second is for the walks of `_refuse_symlinks_checked_out_as_files`, which make their own `_SymlinkProbe`, as they ask `_checks_out_symlinks` again on main. The code is unchanged. The sentence under "What changed" now says so.

- **NIT 4: two mutants of rv1091's that survived the first round.**
  - R8 guesses only `refs/heads` for a detached source, not `refs/remotes`. Round 2's detached-HEAD rows for `.git/refs/remotes/origin/HEAD` and its reflog kill it (M28 below).
  - R6 has the fix rename the kept file rather than the path the walk reaches. It still survives here, because no row keeps a directory at the reached pin file. That is a gap in the tests, not in the code. The row is a follow-up for after the v1.2.0 tag, written and run but not in this PR: `contracts -> governance`, with `governance/openglass-pin.yaml/notes.md` kept, gives "inside `contracts/openglass-pin.yaml` through the symlink ... rename `governance/openglass-pin.yaml`", and main checks it with exit 0.

**Words.** A grep of `tests/`, `README.md`, `docs/` (outside the release notes), `AGENTS.md` and `templates/` finds the changed words only in the two test files. No refusal code changed.

**Rows that fail on main.** The detached-HEAD rows for `.git/refs/remotes/origin/HEAD`, `.git/logs/refs/heads/main` and `.git/logs/refs/remotes/origin/HEAD` are new. A probe on main `2a119f28` gives `check` exit 0 for all four links, so main refuses none of them, and every row expects a refusal. The pytest run of these rows on main was not made before the checkpoint. On the merge `1cc9180fd` all four are refused, but the three new ones say "the branch it checks out".

**Mutants** on `160de1186` were not run before the checkpoint. Each is written, and its anchor occurs once in `160de1186`'s `adopt-project.py`:
- M17 and M23, re-anchored on the new helpers: the through-link fix becomes the kept entry's, and `_kept_pin_finding`'s `return []` for #979's named link is dropped;
- M24: every guessed directory says what `refs/heads` holds;
- M25: no "the reflog of";
- M26: the K9 sentence's old "writing nothing,";
- M28: rv1091's R8;
- M8, re-run.
rv1091 ran R8 at `160de118`, and the NIT 2 rows kill it (2 of 4 failed).

**Runs on `160de1186`** (`run9.sh`, as before):
- git 2.43.0, both own test files: rv1091 ran them at `160de118`, 326 passed, 3 skipped (the reftable rows).
  - Mine ran at `3abf3b1f5`: 327 passed, 3 skipped, `END 2026-10-21T13:32:51Z rc=0`.
  - `3abf3b1f5` is `160de118` plus the R6 row alone, the follow-up kept off this branch. Its `adopt-project.py` and `tests/test_adopt_root_shape_paths.py` are `160de118`'s, and its `tests/test_adopt_root_symlinks.py` adds that one test function.
- git 2.43.0, `test_shape_doctor.py`, `test_scaffold_leg_path_collisions.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`: running at the checkpoint, at 19% at 13:43Z, with no failure so far.
- Not started before the checkpoint:
  - the seven other test files that pin `plan-root-holds-pin` or the K9 sentence (`test_adopt_pins.py`, `test_adopt_shape_pin_and_work_dir.py`, `test_adopt_symlink_target_bytes.py`, `test_adopt_ignored_shape_files.py`, `test_adopt_root_manifest.py`, `test_adopt_nested_attributes.py` and `test_adopt_reencoded_files.py`), though all seven are in the gate on `1cc9180fd`;
  - the two own files on git 2.47.1.
- CI runs the full suite on the push.

**Merge-trees** of `160de1186`, by `git merge-tree --write-tree`:
- origin/main `7e00834cf`: clean, tree `0b8a5bc9`. #1087 (`16f9a0e81`) and `7e00834cf` landed after the merge and change none of this branch's files. #1087 changes `scripts/shape_materialize.py`, whose `place` and `copy_in_the_way` the K9 sentence describes, but only the fetch for a branch's answer.
- #1090 (`1ad76055b`): clean, tree `108c3a8e`.
- origin/main `6da757cbe`, re-fetched at about 13:10Z, after #1090 landed: clean, tree `283d7b52`.
  - #1090 changes `adopt-project.py` in the NOTE `check` prints for a pin's digest and in the docstring of `_neutral_product_pins`.
  - Neither is code this branch changes, and no row here pins those words.

Lane: openreposhape-5 (openRepoShape-5)

Review: rv1091, lane 5's independent reviewer, not the writer (w904): NOT READY at `8f5fd0fd` (#1091 issuecomment-6316443939) for two SHOULDs, with the merge `1cc9180f`'s resolution READY; the fix round took both SHOULDs and three NITs, and the re-check is READY at `160de118` (#1091 issuecomment-6317666157); its one open gap, R6's row, is filed as #1116. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 160de11 passed tests, tests-git-bash and SonarCloud; the head 551452b is a clean update-branch merge of main 823624d, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree c1dcef6 over main 823624d: the adopt_root_shape_paths, adopt_root_symlinks, repo_hygiene, agents_md_codes, cli_reference sets, 496 passed, 3 skipped. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #904

Lane: openreposhape-5 (openRepoShape-5)
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

Labels

None yet

Projects

None yet

1 participant