Skip to content

validate-family.py: a hand mount's git rm line works typed: git rm --cached where a plain one refuses a staged gitlink, dies on a clone no section names or refuses what is no checkout, with what it leaves said, and a .gitmodules gone from the work tree put back before the git rm that takes a section out (#936) - #1025

Merged
brettheap merged 7 commits into
mainfrom
fix/936-hand-mount-git-rm-lines
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

(lane openRepoShape-4, DRAFT; head 4c0c52d0, fix round 1 for RV1025's review, with main 5b41d44f merged in; #889 landed as 702cc23d, #956 as 3864b18c and #983 as 5b41d44f)

Every git rm line that templates/family-root/scripts/validate-family.py's member-gitlink-unnamed prints for a member mounted by hand now works typed, and the gate passes after the commit. A plain git rm refuses a staged gitlink, dies on a clone no section names, and refuses what is no checkout. In those states the line is now git rm --cached, which touches no file, and the text says what stays on disk and what to do with it. Where the work tree's .gitmodules is gone, the file is put back before the git rm that takes the hand mount's section out. Before this change, each of these lines exited 1 or 128 when typed, or the gate found the same thing again after the commit.

#936 holds the three hand-mount states that #889 (#879) named under "What it cannot tell" in its round 2. The issue's goal is every hand mount's git rm working typed, so a fourth state of the same kind, what is no checkout, is fixed here too.

What is on disk at a hand mount is asked as #956 asks it (#430's rule, which landed on main as #956). A path or a .git this user cannot read is refused member-unreadable by name. Before, it ended in a traceback on Python 3.12, and on 3.14 it would read as a false "no checkout". This moves one of #956's test rows; see "Choices for the reviewer".

Fix round 1 (RV1025, issuecomment-6280865402)

RV1025 found this PR READY at 927fabf7, with no MUST, one SHOULD and three NITs. This round's head is 4c0c52d0, tree 4d0c9deb:

Before and Now

How it was measured.

1. A gitlink the index stages at a commit HEAD does not record there, with something on disk at it

This is lane 1's SHOULD on #889, and _staged_over is true for it. Seven states:

  • git mv members/IRRS members/IRRS2, staged, either clean or with a commit in the checkout it moved;
  • a gitlink staged over an empty directory, over a directory holding a file, or over a dangling link;
  • a git submodule add not committed;
  • a hand mount's checkout moved to HEAD~1, then git add.

Before. The text printed git rm -- <path>, which exits 1 in all seven. Git says one of two things:

  • "the following file has changes staged in the index";
  • where what is on disk differs from what is staged too, "the following file has staged content different from both the file and the HEAD".

After the commit, the gate found the gitlink again. Where a section gives the path, it also found gitmodules-path-unnamed.

Now. The line is git rm --cached -f -- <path>. Over an empty directory it is git rm --cached -- <path>, with no -f, as _forced decides. Either way it touches no file. The text says why, and quotes both refusals: "take it out of the index alone with the second, which touches no file (-f takes out a staged gitlink whose checkout has moved on or holds a file git does not track, which git rm otherwise refuses, and with --cached touches no file), since a plain git rm refuses it here: the index stages it at a commit HEAD does not record there, with something on disk at it (the following file has changes staged in the index, or, where what is on disk differs too, has staged content different from both the file and the HEAD, exit 1)". Where HEAD records nothing at the path, the text adds "; HEAD records nothing there, so nothing of it is left to commit". Where the work tree's .gitmodules holds a change the index does not, git add -- .gitmodules comes first, and the text numbers both. Git takes no gitlink out of the index until that change is staged ("please stage your changes to .gitmodules or stash them to proceed", exit 128). This is RV1025's NIT 2, a row since fix round 1.

The text then says what is left on disk, and what to do with it before family.py add, which refuses anything at the path (member-path-taken):

  • A checkout: "The checkout at members/IRQS stays there, untracked: its .git file names the store that keeps its commits whatever becomes of the checkout, so only what is not committed is in the checkout alone, which git -C members/IRQS status shows. Delete it by hand once nothing only it holds is needed, ...".
  • An empty directory: delete it.
  • What is no checkout, a dangling link included: move it out, or delete it.

A section that gives the path now prints its own --unset-all lines, because --cached leaves the section in place. Before, it printed none, because a plain git rm would take the section out with the gitlink.

Typed in order, with what is left deleted by hand as the text says, then committed: nothing is left, the gate passes in every mode, and a plain clone checks out both members.

2. A hand mount with its own section, then a staged removal, with the work tree's .gitmodules gone

The state:

  1. a hand mount with its checkout and its section, committed;
  2. then git rm --cached members/IRRS, or a plain git rm members/IRRS;
  3. then the work tree's .gitmodules is deleted, or its deletion is staged.

Before. The hand mount's git rm -- members/IRQS exited 0 but took its section out of no file. The removal's put-back after it (git checkout -- .gitmodules, or HEAD's) brought the section back. After the commit, the gate found gitmodules-path-unnamed in every mode.

Now. The hand mount's lines put the file back first, from where git reads it, unless a finding before did (C1's single put-back):

  • git checkout -- .gitmodules;
  • or git checkout HEAD -- .gitmodules where the deletion is staged.

The text says "put .gitmodules, gone from the work tree, back from the index's with the second, so that the git rm takes its section out of that file and every other section stays".

The removal after it puts nothing back:

  • after git rm --cached, its finding prints git reset alone;
  • after a plain git rm, it writes IRRS's section back key by key where the index's file lacks it.

Typed in order and committed, in all four states: nothing is left, HEAD's .gitmodules gives both members, the gate passes in every mode, and a plain clone checks both out.

3. A clone added by hand that no section names

The state: IRQS cloned into members/IRQS, with a commit only that clone holds, then git add members/IRQS, committed or not. No section gives the path.

Before. git rm -- members/IRQS died with fatal: could not lookup name for submodule 'members/IRQS', exit 128. Git's rm first moves a .git directory into the holder's store, and it names that store from a section of .gitmodules.

Now. The line is git rm --cached -- members/IRQS. Where the index stages it and HEAD records nothing, it is git rm --cached -f -- members/IRQS. The text says: "The clone at members/IRQS, whose .git is a directory, stays there, untracked: it holds its commits there and nowhere else, and git -C members/IRQS log --oneline HEAD --branches --not --remotes lists the ones not pushed, so move it out of the holder whole, by hand, rather than delete it, ...".

Typed, that git log lists the clone's commit. Then:

  • the clone was moved out as the text says, and the move kept the commit;
  • the removal was committed where HEAD recorded it;
  • nothing is left, the gate passes, and a plain clone checks out both members.

Beyond the issue: what is on disk is no checkout

The state: a committed hand mount with no .git at its path. At the path is a directory holding a file, or a file.

Before. git rm -- members/IRQS exited 1 ("the following file has local modifications").

Now. The line is git rm --cached -- members/IRQS. The text says "What is on disk at members/IRQS, which is no checkout, stays there, untracked: move it out of the holder, or delete it by hand once nothing in it is needed, ...". Typed, deleted by hand and committed, the gate passes.

What cannot be read (#430, as #956 asks it)

Before, on this branch's third commit moved onto main (02bd9e10):

  • members/ at mode 000, with a hand mount staged or committed: _hand_cached's Path.is_dir() of members/IRQS/.git ended the gate in a PermissionError traceback on Python 3.12.
  • A checkout at mode 444, which this user can list but not search: the same traceback, from the same is_dir(), which _hand_cached asked first. On 3.14 (not run here), is_dir() answers False to a refused stat, as os.path.lexists does on every Python. So, read from the code, the text would say "no checkout". For the committed checkout, that is the reason for a git rm --cached the plain line does not need. For the staged one, it is what the --cached line leaves.

Now each is refused by name, exit 2, with the chmod cure, as #956's other sites are:

  • REFUSED member-unreadable: members/IRQS: cannot be read (Permission denied) where the folder above the path cannot be searched;
  • REFUSED member-unreadable: members/IRQS/.git: cannot be read (Permission denied) where the checkout cannot be.

With the mode back, the gate says what it said before.

A hand mount HEAD records is refused too, where #956 printed the plain git rm (r956's C19). With members/ at mode 000, that line, git rm -- members/IRQS, exits 128 on git 2.43:

warning: failed to stat 'members/IRQS': Permission denied
fatal: could not remove 'members/IRQS'

So it was no line that works.

The controls

These print the plain git rm, as before, and it works:

  • a hand mount with nothing on disk;
  • one with its checkout and its section;
  • a clone whose path a section gives, which git moves into the holder's store first.

The change

_hand_cached asks git's records and what is on disk, as git's rm.c does, and never git's words:

  • The path first (_anything_at). Nothing there is the plain line.
  • "clone": a .git directory at the path, with no section giving it in the file git reads. _given_as_git_reads reads the work tree's file, else the index's, else HEAD's. It is asked first, so the .git is asked only where no section gives the path.
  • "staged": _staged_over. git rev-parse HEAD:<path> differs from the index's commit, and anything is on disk at the path.
  • "no checkout": anything on disk but an empty directory, with no .git entry.

Around it:

The tests

tests/test_family_hand_mount_git_rm_lines.py is new, with 21 rows. Each of the first 19:

  1. types every git line as printed, in order;
  2. deals by hand with what the text says to delete or move out;
  3. commits;
  4. asks the gate in every mode;
  5. makes a plain clone, whose git submodule update --init --recursive must check out both members.
state rows what
1 8 git mv; an empty directory; git submodule add not committed; a checkout moved to HEAD~1 and added; git mv with a commit in the moved checkout; a directory holding a file; a dangling link; git mv with a hand edit of .gitmodules not staged (RV1025's NIT 2)
2 4 git rm --cached or a plain git rm of IRRS, with the file deleted or its deletion staged
3 2 the clone committed, and staged only
beyond 2 a directory holding a file, and a file
controls 3 nothing on disk; a checkout and its section; a clone a section names
what cannot be read 2 a checkout and its section, committed, and git submodule add not committed, each at mode 444: every mode refused by name, then the same findings with the mode back

Choices for the reviewer

Base: main

The pinned copy

Shared files

git merge-tree --write-tree of this head 4c0c52d0 against main and against every open PR that shares a file with it or with the family root's scripts. Heads were read by gh pr list at 2026-10-20T09:39:15Z.

Against Head Result
main dc7cd49b clean, tree 43ca9045
#954 (#892) fe907c84 clean, tree aeca9f00
#1023 5cf60365 clean, tree d6b30c04
#1028 (#1001) 411e0d6a clean, tree 05a61026

The run and the mutants

The run of fix round 1 is CI's: run 39377078862, pull_request on this head 4c0c52d0. It tests refs/pull/1025/merge (b2623136), this head merged with main 5b41d44f, which it already holds. Its tests job ran python3 -m pytest tests -q, the whole suite, on CPython 3.14.8 and git 2.55.0. 18668 passed, 371 skipped, 0 failed, from 2026-10-20T05:51:04Z to 2026-10-20T06:49:06Z. tests-git-bash, package and SonarCloud passed too.

The run before it: one run, of 39 files, on 927fabf7, with Python 3.12.3 and git 2.43.0, in the environment above. 2612 passed, 15 skipped, 0 failed, from 2026-10-19T22:30:43Z to 2026-10-19T23:25:46Z. Afterwards, the four touched files' bytes, and bootstrap.py's, were checked by sha256 against the commit's blobs.

Mutants: each puts one line's old behaviour back on its own.

mutant state result
_hand_cached never answers "staged": the plain git rm, as before 1 killed: 7 failed
STAGED_AT quotes "changes staged in the index" alone 1 killed: 7 failed
the section at a --cached path prints no lines, as before 1 killed: 2 failed, 5 passed
"commit that removal" where HEAD records nothing 1, 3 killed: 7 failed, 2 passed
nothing said of what --cached leaves on disk 1, 3, beyond killed: 11 failed
the hand mount puts no file back, as before 2 killed: 4 failed
_hand_cached never answers "clone": the plain git rm, as before 3 killed: 2 failed
a clone a section names is taken --cached too the controls killed: 1 failed, 2 passed
_hand_cached never answers "no checkout" beyond killed: 2 failed
_staged_over asks exists, which a dangling link fails 1 (NIT A) killed: 1 failed, 6 passed
_hand_cached asks the .git before the path, so a folder above it at mode 000 is refused at <path>/.git what cannot be read killed: 2 failed, 6 passed
"clone" asks Path.is_dir() first, as before: a traceback on 3.12 what cannot be read killed: 3 failed, 5 passed
"no checkout" asks os.path.lexists of the .git, as before what cannot be read survived: 8 passed
_cached_left asks Path.is_dir() of the .git, as before: a traceback on 3.12 what cannot be read killed: 1 failed, 7 passed

13 of 14 were killed.

What it cannot tell

Closes #936

🤖 Generated with Claude Code

Summary by Sourcery

Ensure validation emits hand-mount removal instructions that work across staged, unnamed, non-checkout, and unreadable member states.

New Features:

  • Make every git rm command emitted for unsupported hand-mounted members executable, using cached removal where a plain removal would fail and explaining the resulting on-disk state.

Bug Fixes:

  • Restore a missing .gitmodules before removing a hand-mounted member so its configuration section is actually removed.
  • Report unreadable hand-mounted paths as named validation refusals instead of producing tracebacks or misleading removal instructions.

Enhancements:

  • Preserve working plain git rm guidance for supported hand-mount states while emitting section cleanup commands when cached removal leaves .gitmodules entries behind.

Tests:

  • Add coverage for staged gitlinks, hand-mounted sections with missing .gitmodules, unnamed clones, non-checkout contents, supported controls, and unreadable paths, including typing every emitted command through to a passing validation gate.

Brett Heap and others added 4 commits October 19, 2026 21:30
… --cached` where a plain one refuses a staged gitlink, dies on a clone no section names or refuses what is no checkout, with what it leaves said, and a `.gitmodules` gone from the work tree put back before the `git rm` that takes a section out (#936)

Every `git rm` line `member-gitlink-unnamed` prints for a hand mount now
works typed, and the gate passes after the commit. Based on main,
where #889 landed as 702cc23.

- `_hand_cached`: asked of git's records and what is on disk, as git's
  rm.c asks it, never of git's words. The line is `git rm --cached`,
  with `-f` where `_forced` says, which touches no file, where a plain
  `git rm` would
  - refuse a gitlink the index stages at a commit HEAD does not record
    there, over anything on disk (exit 1, either of git's two refusals,
    both quoted: `STAGED_AT`);
  - die on a clone whose `.git` is a directory that no section of the
    file git reads names (could not lookup name for submodule, exit 128);
  - refuse what is on disk and is no checkout (exit 1).
  `_cached_left` says what stays on disk and what to do with it before
  `family.py add`: move a clone out whole, delete a checkout once nothing
  only it holds is needed. Where HEAD records nothing at the path,
  nothing of it is left to commit.
- `_check_gitmodules_named`: a section at such a gitlink's path prints
  its own `--unset-all` lines, which `--cached` leaves to it.
- `_plain_rm_said`: where the work tree's `.gitmodules` is gone and the
  file git reads gives the hand mount's path, the file is put back
  first, from where git reads it, unless a finding before did, so the
  `git rm` takes the section out and a staged removal's put-back after
  it no longer brings it back.
- The controls print the plain `git rm`, as before.

Tests: tests/test_family_hand_mount_git_rm_lines.py (17 rows); #889's
round-2 SHOULD test in tests/test_family_holder_remedies_compose.py is
dropped, its states being rows here, and its helpers stay there.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… dangling link is taken out cached, so `_staged_over` reading `exists` in place of `lexists` fails a row (#936)

x889c's NIT A on #889 (issuecomment-6253217474): nothing tested
`_staged_over`'s `os.path.lexists` against a dangling link. Under
`(root / path).exists()` the reason was lost while git still refuses the
plain `git rm` (exit 1, "has staged content different from both the
file and the HEAD"). The row expects `git rm --cached -f` with the
staged reason; the mutant is killed (1 failed, 6 passed).

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dy's own text, so the guard that every remedy naming `_switch_line` ends its prose with `BRANCH_LEFT` sees the sentence (#936)

The 37-file run on 40e6e27b failed two tests in
tests/test_family_switch_branch_exists.py: the guard, and the test that
shows the guard is not vacuous. `_mounted_by_hand` built its lines as a
list that began with `_switch_line(...)`. The guard reads the string
expression a `_switch_line` call stands in, and for this call that was
the list. The printed text was the same, and its prose ended with
`BRANCH_LEFT`, but the guard could not see it.

- `lines` now holds the lines after the switch line. The returned text
  writes the switch line itself, as main's did.
- The helpers number their lines from the second
  (`_ordinal(len(lines))`), and the add is the
  `_ordinal(len(lines) + 1)`.
- The printed text is unchanged. The 18 rows of
  tests/test_family_hand_mount_git_rm_lines.py and the two guard tests
  pass (20 passed).

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…asks it, so a path or a `.git` this user cannot read is `member-unreadable` by name, never a 3.12 traceback or a false "no checkout", and a hand mount HEAD records is refused under a folder where its plain `git rm` exits 128 (#936)

Moved onto main 3864b18, where #956 (slice E of #430) landed. The
rebase had no textual conflict, because #956's hunks and this branch's
are apart. Its rule met #936's new reads, though:
tests/test_validate_family_unreadable.py failed 3 of 52 on 02bd9e1, the
third commit moved:

- hand-mount-folder-000 and hand-mount-committed, members/ at mode 000:
  `_hand_cached`'s `git.is_dir()` ended the gate in a PermissionError
  traceback on Python 3.12.
- hand-mount-committed, a refused `stat` of members/IRQS in process:
  `_on_disk` refused it `member-unreadable`, exit 2, where the row
  expected the control's finding, exit 1.

Resolved with #936's intent, every hand mount's line works typed, and
#430's, a path this user cannot read is never absent:

- `_hand_cached` asks the path first (`_anything_at`). Nothing there is
  the plain line, and a folder above it that cannot be searched is
  refused at the path, as `_staged_over` refuses it. "clone" asks the
  file git reads before the `.git`, and the `.git` with `_asked`;
  "no checkout" asks the `.git` with `_anything_at`. `_cached_left`
  asks its `.git` the same way.
- A hand mount HEAD records is asked of the disk too, since whether its
  plain `git rm` works turns on what is there. With members/ at mode
  000, git 2.43's `git rm -- members/IRQS`, the line main prints there,
  exits 128: "warning: failed to stat 'members/IRQS': Permission
  denied", "fatal: could not remove 'members/IRQS'". So r956's C19 row
  for it moves.
  `test_a_gitlink_head_records_as_staged_asks_nothing_of_the_disk` and
  `test_a_folder_under_a_gitlink_git_takes_is_never_asked` keep their
  variant row (C20). The hand mount is refused by name in
  `test_a_path_whose_stat_is_refused_is_refused_by_name`
  (hand-mount-committed) and in
  `test_a_folder_this_user_cannot_search_is_refused_by_name`
  (hand-mount-committed-folder-000, and hand-mount-committed-444 for
  its `.git`).
- tests/test_family_hand_mount_git_rm_lines.py: two rows, a checkout
  and its section committed, and `git submodule add` not committed,
  each at mode 444. Every mode is refused by name at `<path>/.git`, and
  with the mode back the gate says what it said.

tests/test_validate_family_unreadable.py: 53 passed. The hand mount
file and tests/test_family_switch_branch_exists.py: 45 passed.

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

The PR makes every printed git rm remediation for hand-mounted, unnamed gitlinks executable: it selects plain removal when safe, otherwise uses permission-aware git rm --cached with appropriate force handling, restores missing .gitmodules before section removal, explains retained disk content, and adds broad end-to-end tests for these states and regressions.

Sequence diagram for restoring .gitmodules before hand-mount removal

sequenceDiagram
    participant Validator
    participant WorkTree
    participant Git
    participant Report
    Validator->>WorkTree: _file_put_back
    WorkTree-->>Validator: Restore .gitmodules from index or HEAD
    Validator->>Report: Record restoration line
    Validator->>Git: git rm -- members/IRQS
    Git-->>WorkTree: Remove gitlink and its section
    Validator->>Git: Commit removal
    Validator-->>WorkTree: family.py add can remount path
Loading

State diagram for hand-mounted gitlink remediation outcomes

stateDiagram-v2
    [*] --> Inspect
    Inspect --> PlainRemoval: _hand_cached returns None
    Inspect --> CachedRemoval: clone, staged, or no checkout
    Inspect --> Refused: _anything_at or _asked reports member-unreadable
    PlainRemoval --> SectionRemoved: git rm -- path
    CachedRemoval --> IndexOnly: git rm --cached [ -f ] -- path
    IndexOnly --> DiskExplained: _cached_left
    SectionRemoved --> Committed
    DiskExplained --> ManualCleanup
    ManualCleanup --> Committed
    Committed --> [*]
Loading

Flow diagram for executable hand-mount git rm remediation

flowchart TD
    A[Unnamed hand-mounted gitlink] --> B{_hand_cached}
    B -->|Nothing on disk| C[git rm -- path]
    B -->|Clone without named section| D[git rm --cached path]
    B -->|Staged over HEAD| D
    B -->|No checkout on disk| D
    B -->|Readable checkout with safe plain removal| C
    D --> E{_forced}
    E -->|Required| F[git rm --cached -f -- path]
    E -->|Not required| G[git rm --cached -- path]
    C --> H[Section removed from .gitmodules]
    D --> I[Gitlink removed from index; disk content remains]
    I --> J[_cached_left explains move or delete]
    H --> K[Commit removal, then family.py add]
    J --> K
Loading

File-Level Changes

Change Details Files
Classify hand-mounted gitlinks whose plain removal fails and emit working index-only removal commands with actionable cleanup guidance.
  • Detect staged-over, unsectioned clone, and non-checkout states using Git records and permission-aware filesystem checks.
  • Generate git rm --cached, optionally forced, while preserving on-disk content and explaining how to move or delete it.
  • Keep plain git rm for supported controls and emit section cleanup commands when cached removal leaves .gitmodules entries.
templates/family-root/scripts/validate-family.py
Add comprehensive behavioral coverage for typed remediation commands across hand-mount states.
  • Exercise staged gitlinks, missing .gitmodules, unsectioned clones, non-checkouts, controls, and unreadable paths across validation modes.
  • Type printed Git commands in order, perform documented manual cleanup, commit results, and verify clean gates and clone behavior.
  • Pin command wording, forced-removal behavior, preserved clone commits, and permission refusal diagnostics.
tests/test_family_hand_mount_git_rm_lines.py
Refactor existing hand-mount tests to delegate staged-removal scenarios to the new coverage and shared helpers.
  • Remove superseded staged-refusal constants, state tables, and test cases.
  • Retain and document reusable fixture helpers for the expanded scenarios.
tests/test_family_holder_remedies_compose.py
Update unreadable-path expectations to reflect disk inspection for hand mounts.
  • Move committed hand-mount cases from the 'disk never asked' assertions to named member-unreadable refusals.
  • Cover inaccessible parent directories and .git paths while preserving existing variant behavior.
tests/test_validate_family_unreadable.py

Assessment against linked issues

Issue Objective Addressed Explanation
#936 For hand mounts whose gitlink is staged at a commit HEAD does not record, print a git rm remedy that succeeds without discarding checkout work, explain what remains on disk, and ensure the gate passes after committing. ✅
#936 When .gitmodules is absent from the work tree but available from the index or HEAD, restore it before removing a hand mount’s section so the section is actually removed and the gate passes after the commit. ✅
#936 For a hand-cloned member with no .gitmodules section, print a working removal remedy that preserves the clone’s commits, explains what remains, and allows the gate to pass after the commit. ✅

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-4 RV1025, 2026-10-20T01:03:16Z)

RV1025, independent review of #1025 at 927fabf7 (tree aaee59fc): READY, with no MUST. One SHOULD, which can be met in the body and a follow-up issue, and three NITs. Every one of #936's states prints lines that work typed, the gate passes after the commit, the C19 change is right, and the tests discriminate. CI's tests job had not finished when this was posted (see CI).

What I ran

The C19 change: right, and consistent with #430

Mutants (mine, one per decision; each restored and checked by sha256)

id decision undone rows run result
MA _hand_cached asks "staged" before "clone" new file killed: 1 failed (issue_3[staged])
MB "no checkout" asks no .git new file killed: 6 failed (state 2's 4 rows, two controls)
MD the --cached line never takes -f new file killed: 7 failed
ME _cached_left calls any .git a clone's new file killed: 4 failed
MF _hand_cached reads the path with os.path.lexists new + #956's file killed: 2 failed (hand-mount-folder-000, hand-mount-committed-folder-000)
MC the plain line puts .gitmodules back where no section gives the path new + compose killed: 2 failed (round 2 MUST 2, both rows)
MW the plain line is not recorded as a write new + compose killed: 1 failed (round 1 SHOULD 1, a-hand-mount-with-its-section)
MG git add -- .gitmodules printed after the --cached line new + compose + my probe survives the PR's 74 rows; only my probe kills it (NIT 2)

7 of 8 are killed by the PR's own rows.

The body's claims

All of these match what I measured:

No text the PR changes is pinned in README.md, docs/handbook.html, docs/cli.md, AGENTS.md or the family root's docs. The test pins are only in the new file and in the compose file's dropped constants. No new quoting or path choice differs on Windows:

  • every printed line is shlex.quoted, as every other line in this file is;
  • the mode rows are needs_a_mode;
  • the dangling-link row needs a symlink, as the compose file's link row does, and CI is Linux only (ci: run the test suite on Linux only #597).

CI

  • Run 39345719590, pull_request on this head, which tests the merge with main, as of 2026-10-20T01:03:08Z:
    • package and tests-git-bash passed, and SonarCloud's gate passed with 0 new issues;
    • tests was still running. It started at 00:07:13Z, and main's own push runs take about 65 minutes of pytest.
  • Main's push run for 36e39a0e failed exactly the two inherited rows, tests/test_family_add_gitlink_and_null_members.py::test_a_second_members_line_above_the_block_is_refused[members: null] and [members: []]: 2 failed, 17601 passed. main is red since #974: two rows of test_a_second_members_line_above_the_block_is_refused expect the refusal the YAML reader now pre-empts #1024 fixes them.
  • READY assumes tests fails those two rows and nothing else. Any other failure there is a finding against this verdict, so read the job's failed rows before landing.

Items

SHOULD 1: the body's first "What it cannot tell" is measured now, and it is not what the body predicts.

  • Where: templates/family-root/scripts/validate-family.py:3054-3057. _cached_rm_said says "HEAD records nothing there, so nothing of it is left to commit". Meanwhile gitmodules-path-unnamed reads only the work tree's file (_gitmodules_listed, :1843-1844, read at :3999).
  • The state: git submodule add <IRSS> members/IRQS, not committed, then the work tree's .gitmodules deleted.
  • What the gate prints: one finding in every mode, member-gitlink-unnamed, with the lines switch, git rm --cached -f -- members/IRQS and add.
  • Typed, with the checkout deleted as the text says:
    • every line exits 0, and the gate exits 0 in all three modes;
    • but git status --porcelain is MD .gitmodules. The index's file still holds [submodule "members/IRQS"], with its path and url, which the add staged.
  • What follows: the next commit records that section. With the index's file back on disk (git checkout -- .gitmodules, what a clone of that commit has), the gate finds gitmodules-path-unnamed in all three modes, exit 1.
  • Against the body: no later finding is needed for this, and the local gate never sees it.
  • Not a regression: main printed git rm -- members/IRQS here, which exits 1.
  • Fix: the real cure is outside validate-family.py (family root): after #889, three hand-mount git rm lines still fail when typed: a gitlink staged at a commit HEAD does not record, a hand mount with its own section while .gitmodules is gone, and a hand clone with no section (exit 128) #936. gitmodules-path-unnamed should read its sections from the file git reads where the work tree's is gone, as _check_gitlinks_elsewhere has done since rv882's SHOULD 2. For this PR:
    • replace "was not measured" with this measurement;
    • file that follow-up.

NIT 1: the body's second "What it cannot tell" is wrong on git 2.43.

  • The state: a linked worktree of an outside clone, mounted by hand at members/IRQS with git worktree add, then git add and a commit. No section names it.
  • Measured: the plain git rm -- members/IRQS exits 0 (rm 'members/IRQS'). It makes no .git/modules and leaves the worktree prunable.
  • Why: rm.c absorbs only a .git that is not a gitfile (submodule_uses_gitfile).
  • The head: it prints the plain line there (.git is a file, so _hand_cached answers None at :2882), and that line works.
  • Fix: correct the item.

NIT 2: no row reaches git add -- .gitmodules before a --cached line.

  • Where: :3050, _staging_said in _cached_rm_said.
  • The gap: mutant MG, which prints the stage line after the --cached line, passes all 74 rows of the new file and the compose file.
  • The state (my probe): git mv members/IRRS members/IRRS2, staged, then a line appended to .gitmodules by hand and not staged.
    • The head prints switch, git add -- .gitmodules, git rm --cached -f -- members/IRRS2 and add, with "with the third" and "with the fourth". Typed in order, with the moved checkout deleted and the result committed, it ends well: the gate passes in every mode, and a plain clone checks out both members.
    • In MG's order, git rm --cached -f exits 128 (please stage your changes to .gitmodules or stash them to proceed). Measured on plain git 2.43.
  • Fix: add that state as a row.

NIT 3: "Its states are now rows in the new file" (the body, on the dropped compose test) leaves one out.

  • The state left out: STAGED_STATES' nothing-on-disk, a gitlink staged by hand over nothing (staged_by_hand(lambda where: None)). The dropped row typed its plain git rm, which exited 0.
  • Why it is missing: the new file's control nothing-on-disk is hand_mounted, which is committed.
  • Where it is still pinned: the staged state's plain line, untyped, in tests/test_validate_family_unnamed_mounts.py:293.
  • Fix: add it to CONTROLS, or correct the sentence.

Choices for the reviewer

I agree with all four:

Lane: openreposhape-4 (openRepoShape-4)

Brett Heap and others added 2 commits October 20, 2026 05:15
…lict, so #1025 is tested against main as it stands, with #983's change to validate-family.py and #1026's fix of main's two red rows (#936)

Main took twelve squashes since e076e3e: #1026, #958, #986, #1002,
#1016, #1011, #1021, #1022, #973, #975, #1015 and #983. Only #983
touches validate-family.py, in `_git_runs` and the member checks, apart
from this branch's hunks; it touches none of this branch's test files.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ules` not staged puts `git add -- .gitmodules` before a hand mount's `git rm --cached`, so a stage line printed after it, where git's rm exits 128, fails a row (#936)

RV1025's NIT 2 on #1025: no row reached `_staging_said` in
`_cached_rm_said`, so its mutant MG, which prints the stage line after
the `--cached` line, passed every row of the new file and the compose
file. The state is RV1025's probe: `git mv members/IRRS members/IRRS2`,
staged, then a line appended to the work tree's `.gitmodules` by hand
and not staged. The row pins the order (`git add -- .gitmodules`, then
`git rm --cached -f -- members/IRRS2`), the text that numbers both, and
what is left, then types every line in order, deletes the moved
checkout as the text says, commits, and asks the gate in every mode and
a plain clone. In MG's order the `git rm --cached -f` exits 128
(`please stage your changes to .gitmodules or stash them to proceed`).

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-20T09:41:13Z)

RV1025: fix round 1 is pushed. Please re-check the delta 927fabf7..4c0c52d0. The body is updated to match.

Lane: openreposhape-4 (openRepoShape-4)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4 RV1025, 2026-10-20T09:52:56Z)

RV1025, re-check of #1025 at 4c0c52d0 (tree 4d0c9deb): READY, with no MUST and no SHOULD. Fix round 1 takes the SHOULD and all three NITs from my review at 927fabf7 (issuecomment-6280865402). The merge of main is clean. The new row kills my mutant MG and fails against main's script. CI passed on this head. One new NIT, on #1036's text rather than on this PR.

The delta 927fabf7..4c0c52d0

The NIT 2 row: it kills MG and fails against main's script

The row is test_issue_1_a_gitmodules_change_not_staged_is_staged_before_cached. It is stronger than my probe. It pins:

  • the line order;
  • the text that numbers both lines ("with the second", "with the third", "with the fourth");
  • what the --cached line leaves on disk.

It then types every line, commits, checks that git status --porcelain is empty, and runs the gate in every mode and a plain clone.

The runs, in my clone, with one basetemp:

run result
this head, the five files (the four touched ones, plus #983's tests/test_validate_family_git_not_runnable.py, which now shares the script) 212 passed (21 + 54 + 53 + 25 + 59)
MG, applied from my own mutate.py on this head, against the new file 1 failed, 20 passed. The failure is the new row, on its line-order assertion.
main 3864b18c's script (sha256 ca2c40b7), against state 1's rows, the new row and the controls 8 failed, 3 passed. The new row fails: main prints git rm -- members/IRRS2 after the stage line. The 3 controls pass.

After each swap I put the file back with git checkout and checked it: sha256 954696d0, blob 31e4d193, the head's. The clone ends clean at 4c0c52d0.

The body

CI

Run 39377078862 (pull_request on 4c0c52d0, merge ref b2623136) succeeded:

  • tests: 18668 passed, 371 skipped in 58 minutes, read from its log;
  • tests-git-bash and package passed.

Main's two red rows are gone, since #1026 is merged in, so there is no inherited failure left to discount.

The C19 change

My ruling at 927fabf7 stands. The merge does not touch _hand_cached or _cached_left. Lane 1's r956 is still asked in the body to confirm.

NIT, new, on #1036's text (not this PR)

#1036 cites "#1025's head 760ea814" and lines :1843-1844 and :3999 there.

Lane: openreposhape-4 (openRepoShape-4)

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
brettheap marked this pull request as ready for review October 20, 2026 16:08
@sourcery-ai

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

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 6476c17 into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the fix/936-hand-mount-git-rm-lines branch October 20, 2026 16:09
brettheap pushed a commit that referenced this pull request Oct 20, 2026
Main moved past eee089c: #1045 (00f6d7f) changed the holder's
bootstrap.py and #1025 (6476c17) changed validate-family.py, two of the
four copies of the git-not-runnable rule. The merge is clean, and the four
copies' rule is still one text.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 20, 2026
…s where the work tree's `.gitmodules` is gone, the index's then HEAD's, and its lines put that file back first, so a section `git submodule add` staged is no longer committed past a passing gate (#1036) (#1069)

RV1025's SHOULD 1 on #1025. In its state, `git submodule add` at
members/IRQS, not committed, then the work tree's `.gitmodules` deleted
(`MD .gitmodules`), the finding read the work tree's file alone and named
nothing: every printed line exited 0, the gate passed in every mode, and
the commit of what was staged recorded IRQS's section in HEAD's file.

Measured on git 2.43.0, isolated config: with the work tree's file gone,
`git submodule status members/IRQS` maps the path, which HEAD's file does
not give, so git reads the index's; `git config -f .gitmodules
--unset-all ...` on no file exits 5 and makes none; the index's file put
back and both keys unset leave it byte-identical to HEAD's.

- `_sections_as_git_reads`: the work tree's sections where the file is on
  disk, else the file git reads in its place (`_gitmodules_put_back`),
  through `_blob_sections`. `_given_as_git_reads` and `_blob_paths` derive
  from it, unchanged in what they answer, so #936's hand-mount put-back
  and this finding agree on one file.
- `_check_members` hands it to `_check_gitmodules_named`, but where
  `.gitmodules` gives a value git dies on: that finding's lines put HEAD's
  file back and stage it, which drops a section the index's file alone
  gave. Every other reader of `.gitmodules` is as it was.
- `_section_out` takes the put-back (`_file_put_back`, one for every
  finding) and prints it right before the keys' lines, saying so; the
  case-variant branch and `_gitlink_out_first` pass it. A gitlink outside
  `members/` keeps its `git rm --cached` first, which needs no file.

Rows at the end of tests/test_family_hand_mount_git_rm_lines.py: the
issue's state, the same at tools/x, a stray section, two sections served
by one put-back, a hand mount whose plain `git rm` takes its section
(no command), and the control where a value git dies on keeps what it
read. Two rows of tests/test_family_holder_remedies_compose.py move with
the fix: C1's `--no-members` run names the section giving `Members/IRRS`
(no command), as it does with the file on disk, and rv882's SHOULD 2 row,
which said the gate passed, now expects the `gitmodules-path-unnamed` the
same state is with the file on disk, typed through to a passing gate.

Round 2, for RV1069's review: validate-family.py: `gitmodules-path-unnamed` stages `.gitmodules` with a printed line where HEAD's file holds no section of the name it takes out, in `members/` and in another case as at a gitlink outside it, so `git commit` after the lines records nothing of a section `git submodule add` staged (#1036)

RV1069's SHOULD 1 on #1069. In #1036's state the section's text ended
"then commit .gitmodules" and printed no stage line: after the put-back
and the `--unset-all` lines the work tree's file was HEAD's, the index's
still held the section (`MM .gitmodules`), so `git commit` recorded it
and `git commit -- .gitmodules` had nothing to commit.

`_section_then` gives every branch one ending, from HEAD's file: the
commit where it holds a section of that name, else "then stage
.gitmodules: HEAD's holds no section of that name, so nothing of it is
left to commit", the words the branch for a gitlink outside `members/`
already said, now with `git add -- .gitmodules` printed after the keys'
lines (`_section_out`), as the removal's written-back section and the
wrong-case move print theirs.

Rows: R1 and its tools/x sibling type only the printed lines, then
`git commit` of what is staged; both fail on ab8ed9e (HEAD keeps
`members/IRQS`; the stage line is missing) and pass here.
`tests/test_family_unnamed_gitlink.py`'s `section_first` expects the
stage line where the text ends "then stage".

Closes #1036

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