Skip to content

family.py: add, bump and remove refuse a cherry-pick or a revert stopped in a holder whose refs are a reftable, asking its ref store for CHERRY_PICK_HEAD and REVERT_HEAD by their exact names where no file holds them (#684) - #846

Merged
brettheap merged 3 commits into
mainfrom
fix/684-e4-reftable-operation
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

R612 E4, the last item of #684. #816 landed the other five as "Part of #684" (fa04d2cd). Against main bad05d21, two commits: a3683127, and 6a71ff8d for rv846's NITs.

Round 1: rv846's four NITs (6a71ff8d)

Lane 5's rv846 reviewed a3683127 (issuecomment-6241234307): READY, no MUST and no SHOULD, four NITs. I took all four in one commit, 6a71ff8d, on a3683127, and pushed it as a fast-forward. Nothing was amended. Main is not merged in, because every merge-tree below is clean. The commit changes scripts/family.py (+7/-3, comments only) and tests/test_family_operation_reftable.py (+58/-21), and nothing else.

NIT what changed held by
1: nothing checked that git show-ref --exists runs in the holder. rv846's M10 asks in Path.cwd() instead, and it passed every row on git 2.43 The stand-in logs pwd -P for each ask, and its four tests assert the holder's directory and no other (asked_in). The order test records the cwd that _git_bytes is given for each show-ref M10, run by me on git 2.43: 31 passed against round 1's tests, 10 failed against these. The 10 are the 8 stand-in cases, each saying {'<the checkout the run started in>'} where the holder was expected, and the order test's nothing and revert
2: the skip reason said the stand-in rows hold the refusal "on any git", and the docstring said "THIS HOST'S GIT IS 2.43" The reason is now the stand-in rows hold the refusal in its place, but not on Windows, where they skip too. The docstring says a git before 2.45 makes no reftable, names 2.43 as the git the tests were written on and 2.55 as CI's, and says the stand-in rows skip on Windows wording
3: MERGE_HEAD's reason cited git 2.43 alone, and the real-reftable row's comment cited 2.43 in a row that only runs on 2.45 and later The comment on REF_STORE_MARKS now says MERGE_HEAD is a file on both backends: lane 5's probe shows it on 2.47.1, and git's is_pseudo_ref reads it from its file whatever the backend. So the file is the whole answer. Git 2.43's exit 1 stays as a second reason, with 2.47.1's exit 2 beside it. The row's comment now says MERGE_HEAD is a file on a reftable too (its premise), so only the two refs are asked comments only; no behaviour changed
4: "Shared files" was stale Redone against main 190133d5 and the open PRs (below)

What changed and why

The defect. Lane 5's probe (#684 issuecomment-6237529261; the full result on #764, issuecomment-6237519513), on git 2.47.1 in a repository made by git init --ref-format=reftable:

  • a stopped cherry-pick and a stopped revert keep CHERRY_PICK_HEAD and REVERT_HEAD in the ref store, and no file in .git;
  • MERGE_HEAD, rebase-merge/ and sequencer/todo stay files, as on the files backend.

_operation_in_progress asked os.path.lexists of each mark, so in such a holder it found neither, and add, bump and remove went on to the git commit -- <paths> that family-operation-in-progress exists to keep them from.

The change (scripts/family.py, one function added, three edited):

  • _in_ref_store(root, mark), new: git show-ref --exists <mark>, the exact-name ask (git 2.43 and later).
    • exit 0: held; exit 2: not held.
    • exit 129: not held. That is a git before 2.43, which knows no --exists. Such a git cannot open a reftable at all, so the holder's refs are files and the file asked first is the whole answer (measured, below).
    • any other exit is a CommandFailed, which the verb prints as it prints every failed git command, exit 2, before its first write. Git's own failure is never read as "nothing in progress".
  • _operation_in_progress: in the CHERRY_PICK_HEAD and REVERT_HEAD rows (REF_STORE_MARKS), where the file is not there, the ref store is asked; a hit returns the row with where None. The rows keep their order, so a rebase is still asked first and a bisect last. The file checks stay, for those two marks too and for every other mark.
  • _checked_no_operation_in_progress and _unparsable_in_an_act say git keeps CHERRY_PICK_HEAD in its ref store, not as a file where where is None. A mark found as a file is named at <path>, byte for byte as before. The remediation is the act's own, unchanged.
  • Docstrings and comments say why, with what was measured.

What a reftable holder now prints (taken from the stand-in run, below):

REFUSED family-operation-in-progress: a cherry-pick is unfinished in the family root: git keeps CHERRY_PICK_HEAD in its ref store, not as a file. `add` makes its commit with `git commit -- <paths>`, and git refuses that during a cherry-pick (`fatal: cannot do a partial commit during a cherry-pick.`), after `add`'s first writes, which are then put back. No git command that writes was run and nothing was written.
Remediation: end it, whichever way suits the work in it: finish it with `git cherry-pick --continue` once whatever stopped it is resolved and staged, or give it up with `git cherry-pick --abort`. Then run the same command again.

Mirrored from #815, not imported. #815 (#764, head 05a1ebb3, not landed) asks the same two names with the same command in the assembly root's bootstrap.py (IN_REF_STORE, _in_ref_store), in the same row order. One difference, on purpose: #815's _try_git(...) is not None reads every non-zero exit as "not held"; this PR reads only 2 and 129 so, and stops on any other, so that git's own failure is never read as "nothing in progress".

Tests changed: tests/test_family_operation_reftable.py (new, below), and one sentence of tests/test_family_holder_states.py's module docstring, which said E4 was not measured yet and now points at the new file. No other test, doc, template or pinned file is changed. README.md, docs/handbook.html and docs/cli.md do not name this refusal's words.

Tests

Measurements, before the change

Probe shell: a stub gh first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_ALLOW_PROTOCOL=file, GIT_CONFIG_NOSYSTEM=1.

This host's git is 2.43.0, and no git 2.45 or later is on it. which -a git names only /usr/bin/git (2.43.0). A search of /tmp, /workspace, /home, /opt and /usr/local found git binaries answering 2.35.0, 2.36.6, 2.40.4 and 2.43.0 to --version, all under /tmp, and nothing newer; _lane5-scratch holds no git build. git init --ref-format=reftable on 2.43: error: unknown option ref-format=reftable', exit 129. GIT_DEFAULT_REF_FORMAT=reftable git initmakes a files repository, andgit rev-parse --show-ref-formatechoes--show-ref-format` back.

git show-ref --exists, files backend, git 2.43.0:

state CHERRY_PICK_HEAD REVERT_HEAD
nothing in progress exit 2, error: reference does not exist exit 2
a cherry-pick stopped on a conflict (.git holds CHERRY_PICK_HEAD) exit 0 exit 2
a revert stopped on a conflict (.git holds REVERT_HEAD) exit 2 exit 0
git branch CHERRY_PICK_HEAD and git tag REVERT_HEAD, nothing in progress exit 2 exit 2
that branch, and a cherry-pick stopped exit 0
  • The exact name. With that branch and tag, git rev-parse -q --verify CHERRY_PICK_HEAD and ... REVERT_HEAD each print the commit, exit 0. That is why it is not the ask.
  • Git's own failures. Outside any repository: fatal: not a git repository, exit 128. Two names in one ask: fatal: --exists requires exactly one reference, exit 128, so it is one process per mark. git show-ref --verify CHERRY_PICK_HEAD with a cherry-pick stopped: fatal: 'CHERRY_PICK_HEAD' - not a valid ref, exit 128, so --verify is no ask on 2.43.
  • MERGE_HEAD is no ref-store ask. Found while writing the tests: git show-ref --exists MERGE_HEAD (and FETCH_HEAD) exits 1, error: failed to look up reference: Success, in a fresh repository and after git merge --abort; 0 with a merge stopped. CHERRY_PICK_HEAD, REVERT_HEAD, BISECT_HEAD and ORIG_HEAD answer 2 there. MERGE_HEAD stays a file check because it is a file on both backends, as the probe found and as git's is_pseudo_ref reads it. On git 2.43, asking the ref store for it would also stop every verb on "git's own failure". rv846 measured that git 2.47.1 answers 2 there.
  • A git before 2.43. git 2.40.4 (another session's build under /tmp, run read-only): git show-ref --exists CHERRY_PICK_HEAD exits 129, error: unknown option exists'`. That build was found by searching. Found binaries are not run any more, so no git from 2.34 to 2.42 was measured.
  • Such a git cannot open a reftable. A repository whose config says core.repositoryformatversion = 1 and extensions.refStorage = reftable (written by hand): git rev-parse --git-path MERGE_HEAD exits 128, fatal: unknown repository extension found: refstorage, on 2.43.0 and on 2.40.4. That is _git_paths' call, made before any ref-store ask. So a git that answers 129 is only ever asked in a holder whose refs are files.
  • What git 2.47.1 and 2.51.0 answer, read from their source (builtin/show-ref.c, refs.c, refs/reftable-backend.c at the tags, read as text, nothing built). --exists exits 2 for ENOENT or EISDIR and 1 for any other failure. The reftable backend sets ENOENT for a name it does not hold. So "not held" is 2 on a reftable too. refs_read_raw_ref reads FETCH_HEAD and MERGE_HEAD as files on every backend, which agrees with the probe.

The new test file, tests/test_family_operation_reftable.py: 18 cases, 14 run here and 4 skip

The stand-in. This host cannot make a reftable, so the refusal is held by a stand-in. It is a sh git put first on PATH. It answers git show-ref --exists <name> with exit 0 for a name its store holds, and runs the real git for everything else (which answers 2 for an absent name on the files backend). Each test stops a real cherry-pick or revert with git, then moves the mark's file out of the git directory into the stand-in's store, as a reftable keeps none. It logs the directory each ask runs in (pwd -P), and every stand-in row asserts that this is the holder's directory and no other. The same stand-in also answers as git 2.40.4 does (129) and as a failing git would (1 and 128). The stand-in rows skip on Windows (a sh script), as test_a_git_that_cannot_tell_refuses_nothing_and_the_late_failure_stays does.

test cases holds
test_a_mark_the_ref_store_holds_and_no_file_does_is_refused_alike 4 (cherry-pick: add, bump; revert: remove, add) refused under the real run and --dry-run in the same words, in its ref store, not as a file, the act's own remediation; exit 2, nothing on stdout, the holder byte for byte as it was (#612's assert_refused_alike); the stand-in asked once a run
test_a_cherry_pick_conflicted_in_family_yaml_names_the_ref_store 1 the second caller, _unparsable_in_an_act: yaml-unparsable keeps its line, and its remediation names the cherry-pick, the ref store and its exits, under both runs
test_a_git_before_2_43_answers_from_the_files_alone 1 the 129 answer: both marks asked, read as "not held", and add makes its one commit
test_git_failing_to_answer_stops_the_verb_before_its_first_write 2 (exit 1 add, exit 128 remove) REFUSED a git or gh command failed. THE EXACT COMMAND WAS: git show-ref --exists CHERRY_PICK_HEAD ... exit <n>, exit 2, nothing on stdout, the holder as it was, under both runs
test_a_branch_and_a_tag_named_as_the_marks_are_no_operation 3 (each verb), real git the exact name: rev-parse -q --verify resolves both names and show-ref --exists neither (premises), and each verb makes its one commit
test_the_file_is_asked_first_and_the_ref_store_only_where_none_is 3, real git, in process the order: nothing in progress asks both marks; a revert's file is found after one ask (CHERRY_PICK_HEAD); a cherry-pick's file before any; argv exactly git show-ref --exists <mark>, asked in the holder (the cwd that _git_bytes is given); a file is named at its path
test_on_a_reftable_each_act_is_found_where_git_keeps_its_mark 3 (cherry-pick, revert, merge), real reftable, skip here premises as the probe measured; the refusal's two lines exactly; the printed --abort, typed, ends the act and nothing is found after
test_on_a_reftable_a_branch_and_a_tag_named_as_the_marks_are_no_act 1, real reftable, skip here the exact name on a reftable

The skip reason is printed: this git cannot make a reftable repository (git init --ref-format=reftable needs git 2.45 or later); the stand-in rows hold the refusal in its place, but not on Windows, where they skip too. The check is made of git itself: git init --ref-format=reftable, then git rev-parse --show-ref-format must say reftable.

The real-reftable rows' own steps were run once here, by emulation. A scratch test, never committed, ran the same steps on the files backend with the stand-in in place of a reftable: 4 passed. That run is how the show-ref --exists MERGE_HEAD exit 1 above was found. The real-reftable merge row now checks that MERGE_HEAD's file is gone after the abort, and asks show-ref only of the two ref marks.

The real-reftable rows ran, and passed, on CI. CI's Linux tests job runs pytest tests -q on the ubuntu-24.04 image, whose git is 2.55.0 (git version 2.55.0 in this PR's log).

  • This PR's run 39062357443 tested refs/remotes/pull/846/merge (9a752c50, this head merged into main fc867207): 12272 passed, 145 skipped, 1 xfailed, success.
  • Main fc867207's run 39060634121: 12254 passed, 145 skipped, 1 xfailed.
  • So +18 passed and +0 skipped: all 18 new cases passed there, including the four real-reftable rows, which skip here.
  • rv846 also ran them under git 2.47.1: 18 passed, with each real-reftable row listed as PASSED.

The run

/usr/bin/python3 -m pytest, Python 3.12.3, git 2.43.0, at a3683127 with a clean tree. The gh stub was first on PATH with GH_TOKEN=bogus and an empty GH_CONFIG_DIR; GIT_ALLOW_PROTOCOL was not exported. One reused basetemp, -o tmp_path_retention_policy=failed.

file cases
tests/test_family_operation_reftable.py (new) 18 (4 skip: the real-reftable rows)
tests/test_family_refuse_up_front.py 79
tests/test_family_holder_states.py 42
tests/test_family.py 106
tests/test_family_member_path.py (no git call before the member-path refusal) 43
tests/test_family_dry_run.py (a dry run runs no git that writes) 61
tests/test_refusal_default_remediation.py (yaml-unparsable through Family.open) 20
tests/test_repo_hygiene.py 77
tests/test_cli_reference.py 23
tests/test_agents_md_codes.py 47

512 passed, 4 skipped in 422.05s, one run, at a3683127.

Round 1, at 6a71ff8d (the same ten files, the same settings, clean tree): 512 passed, 4 skipped in 424.74s. The four skips are the real-reftable rows, with the new reason. The four skips are the real-reftable rows. git grep over tests/ for _operation_in_progress, operation-in-progress, _unparsable_in_an_act, CHERRY_PICK_HEAD and REVERT_HEAD names no other family test file (test_bootstrap_leg_moves.py is the assembly root's bootstrap.py).

Fails on base. The new file run on main's family.py (bad05d21), in a scratch clone: 10 failed, 21 passed, 4 skipped, against the new file and the 17-case files-backend set below. The 10 are:

  • the four stand-in refusals: add mounted IRQS and committed, exit 0, and bump committed members/IRRS, family.yaml, over a cherry-pick or a revert held in the ref store;
  • the family.yaml cherry-pick;
  • the 129 case and both failure cases (no ask was made);
  • the order test's nothing and revert cases.

The exact-name rows, the order test's cherry-pick case and all 17 files-backend rows pass on base, by design: they are the controls.

Mutants

Each was applied to a3683127's scripts/family.py in a scratch clone of its own, one at a time. Each was run against the new file and test_family_refuse_up_front.py::test_every_unfinished_act_is_named_and_refused_alike_in_both_runs (17 cases: every act on the files backend). Each is killed.

mutant result killed by
MA: E4's check removed (the two new lines in _operation_in_progress) 10 failed the four stand-in refusals (add went on, exit 0), the family.yaml one, the 129 case and both failure cases (no ask), and the order test's nothing and revert
MB: git rev-parse -q --verify <mark> in place of git show-ref --exists <mark>, with its exit 1 read as "not held" 13 failed test_a_branch_and_a_tag_named_as_the_marks_are_no_operation[add|bump|remove]: REFUSED family-operation-in-progress: a cherry-pick is unfinished ... in its ref store over a branch called CHERRY_PICK_HEAD, exit 2. Also the 10 that MA fails, since the stand-in answers only show-ref
MC: git's own failure read as "nothing in progress" (except CommandFailed: return False) 2 failed test_git_failing_to_answer_stops_the_verb_before_its_first_write[1-add] and [128-remove]: add mounted IRQS and went on, exit 0
MD1: the file check dropped for CHERRY_PICK_HEAD and REVERT_HEAD (the ref store asked alone) 6 failed test_family_refuse_up_front.py::test_every_unfinished_act_is_named_and_refused_alike_in_both_runs[cherry-pick-add|cherry-pick-remove|revert-bump|revert-remove]: the line says in its ref store where it said at <path>. Also the order test's cherry-pick and revert
MD2: the file checks dropped for every other mark (MERGE_HEAD, the rebases, BISECT_LOG, sequencer/) 13 failed 13 of those 17 rows: merge (3), rebase (2), am (2) and the three series (6). add mounted IRQS over a merge
ME: 129 read as git's failure 1 failed test_a_git_before_2_43_answers_from_the_files_alone: REFUSED a git or gh command failed ... git show-ref --exists CHERRY_PICK_HEAD ... exit 129
M10 (rv846's, round 1, applied to 6a71ff8d): _in_ref_store asks in Path.cwd() 10 failed (31 passed against a3683127's tests) the 8 stand-in cases (asked_in gives the checkout the run started in, not the holder), and the order test's nothing and revert (cwd is not the holder)

What it cannot tell

  • No real reftable ran on this host. Its git is 2.43.0, and no git 2.45 or later is on it. The refusal on a reftable is held here by the stand-in, which answers as lane 5's probe says a reftable answers. The four real-reftable rows ran only on CI (git 2.55.0), and that is known from the counts, not from a per-test line: CI runs -q, which names no passing test. Those rows call the check in process, on a plain repository. This PR's tests run no family.py verb against a reftable holder. rv846's own end-to-end probe did: it moved a holder to a reftable with git refs migrate, and each of the six pairs of verb and act was refused in both runs.
  • The effect sentences were measured on the files backend only (git 2.43, family.py add, bump and remove: a merge or rebase in progress, an excluded .gitmodules or a members that is a file makes them fail after their first writes #612): git refuses the partial commit during a cherry-pick, and takes it as the end of a revert. On a reftable they are assumed to hold, and were not measured.
  • A series committed by hand on a reftable (sequencer/ left, no CHERRY_PICK_HEAD) was not probed. The sequencer/todo row is a file on both backends (the probe), and is asked as before.
  • 129 is read as "a git before 2.43" from the exit code alone, not from git's words, which are translated. For this fixed command line, a git that knows --exists has no usage error to give. Measured on 2.40.4 only; 2.34 to 2.39 were not run.
  • The other family verbs on a reftable holder were not run end to end. This PR changes only the in-progress check. A grep of family.py for ref files read from disk (.git/HEAD, refs/heads/..., packed-refs, *_HEAD) finds none but these marks; everything else is asked of git.

Shared files

Checked from 6a71ff8d on 2026-10-17 at about 21:45Z with git merge-tree --write-tree. Every check is clean, so main is not merged in.

Closes #684

🤖 Generated with Claude Code

Summary by Sourcery

Refuse family mutations for unfinished cherry-picks and reverts whose operation markers are held in a reftable ref store.

Bug Fixes:

  • Prevent family add, bump, and remove from proceeding when a cherry-pick or revert is unfinished in a reftable-backed holder.

Enhancements:

  • Report whether unfinished operation markers are stored as files or in the ref store while preserving existing operation ordering and failure handling.

Tests:

  • Add coverage for reftable operation detection, exact marker names, holder working directories, older Git versions, ref-store failures, and file/ref-check ordering.

…t stopped in a holder whose refs are a reftable, asking its ref store for `CHERRY_PICK_HEAD` and `REVERT_HEAD` by their exact names where no file holds them (#684)

R612 E4, the last item of #684. Lane 5's probe (git 2.47.1, `git init
--ref-format=reftable`): a stopped cherry-pick and a stopped revert keep
`CHERRY_PICK_HEAD` and `REVERT_HEAD` in the ref store and in no file;
`MERGE_HEAD`, `rebase-merge/` and `sequencer/todo` stay files.
`_operation_in_progress` asked for files alone, so it found neither.

- Where either file is not there, `_in_ref_store` asks `git show-ref
  --exists <mark>` in that mark's own row: exit 0 is held, 2 is not, 129
  (a git before 2.43, which cannot open a reftable) is not, and any other
  exit is a `CommandFailed`, never "nothing in progress".
- The refusal, and `_unparsable_in_an_act`'s remediation, say git keeps
  the mark "in its ref store, not as a file"; a mark that is a file is
  named at its path, byte for byte as before. The file checks stay.
- New `tests/test_family_operation_reftable.py`: a stand-in git holds the
  refusal under both runs (this host's git, 2.43, makes no reftable), the
  exact-name rows run on every git, and the real-reftable rows skip, with
  their reason, where `git init --ref-format=reftable` fails.

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

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR makes family-operation-in-progress detection work with reftable repositories by querying the exact CHERRY_PICK_HEAD and REVERT_HEAD ref names when their files are absent, failing safely on unexpected git errors, and reporting the correct storage location. It adds stand-in and real-reftable-aware tests covering detection, compatibility, ordering, messaging, and write prevention.

Sequence diagram for reftable operation detection

sequenceDiagram
    participant Verb as family.py verb
    participant Detector as _operation_in_progress
    participant FS as Holder filesystem
    participant Git as git show-ref
    participant RefStore as Ref store

    Verb->>Detector: _operation_in_progress(root)
    Detector->>FS: Check CHERRY_PICK_HEAD file
    alt file absent
        Detector->>Git: _in_ref_store(root, CHERRY_PICK_HEAD)
        Git->>RefStore: show-ref --exists CHERRY_PICK_HEAD
        RefStore-->>Git: exit 0
        Git-->>Detector: held in ref store
        Detector-->>Verb: operation row with where=None
        Verb-->>Verb: Refuse before any write
    else file present
        FS-->>Detector: mark found at path
        Detector-->>Verb: operation row with file path
    end
Loading

Flow diagram for safe ref-store probing

flowchart TD
    A[_in_ref_store] --> B[git show-ref --exists mark]
    B -->|exit 0| C[Ref is held]
    B -->|exit 2| D[Ref is not held]
    B -->|exit 129| E[Older git: use file checks only]
    B -->|any other exit| F[Raise CommandFailed]
    C --> G[Refuse operation before first write]
    D --> H[Continue checking other operation marks]
    E --> H
    F --> I[Print git failure and stop before writes]
Loading

File-Level Changes

Change Details Files
Detect cherry-pick and revert operations stored as reftable refs instead of filesystem marks, while preserving exact-name semantics and fail-closed command handling.
  • Add _in_ref_store using git show-ref --exists and distinguish held, missing, pre-2.43, and unexpected failure exits.
  • Check the ref store only after the corresponding mark file is absent, retaining operation ordering and all existing file-based checks.
  • Represent ref-store detections without a path and update refusal and YAML-unparsable messages accordingly.
  • Keep MERGE_HEAD and other operation markers file-based because their ref-store lookup is not reliable.
scripts/family.py
Add comprehensive coverage for reftable-backed operation detection, compatibility behavior, exact-name handling, ordering, and failure modes.
  • Add stand-in tests for reftable-held cherry-pick and revert marks, including dry-run, remediation, and no-write guarantees.
  • Test git versions before 2.43, unexpected show-ref failures, exact-name protection against similarly named branches/tags, and file/ref-store query ordering.
  • Add conditionally skipped real-reftable tests for cherry-pick, revert, merge, and exact-name scenarios when the installed git supports reftable.
  • Update the holder-state test documentation to point to the dedicated reftable test module.
tests/test_family_operation_reftable.py
tests/test_family_holder_states.py

Assessment against linked issues

Issue Objective Addressed Explanation
#684 Refuse add when the tracked members file is absent from disk or still staged for deletion, before writing, and give the git mv remedy. ✅
#684 Detect the unmodeled holder states—an active bisect, an empty sparse-checkout rules file, and an empty rebase-merge/—and refuse with remedies that work for each state. ✅
#684 Recognize cherry-pick and revert marks held in a reftable, and identify a merge conflict in family.yaml with an appropriate refusal and remediation even when parsing the manifest fails. ✅

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-5, 2026-10-17T21:16:52Z) Independent review of #846 (#684, E4) at a3683127, for lane openRepoShape-4: READY

All my runs had finished before the swap message came in. No pytest, mutant or watcher process is left (ps -eo pid,cmd | grep -E 'pytest.*rv846|run9.sh rv846' prints nothing). I stopped the one leftover tail from my monitor by its PID, 2326244. I changed nothing in any branch, pushed nothing and posted nothing.

$R below is /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v117/rv846.

Findings, most severe first

No MUST and no SHOULD.

Checked and true

  1. Reading 129 as "not held" is right, and no other exit code is misread.
    • In git 2.47.1's builtin/show-ref.c (cmd_show_ref__exists):
      • 2 comes only from ENOENT or EISDIR;
      • 1 is any other lookup failure (a reftable read error leaves errno at 0, so git prints "Success");
      • 128 comes from die.
    • 129 is parse-options' answer to an unknown option. On git 2.43, git show-ref --no-such-option gives rc=129 and "error: unknown option `no-such-option'", followed by the usage text.
    • With this fixed command line, a git that knows --exists cannot exit 129.
    • A git older than 2.45 cannot open a real reftable. I ran /usr/bin/git 2.43 in a repository made by 2.47.1:
      • rev-parse --git-path MERGE_HEAD gives fatal: unknown repository extension found: refstorage, exit 128. That is the call _git_paths makes first.
      • show-ref --exists gives the same 128.
    • A signal (negative return code), 1 and 128 all raise CommandFailed.
    • The words a pre-2.43 git prints for --exists are dropped on purpose; family.py shows nothing for them.
    • I could not run a git older than 2.43, because the brief allows no other binary.
  2. MERGE_HEAD stays a file on a reftable (git 2.47.1).
    • A conflicted git merge and git merge --no-commit --no-ff each leave a MERGE_HEAD file. rebase-merge, rebase-apply, rebase-apply/applying, BISECT_LOG and sequencer/todo are files too.
    • CHERRY_PICK_HEAD and REVERT_HEAD are only in the ref store, with no file. That holds for a single stop, for both series, and in a linked worktree.
      • In the linked worktree the ask finds the mark (exit 0), and the main worktree does not see it (exit 2).
    • Logs: $R/logs/probe-s247rt.log and $R/logs/probe-wt247rt.log.
  3. The real-reftable rows pass under git 2.47.1: 18 passed in 18.63s, with each of the four rows listed as PASSED ($R/logs/headV247.log).
    • The skip condition is right: git init --ref-format=reftable and rev-parse --show-ref-format both arrived in git 2.45.
    • The rows are not empty passes: on base, the cherry-pick and revert rows fail under 2.47.1.
  4. family.py add/bump/remove: a members file deleted unstaged, a git bisect, an empty sparse-checkout file, an empty rebase-merge/ and a merge conflicted in family.yaml meet a refusal or remedy that does not fit (#626's gaps) #684's E4 line (re-read in the corrected issue-684.md; E4 has its Direction, and I found no separate Verify section in that file).
    • "Verify first": re-measured as in check 2.
    • The exact name: show-ref --exists answers 2 for git branch CHERRY_PICK_HEAD and git tag REVERT_HEAD on both backends. rev-parse -q --verify resolves both.
    • The harm, reproduced on base 6f64eea2. I took a real holder, moved it to a reftable with git refs migrate, and ran under git 2.47.1 ($R/logs/base247.log):
      • cherry-pick: --dry-run printed a plan, exit 0. The real run mounted, then hit fatal: cannot do a partial commit during a cherry-pick. and was rolled back, exit 2.
      • revert: add, bump and remove each committed, exit 0, and REVERT_HEAD was gone.
    • At the head, all six verb and act pairs are refused in both runs, exit 2, with nothing on stdout. The holder is left as it was. The remedy is the same as on the files backend, and only the line's in its ref store, not as a file differs.
      • My end-to-end test gave 18 passed ($R/logs/e2e-head247.log). Its merge control is refused by the file, and with nothing in progress all three verbs run on the reftable holder.
    • The effect sentences also hold on a reftable ($R/logs/probe-eff247rt.log):
      • cherry-pick: fatal: cannot do a partial commit, exit 128;
      • revert: the commit is made, REVERT_HEAD goes, --continue and --abort say "no cherry-pick or revert in progress", and UU notes.txt is left.
  5. The new tests fail on base and pass at the head.
    • Base 6f64eea2 with both test files copied in, git 2.43: 10 failed, 63 passed, 4 skipped. These are the PR's 10 ($R/logs/base243.log).
    • The same base under git 2.47.1: 12 of the new file fail, the 10 plus the two real rows.
    • My mutants ($R/mut/summary243.log):
      • Killed on git 2.43: M01, M02, M04, M06-M09 and M11-M18.
      • Killed as setup errors on git 2.43 ("31 errors": the module fixture's add fails): M03, M05 and M14.
        • On git 2.47.1, M03 and M14 are killed by the order test and the stand-in rows instead (7 failed each).
      • Survives on git 2.43: M10 only, killed on git 2.47.1 (see the first NIT).
  6. Runs.
  7. Merges, by merge-tree.
  8. Sonar-style checks, counted by hand.
    • Cognitive complexity: _operation_in_progress 13 (it was 9), _in_ref_store 3, _checked_no_operation_in_progress 2, _unparsable_in_an_act 3. The test functions are at most 4.
    • Duplication: no run of 5 or more non-import lines is shared with any tests/*.py file.
    • No composite asserts, no non-ASCII bytes added, and no added line over 79 characters.

Other observations

Could not verify

…that `git show-ref --exists` runs in the holder, the skip reason says where the stand-in runs, and `MERGE_HEAD` stays a file check for the measured reason (#684)

Lane 5's rv846 review of `a3683127` (#846 issuecomment-6241234307): READY, with no MUST and no SHOULD, and four NITs.

- NIT 1, the test gap. Nothing checked that the ask runs in the holder. rv846's M10 asks in `Path.cwd()`, and it passed every row on git 2.43.
  - The stand-in now logs `pwd -P` for each ask, and its four tests assert the holder's directory alone (`asked_in`).
  - The order test records the `cwd` that `_git_bytes` is given for each `show-ref`.
  - M10 on git 2.43: round 1's tests give 31 passed; these give 10 failed.
- NIT 2, the skip reason and the module docstring. They now say that the stand-in rows skip on Windows too. The docstring states a git before 2.45, not "this host".
- NIT 3, the comment on `REF_STORE_MARKS` and the real-reftable row's comment. `MERGE_HEAD` is a file on both backends: lane 5's probe shows it on git 2.47.1, and `is_pseudo_ref` in git's `refs.c` reads it from the file. So the file is the whole answer. Git 2.43's exit 1 is now given as a second reason.
- NIT 4 is the PR body's "Shared files" section, which this commit does not change.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-17T21:44:31Z) To lane openRepoShape-5, for rv846 (#846 issuecomment-6241234307): thank you. I took all four NITs in one commit, 6a71ff8d. It sits on a3683127 and was pushed as a fast-forward (ls-remote reads 6a71ff8d09bb0ebb3621cfcf775023d66c736a7b). Nothing was amended, and main is not merged in.

Run at 6a71ff8d: git 2.43, the PR's ten files, clean tree: 512 passed, 4 skipped in 424.74s. The four skips are the real-reftable rows, with the new reason.

The PR body is updated (gh pr edit). It adds a "Round 1" table, the M10 row under Mutants, your 2.47.1 run of the real rows, and your end-to-end probe under "What it cannot tell". Nothing else was changed.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T22:11:50Z) Re-check of #846 (#684, E4) at 6a71ff8d, for lane openRepoShape-4: READY

Lane 4 fixed the first three NITs as they said. I could not check the fourth (the PR body) from here. Every run is green, M10 now fails on both gits, and #846 merges clean with current main, #814, #815 and #828.

$R is rv846's scratch folder. I changed no branch, pushed nothing and posted nothing. No run of mine is still going.

Findings

None: no MUST, no SHOULD and no NIT.

Checked and true

The delta. 6a71ff8d is one commit on top of a3683127, a fast-forward (rev-list --count = 1). It changes scripts/family.py (+6/-2) and tests/test_family_operation_reftable.py (+59/-22).

NIT 1, the M10 test gap: done.

  • The stand-in now logs pwd -P for each ask. Its four tests assert asked_in(log) == {str(holder.resolve())}, at the refusal, family.yaml, 129 and failure rows.
  • The order test wraps _git_bytes and asserts each show-ref ran with cwd == holder.
  • M10 (asking in Path.cwd()) against these tests:
    • git 2.43: 10 failed, 21 passed, 4 skipped. It was 31 passed before. The failures are the 8 stand-in rows and the order test's nothing and revert. Each fails on the new assert, for example {'.../rv846r-mut'} == {'/tmp/.../InkRouter'} ($R/mut2-243/M10-asked-in-the-cwd.log).
    • git 2.47.1: 12 failed, 23 passed: those 10 plus both real-reftable act rows ($R/mut2-247/M10-asked-in-the-cwd.log).

NIT 2, the skip reason and the docstring: done.

  • The skip reason now reads, as printed on git 2.43: "...the stand-in rows hold the refusal in its place, but not on Windows, where they skip too".
  • The module docstring now says "A GIT BEFORE 2.45 MAKES NO REFTABLE", calls 2.43 "the git these tests were written on", names CI's 2.55, and says the stand-in rows skip on Windows.
  • The exact-name rows and the order rows really do have no skip marker.

NIT 3, the MERGE_HEAD reason: done.

  • family.py:1006-1013 now leads with "a file on both backends". It cites the git 2.47.1 probe and is_pseudo_ref. Git 2.43's exit 1 is the second reason, with "git 2.47.1 answers 2 there".
  • This matches what I measured last round ($R/logs/probe-s247rt.log and probe-s247f.log), and refs.c at 2.47.1.
  • The real-reftable row's comment no longer cites 2.43.

Nothing else changed in behaviour.

  • family.py at the two commits has the same AST and the same tokens once comments are left out:
    • ast.dump is equal: True;
    • non-comment tokens are equal: True.
  • In the test file, the delta only adds assertions, the two helpers (asked_where and asked_in), the _git_bytes wrapper and docstring text.
  • No non-ASCII bytes and no added line over 79 characters. body-guard.py exits 0. No composite asserts.
  • Duplication: no run of 5 or more non-import lines is shared with any tests/*.py file.

Runs at 6a71ff8d:

  • git 2.43: the new file, holder_states, refuse_up_front and the trio gave 282 passed, 4 skipped in 416.01s. The 4 skips are the real-reftable rows ($R/logs2/head243.log).
  • git 2.47.1: the new file, holder_states and refuse_up_front gave 139 passed, 0 skipped in 364.70s. All four real-reftable rows show PASSED ($R/logs2/head247.log).
  • Merged with main 0dee22b3: main had changed scripts/repo_shape.py and tests/test_refusal_default_remediation.py, so I also ran the merged tree (commit e3622006, tree e24ca2d1). The same set plus test_refusal_default_remediation.py gave 303 passed, 4 skipped in 405.66s ($R/logs2/merged243.log).

Merges, by merge-tree --write-tree, all clean:

Could not verify

  • NIT 4, the PR body's "Shared files" section. I make no GitHub calls. The commit message says the body is not changed by the commit. The only saved copy I found ($Q/v118/t114/prs-open.json, taken at 21:38Z with head a3683127) still has the old section; I extracted it to $R/pr846-body-2138.md. The coordinator can read the live body against the merge results above.
  • CI's run on 6a71ff8d, and any Windows or macOS run.

Coordinator's note (lane 5) on NIT 4: I read the live body. Its "Shared files" section is redone from 6a71ff8d against main 190133d5, and it lists #849, #828 and #814 (now clean) and #795 as landed. NIT 4 is done.

@brettheap
brettheap marked this pull request as ready for review October 17, 2026 23:17
@sourcery-ai

sourcery-ai Bot commented Oct 17, 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 1 hour and 30 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit d8b8eb3 into main Oct 17, 2026
5 checks passed
@brettheap
brettheap deleted the fix/684-e4-reftable-operation branch October 17, 2026 23:20
brettheap pushed a commit that referenced this pull request Oct 18, 2026
…trap_after_bump.py` imports both `appended_to_command_scope` and `needs_a_byte_name` (#715)

Main moved from `0dee22b3` to `ca47b71d` while round 3 was paused (#846, #779, #772, #738, #842, #814, #790, #820, #774, #830 and #841). `git merge-tree` of `8b4dbcf6` with it stopped in one path:

- `tests/test_bootstrap_after_bump.py`, its imports. Main's `aec3b38f` (tests append what they tell git to the command scope) added `appended_to_command_scope` to the `from conftest import (...)` list. This branch added `from test_bootstrap_spelling import needs_a_byte_name` below it (#715, round 1). Both are kept.

`scaffold-project.py`, `scripts/bump-leg.py` and `scripts/shape_materialize.py` merged without a conflict.

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 18, 2026
…w's path under `members/`, a link or checkout in a move's way, an unmerged entry and a staged or unmerged `.gitmodules` are named first, so each remedy types clean (#840) (#865)

# Title

family.py add: a section astray beside its gitlink, a variant of a row's path under `members/`, a link or checkout in a move's way, an unmerged entry and a staged or unmerged `.gitmodules` are named first, so each remedy types clean (#840)

# Body

Was stacked on #828, which landed on main as `9b0a7038` (its final head `3546118b`). GitHub retargeted this PR to main, and the branch is restacked onto it (`6ef6e4c1`).

**Goal (#840):** where `add` refuses a member recorded in another case, no printed command leaves a section of `.gitmodules` naming another spelling, moves a live checkout out from under its `.git` file, or fails through a link.

#840 lists six states, and a seventh came from lane 2's rv828 review (SHOULD 3). The coordinator added an eighth on the way: a link at a variant the index holds as the member's gitlink. Each state was first typed as printed at `ce0f2a5f`, on git 2.43, with the gate run in all three modes and the same `add` run after. The link states were also typed on git 2.47.1. Every change is in `scripts/family.py`, which is not a pinned copy, so no `upstream-changed` row follows. The validator is not touched.

The commit after the restack, `231c404c`, adds one test for rv865b's NIT 1, `test_a_second_paths_checkout_is_asked_in_its_own_spelling`. The state is a gitlink at `Members/IRRS`, committed, with its skip-worktree bit set, and `Members` a link to `members`, which makes `Members/IRRS` resolve to the member's own checkout, as a disk that folds case would.

The test asserts the whole text:
- the link is moved out first;
- then `git rm -r --cached --sparse -- Members/IRRS` and its commit;
- and no line says to delete a checkout.

It then types that text as printed, and the gate passes. Mutant C5, which asks for the checkout whatever the spelling on disk, is caught by it.

The sixth and seventh commits, `adb20242` and `4e92867a`, answer rv865b's findings. The round is two commits because the first was a pause's work in progress, and nothing is amended.
- **MUST 1.** The second path's `git rm -r --cached` now takes `-f` where the move back's would. That is where the index stages the gitlink at a commit HEAD does not record there and a checkout is on disk at it (`_forced`, #828's rule). It prints the move back's own words on what `-f` does (`FORCED_SAID`, now one constant for both), and `--sparse -f` where both apply. At `38e0d503`, plain `git rm --cached` was refused there (`staged content different from both the file and the HEAD`, exit 1), for (a) staged with an untracked file or a commit of its own, and for K2 reset with work in the checkout.
- **SHOULD 1.** The checkout a second path leaves on disk is named to delete by hand in the same round, with its `git -C <path> status`, before the update.
  - At `38e0d503`, K2 committed's update pointed the store that checkout shares with the member at `members/IRRS`, so the status printed nothing while `WIP.txt` was there. The next run then said to delete the checkout.
  - The update is not left to the next run instead, because nothing would print it there once the checkout is gone, and the member would be checked out nowhere.
  - (a), (b), K2 committed, reset and foreign, and the sparse state now each take one round.
- **NIT 1.** The count for `_members_spellings` is corrected and recounted (line 4).
- **NIT 2.** `typed_as_printed`'s `def` line is `f7072d08`'s again.
- **N2.** rv865b ruled it acceptable as is, so nothing changed for it.

The fifth commit, `38e0d503`, answers rv865b's findings:
- **MUST 1.** A gitlink at a case variant of a row's path, beside the member's own gitlink at that path, is now taken out of the index with `git rm -r --cached -- <variant>`, the gate's own line for a second path. It is no longer renamed with `git mv`. At `f7072d08`, `git mv` carried the section that gives its path along under the same name, and the take-out then stripped it, so `renamed-1` was a gitlink no section names. The gate passed, but a plain clone's `git submodule update --init --recursive` exited 128, and that is the holder's own CI path (`validate.yml`). The checkout left on disk is the next run's `CHECKOUT`, deleted by hand. See line 1.
- **K2.** The clause "never point back a section whose path is a gitlink in the index" is dropped, together with the change above, as rv865b rules. Its four states are tests now.
- **NIT 1.** `_variant_in_members` asks `os.path.islink` before `samefile`, so a link `members/irrs -> IRRS` is named again on Linux. See line 3.
- **NIT 2.** rv865b's surviving mutants X1, X2 and X6 are pinned.
- **NIT 3.** A take-out printed after the point-back's own commit is committed too: that commit takes a staged `.gitmodules` into HEAD.
- **NIT 4.** The closing guard takes #839's form, `[ ! -L V ] && { [ ! -e V ] || [ V -ef R ] || [ -e V/.git ]; } || mv -- V ../<V>`, with its words. That is the form lane 5's #839 prints in `validate-family.py` (`77f8a6c8`), so the two texts agree. A link at the variant is now moved in the first round. See the added state.
- **NIT 5.** This body: line 1's claim now carries its caveat, and the merge checks name main `3bf2d3c9`.

The third and fourth commits, `dde7e9cc` and `f7072d08`, answer rv865's findings:
- **MUST 1.** A section beside the member's own section is no longer pointed back. It is taken out with the gate's own `--unset-all` lines. Only the section named after the row's path is pointed back, and only where no section gives that path. See line 1.
- **SHOULD 1.** On a disk that folds case, the member's own checkout listed in another spelling is no longer refused. See line 3.
- **NIT 1.** R7, R8, R9, R10, R13, R23, R25, R25b, R26 and R42 are pinned by tests now. R20 and R21 are not, and "Not here" says why.
- **NIT 2.** This body's mutant count and merge checks are corrected.
- **NIT 3.** `_checked_gitmodules_as_staged` has base's `def` line again, and its complexity went down. See line 4.

A point-back printed after a commit is now committed too. That earlier commit (a pending change, or the index's rename or removal) takes a staged `.gitmodules` into HEAD. At `4268f804`, a staged section edit beside a committed `Members/README` left the point-back staged, and the gate found `member-gitmodules-absent` in every mode.

**Measured at `ce0f2a5f`.** The gitlink stays at `members/IRRS`, and the section is hand-edited to `Members/IRRS`.
- Committed or staged, `add` exits 0 and commits that `.gitmodules` beside the new member.
- The gate exits 1 in every mode: `gitmodules-path-unnamed`, plus `member-gitmodules-absent` once committed.
- With the edit not staged, `gitmodules-unstaged` comes first; typed, it leads to the staged state, and `add` goes through.
- A section giving `members/irrs` behaves the same way.

**What changed.** `_sections_astray` finds each section that gives a case variant of a row's path, where the index holds the member's gitlink at that path. `_row_sections_astray` then tells two cases apart, as the gate does:
- **No section gives the row's path.** This is the gate's `member-gitmodules-absent` case. The section named after the row's path, the name `add` gives a member's section, is pointed back with the gate's own line: `git config -f .gitmodules submodule.members/IRRS.path members/IRRS`, then `git add -- .gitmodules`. Its place is "in .gitmodules, its gitlink at its row's path". A gitlink at its path is taken out of the index first (below), which leaves the section where the point-back finds it, in the same round.
- **A section gives the row's path.** Each other one is a second section beside it, which the gate's `gitmodules-path-unnamed` calls `STRAY`. It is taken out, every key it holds, with the gate's own lines: `git config -f .gitmodules --unset-all submodule.Members/IRRS.path` and the same for `url`, each key quoted for a POSIX shell, then `git add -- .gitmodules`. Its place is "in .gitmodules, beside the section giving its row's path".
- **A gitlink at such a path, beside the member's own gitlink,** is a second path to the member's directory (fix round 2). It is taken out of the index alone, `git rm -r --cached -- Members/IRRS`, as the gate's own line does (`UNSTAGE_SECOND`). It takes `--sparse` where the sparse rules leave it out or its bit is set, and `-f` where the index stages it at a commit HEAD does not record there beside a checkout (fix round 3). That comes before the take-out, and `git rm --cached` leaves the section for it. A checkout there that `git submodule add` or `git mv` made, on disk in its own spelling, is named to delete by hand in the same round, with its `git -C <path> status`, before the update (fix round 3). A clone of its own is not named: the next run moves it out whole.
- Once the member's own section is pointed back, any other variant section is beside it, and is taken out the same way.
- A variant section that is neither is left to the gate, which names it and prints no command for it.

The code is `family-members-dir-variant`, as for any other path under another spelling of `members/`. Each part commits where HEAD's copy holds what it changes, or where a commit printed before it took a staged `.gitmodules` into HEAD: a pending change, the index's rename or removal, or, for a take-out, the point-back's own commit (fix round 2). After a staged edit alone there is nothing to commit, and `git commit` would fail. A change nobody has staged is staged first, as #685 does for the move back.

A point-back closes with the update, `git submodule update --init --recursive`, where nothing is checked out at the row's path. That is the state of a fresh clone: no section gives `members/IRRS`, so nothing is checked out there. Beside a move back of another member, the one closing update names both rows. A take-out needs no update: the member's own section gave its path all along.

**Why the take-out (rv865's MUST 1).** At `4268f804`, every variant section was pointed back, whatever submodule it belonged to. Measured again on that head with the text checks left out, on git 2.43: after `git submodule add <IRSS's remote> Members/IRRS` (kept, at `members/irrs`, or with its checkout deleted), or with the section `git rm --cached` leaves behind, the remedy typed as printed gave `members/IRRS` twice. The gate exited 1 in every mode, and a recursive clone of the pushed branch exited 128: `Fetched in submodule path 'members/IRRS', but it did not contain <commit>`.

**Caveat on fix round 1's claim (rv865b's MUST 1).** The first fix round said that the same four states and a fifth type clean, that the recursive clone checks `members/IRRS` out, and that the gate passes. That held for a recursive clone only. In (a) and (b), the take-out ran after `git mv` of the same gitlink. It stripped the section `git mv` had carried along, and left `renamed-1` a gitlink no section names. A plain clone's `git submodule update --init --recursive` then exited 128 (`No url found for submodule path 'renamed-1' in .gitmodules`), and so did `git submodule status`, in the holder and in the clone. The gate cannot see this, since it asks only gitlinks under `members/`.

**Since fix round 2,** that gitlink is taken out of the index and never renamed. Measured at `4e92867a` on git 2.43 and 2.47.1, the five states and two more type clean:
- the two more are a staged section taken out after the point-back's commit, and a staged one whose name needs quoting;
- each takes one round; in (a) and (b), the checkout `git rm --cached` leaves is deleted by hand where the text says (two rounds at `38e0d503`);
- a recursive clone checks `members/IRRS` out from IRRS's remote at its commit;
- a plain clone's `git submodule update --init --recursive` exits 0 with every member checked out;
- `git submodule status` exits 0 in the holder and in both clones;
- the gate passes in all three modes, and the same `add` goes through.

rv865b's four K2 states (the gitlink at `Members/IRRS` beside the member's own, with the member's section astray: committed, reset, checkout deleted, and beside a foreign section) do the same, in one round each.

**Work in the second path's checkout (fix round 3).** These states type clean in one round too:
- (a) staged with an untracked file;
- (a) staged with a commit of its own;
- (a) staged and left out by the sparse rules, which gives `--sparse -f`;
- K2 reset with work;
- K2 committed with work.

Where the text says to delete the checkout, its `git -C Members/IRRS status` still names the untracked file, and its commit is kept in its store.

**Measured at `ce0f2a5f`.** `Members/X/notes` is committed, and a checkout that `git submodule add` made at `Members/X/Y` is taken out of the index. #574's `git mv -- Members/X <new name>` typed as printed moved the checkout too. `git -C renamed-1/Y status` then said `fatal: not a git repository: .../renamed-1/Y/../../../.git/modules/Members/X/Y`.

A checkout the index still records under that directory is moved by `git mv` with its `.git` file mended: `git -C renamed-1/Y status` exits 0.

**What changed.** Each checkout git does not track, inside a directory that a `git mv` (`RENAME`) or a plain `mv` (`MOVE`) moves, is named first (`CHECKOUT_FIRST`, `_checkouts_in_the_way`). It is to be deleted by hand once nothing only it holds is needed, as #828's `CHECKOUT` says of one found on disk. Its commits stay in the holder's store. `git -C <checkout> status` shows what is not committed.
- The walk does not follow links, and does not enter a directory that holds a `.git` entry, so a checkout's own legs are not named again.
- A gitlink the index holds is not named, and neither is a clone of its own, whose `.git` is a directory: the move takes it whole.
- The moved directory itself is not entered when it holds a `.git` entry.
- The plain `mv` case is the same fault, so it is covered too.

The fix round pins the last three (rv865's R7, R8, R9, R10) with a clone of its own at `Members/X`, legs checked out, and with a plain `Members/X` holding such a clone and a link to a directory outside the holder. In both, only `mv -- Members/X <new name>` is printed. #828's `test_a_checkout_under_an_index_entry_is_the_indexs_to_name` did not pin R7: that directory has no subdirectories.

**Measured at `ce0f2a5f`,** beside the row `members/IRRS`. In each of these states `add` exits 0, so a person meets nothing:
- a file `members/irrs` in the index;
- the same file on disk;
- that file in HEAD alone, its removal staged;
- an untracked directory there;
- a second gitlink there;
- the member's own gitlink moved there, #646's state under `members/`.

The gate passes the first four. It refuses the second gitlink in every mode (`member-gitlink-unnamed`). For the member's own gitlink it exits 2 in members mode, and `member-gitlink-unnamed` under `--no-members`.

**What changed.** `_spelled_target` takes the rows' paths. Under `members/` itself, a case variant of a row's path that is no row's own path is now a target, as a variant of the mount path already was.
- It flows through the index, HEAD and disk checks, so each state gets #574's remedy.
- The member's own gitlink gets #646's move back.
- A section whose gitlink's removal is staged gets #828's `IN_GITMODULES` put-back.
- A second gitlink that `git submodule add` made there, with its own section, is taken out of the index, its section is taken out, and its checkout is named to delete by hand in the same round (line 1, fix rounds 2 and 3). A second gitlink with nothing on disk is taken out of the index as before.

The code is `family-members-dir-variant`. Its first sentence says what was found (`_lead`): "a case variant of the path of a `members:` row under members/, the same path as that row's on a case-insensitive disk". It is joined to the other-spelling sentence where both are found. A folding disk, asked in process with `disk_as`, gives the same text.

**On a disk that folds case (rv865's SHOULD 1),** `_variant_in_members` lets through an entry of `members/` that the disk takes for the row's own path (`os.path.samefile`, as `_members_dir_on_disk` asks of `members/`). macOS and Windows list a directory in the spelling it was made in. So after `git mv members/IRRS members/irrs` and line 3's move back, whose closing guard `[ members/irrs -ef members/IRRS ]` is true there, the member's only checkout is listed as `members/irrs`. At `4268f804` the next `add` refused it as on disk and untracked, every time. Now it goes through. On Linux the two are two paths, `members/IRRS` holds nothing, and `members/irrs` is still named.

**A link is never the row's own (rv865b's NIT 1).** `samefile` follows a link, so at `f7072d08` a link `members/irrs -> IRRS` was let through on Linux and `add` went through beside it; `4268f804` named it. `_variant_in_members` now asks `os.path.islink` first, as `_members_dir_on_disk` never takes a link for `members/`. The link is named on disk, `on disk and untracked: members/irrs (a symlink)`, and moved out of the holder, the link alone.

**Measured at `ce0f2a5f`.** `git mv members Members` is committed, then `.gitmodules` is left unmerged (stages 1, 2 and 3) and deleted from the work tree. `_checked_gitmodules_as_staged` leaves a missing copy to git for `add`, so the rename back was printed. Its commit failed: `Committing is not possible because you have unmerged files`. In #646's state the commit fails the same way, and the update says `No url found`.

**What changed.** `_gitmodules_first` asks first. For this state it raises `gitmodules-missing` (`_gitmodules_missing`) with the restore git takes there, as `remove` prints it: `git checkout --merge -- .gitmodules`, resolve it, `git add .gitmodules`.

The detail says why for `add`. `remove`'s text is unchanged, byte for byte. An unmerged copy is now asked first wherever the spelling refusal is raised, not only beside a gitlink, since any printed commit fails beside it.

**NIT 3.** `_checked_gitmodules_as_staged` has the `def` line base had, `def _checked_gitmodules_as_staged(root: Path, verb: str) -> None:`, and no `spelled` parameter. The missing-file refusal moved into `_gitmodules_missing`, which it raises for `remove` and `_gitmodules_first` raises for `add`. By hand, its cognitive complexity went from 35 at `4268f804` (34 at base) to about 18, so Sonar keeps that issue as the old one, lower. The new functions are under 15 by hand: `_gitmodules_missing` about 12, `_row_sections_astray` about 8 (9 before fix round 2 dropped K2's clause), `_variant_in_members` about 9 (8 before NIT 1's `islink`).

Fix rounds 2 and 3, measured with rv865b's estimator (`cogc.py`, comprehensions not counted):
- **New functions:** `_second_gitlink` 2, `_second_path_part` 5, `_second_checkout_part` 2, `_forced` 1, `_removed` 2.
- **`_point_back_part`:** up 1, to 3.
- **`_spellings_refusal`:** down 2, from 38 at `f7072d08` to 36, since the removal's condition moved into `_removed`.
- **`_move_back_line`:** unchanged at 9.
- **`_members_spellings`:** from 41 at `f7072d08` to 45 at `38e0d503`, then 46 now. Round 2 added 4: its `if`, nesting, its `and`, and `out = … or …`. Round 3 adds 1, the `and` in `checkout = present and …`. The fix round 2 body said 3. Its `def` line is unchanged, and it was over 15 before this PR.
- **`typed_as_printed`:** 31, as at `f7072d08`, and its `def` line is that commit's again.

**Measured at `ce0f2a5f`.** `git rm .gitmodules`, with HEAD holding the file. The printed rename back, or move back, and its commit took `.gitmodules` out of HEAD with every member's section. The gate then found `member-gitmodules-absent` for each member.

With `git rm --cached .gitmodules`, a copy stays on disk, untracked:
- beside the rename back, `git mv` staged that copy again, and the result passed;
- beside the move back, the commit took the deletion as well, and left the copy untracked.

**What changed.** `_gitmodules_beside` tells the two apart: `GITMODULES_DELETED` where the work tree has no copy, and `GITMODULES_UNTRACKED` where it has one.
- Deleted: the refusal's first part puts HEAD's copy back, `git checkout HEAD -- .gitmodules`, as `remove` prints it and as #767 does for a copy deleted from disk.
- Untracked copy: `gitmodules-unstaged` comes first ("its deletion is staged"), since which copy to keep is the person's.

Both apply wherever anything is found, so a lone `Members/README` rename is covered too: its commit took the deletion as well.

**Measured at `ce0f2a5f`, which does not answer it.** #828's set-aside part does not reach this state. `git rm -r --cached --ignore-unmatch -- Members/IRRS Members/IRSS` exits 128: `fatal: not a git repository: Members/IRRS/../../.git/modules/members/IRRS`.

The rest of the line then ran, but the gitlinks stayed under `Members/`, and the next `add` refused again. Through the same link, plain `git mv -- Members/README x` moves the file in from where the link points.

**What changed.** A symbolic link that the index holds entries under is named on disk and moved out of the way first (`LINK_OUT`, `_disk_found`). The words are those `member-path-unaddable` has for `members` itself: "must be a plain directory of the holder's own".

`_on_disk_as_named` now takes nothing beyond a link as on disk. So a file under it is taken out of the index (`git rm -r --cached`), not given a `git mv` that would fail once the link is gone (`bad source`).

Typed as printed:
- the link moves;
- the move back runs with both sections;
- the guarded `mv` moves nothing;
- the update checks both members out at their rows' paths.

The directory the link named is left where it is. A checkout there was unusable already, since its `.git` file is relative.

**Measured at `ce0f2a5f`.** After `git mv members/IRRS Members/IRRS`, an unmerged file at or under `members/IRRS` left the row neither blocked nor a stray. So the gitlink kept #574's `git mv -- Members/IRRS <new name>`, which took it away from its row, and the commit failed.

An unmerged file at `members` itself, off disk: each `git update-index` ran, and the commit failed. On disk, `member-path-unaddable` printed `git mv -- members <new name>`, which exits 128 (`fatal: conflicted`).

**What changed.**
- `_rows_held` counts a file or link at any stage as in the way.
- `_set_aside` takes an unmerged entry out of the index, every stage, with `git rm -r --cached`, which git allows there (`ASIDE_UNMERGED`). Where the entry is on disk as the index holds it, it is then moved out of the way with `mv` (`ASIDE_UNMERGED_MV`).
- Measured why the `mv` is needed: a file left at the row's path made the update fail (`could not create directories for members/IRRS/.git`). One left under it stayed in the member's checkout, which `git status` then lists as modified.
- The removal is a change to commit only where HEAD holds the entry. With the move staged, no commit is printed.
- An entry held at stages 1 and 3 alone, which this side deleted, is unmerged too.
- `_members_is_a_file` gives an unmerged `members` the same two steps, plus the commit on its own where HEAD holds the file.

**Already answered at `ce0f2a5f`.** Measured on git 2.43 and 2.47.1, with #646's state and a link `Members/IRRS` on disk, pointing to the member's checkout or to nowhere. git 2.47.1 refuses `git status` itself: `error: expected submodule path 'Members/IRRS' not to be a symbolic link`. `add` reads no `git status` on this path, and gives its spelling refusal first: no git `fatal:` and no traceback, for `add --dry-run` and the real run alike.

At `f7072d08`, typed as printed on 2.47.1:
- the move back ran;
- the closing guard kept a link to the row's own directory (`[ Members/IRRS -ef members/IRRS ]` follows it);
- the next `add` named it on disk, with its `mv`;
- typed, the gate passed and `add` went through: three rounds.

**Since fix round 2 (rv865b's NIT 4),** the closing guard takes #839's form, which asks `[ ! -L Members/IRRS ]` first, so a link there is always moved, the link and never what it points at. The guard now moves the link in the first round, and the next `add` goes through: two rounds, typed under dash and under bash, on git 2.43 and 2.47.1. A link to nowhere was moved in the first round before, and still is.

`test_a_link_at_a_variant_the_index_holds_is_refused_first` pins this. Its refusal check is `refused_untouched(..., status=False)`, so `snapshot` skips `git status`, which git 2.45.1 and later refuse here, and keeps every other key.

The guard holds `; ` inside its braces, so the tests' `typed_as_printed` now splits the remedy into parts at `; ` outside backticks only (`remedy_parts`).

**New file `tests/test_family_add_spelling_leftovers.py`, 70 cases.** Each typing test types every printed command as printed, with `typed_as_printed`, in the state it describes. Each one then checks that every member is where its row says, that the gate passes in all three modes, and that the same `add` goes through.

| Line | Cases |
|---|---|
| 1 | committed, staged, not staged, under `members/` itself, a fresh clone, beside a move back; a staged edit after a pending change, a rename and a removal; rv865's four states, the point-back beside a take-out, a staged take-out after the point-back's commit, and a staged one whose name needs quoting; rv865b's four K2 states; the second gitlink left out by the sparse rules or marked by hand; five states with work in the second path's checkout; a staged second gitlink with nothing on disk, which takes no `-f`; a clone of its own at a second path, moved whole on the next run; a second gitlink marked at `Members/IRRS` with `Members` a link to `members`, which names no checkout (fix round 4); the section named after the row beside one giving its path; a variant section left to the gate; an unreadable `.gitmodules` |
| 2 | under a `git mv`, under an `mv`, a tracked gitlink's control, and a clone of its own moved whole, alone and beside a link |
| 3 | six states, a section whose gitlink's removal is staged, both disks in process for two of them, the member's checkout a folding disk lists in another case, on both disks, and a link `members/irrs -> IRRS` |
| 4 | one case |
| 5 | beside the rename back, the move back and a file, and the untracked copy |
| 6 | the gitlinks alone, and a file under the link |
| added state | two cases, typed under dash and under bash |
| 7 | five unmerged states, two with the move staged, one at stages 1 and 3 alone; `members` off disk, and `members` on disk with and without HEAD's file |

Every case of line 1 that a section beside the member's own or a second path takes part in (rv865's four, the two staged take-outs, the K2 states, the sparse ones and the work states) ends the same way (`typed_through`):
- HEAD is pushed to a branch of its own;
- a recursive clone checks `members/IRRS` out from IRRS's remote at its commit;
- a plain clone, as the holder's CI makes it, runs `git submodule update --init --recursive`, which must exit 0 with every member checked out;
- `git submodule status` must exit 0 in the holder and in both clones.

Where the text says to delete a checkout by hand, it is deleted there, after the parts before it and before the update. In the work states, the hand step first asserts that `git -C <checkout> status` still names its untracked file.

**Changed:** `tests/test_family_add_move_back_sections.py`. Its `set_aside` builds the unmerged part (`unmerged=`, `moved=`). In fix round 2, one docstring names the guard's new form.

**Changed, in the second commit:** `tests/test_family_members_case_variants.py`. `refused_untouched` takes a keyword `status=True` and passes it to both of its snapshots. The link test calls it with `status=False`, and the narrower helper it had (`refused_beside_a_link`) is gone. In the third commit, its one literal `Spelled` tuple gains the new `keys` field, and in fix round 3 the new `checkout` field.

**Changed, in fix round 2:** `tests/test_family_add_member_variant.py`.
- `guarded` and the closing words take #839's form.
- `typed_in_sh` types into the module's `SHELL`, `sh` unless a test sets `dash` or `bash` with `monkeypatch` (fix round 3). Its `def` line and `typed_as_printed`'s are `f7072d08`'s, so the remedy's split is their only change (rv865b's NIT 2).
- The remedy is split into parts at `; ` outside backticks (`remedy_parts`).

No test needs a git newer than 2.34, but for the sparse case. The second gitlink left out by cone-mode rules needs git 2.38 or later (`sparse_cone`, as #828's tests). So does the work state left out by those rules. The sparse cases skip where git has no `git sparse-checkout check-rules`, as does the staged second gitlink marked by hand. Symlink cases skip on Windows, the added state skips where its shell is missing, and states that need two names differing only in case skip on a disk that folds case, as in #828's files.

**Fails on the base.** The first 35 cases, run against `ce0f2a5f`'s `family.py`: **33 failed and 2 passed** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/base.log`). The two passes pin what the base already does: the added state and line 2's control.

**Fails on `4268f804`.** The fix round's 16 new cases, run against `4268f804`'s `family.py`: **9 failed and 7 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/oldfinal243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/oldfinal247.log`).
- The 9 that fail are the fixes: rv865's four states and the point-back beside a take-out, the section named after the row, the variant section left to the gate, the staged edit after a rename, and the folding disk's checkout.
- The 7 that pass pin code that was already right, as rv865 found it: the staged edit after a pending change (R26), the unreadable `.gitmodules` (R13), the two clones (R7 to R10), the two new unmerged states (R23, R42), and the Linux side of the folding-disk test.

**Fails on `f7072d08`.** Fix round 2's 17 new or changed cases, run against `f7072d08`'s `family.py`: **16 failed and 1 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/old243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/old247.log`). The one that passes is the staged edit after a removal (X2), which pins code that was already right.

A copy with no check on the text was also run on both heads, git 2.43: each state is typed as printed, round after round, then put through the same checks (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/test_w865f_r2_behaviour.py`).
- At `f7072d08`: 11 of 16 failed (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/bh-old243.log`).
  - The plain clone's update failed for (a), (b) and both sparse states.
  - HEAD's `.gitmodules` was not the holder's for the K2 states committed, reset and foreign, and for the staged take-out after the point-back's commit, which left `stale` in HEAD.
  - The link `members/irrs` was let through.
  - The link at the variant took 2 rounds of typing before `add` went through, in dash and in bash.
- At `38e0d503`: 16 passed (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/bh-head243.log`).
- The 5 that pass at `f7072d08` are (c), (d), the point-back beside a committed take-out, the staged name that needs quoting (X1, X6: `f7072d08` already quoted the key and printed no commit), and K2 with its checkout deleted, which took two rounds there and ended right.

**Fails on `38e0d503`.** Fix round 3's new and changed cases, run against `38e0d503`'s `family.py`:
- **Of the 20 cases with the round's changes, 12 failed and 8 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old247.log`).
  - The 12 that fail are (a), (b), K2 committed, reset and foreign, both sparse states, and the five work states.
  - The 8 that pass are the BESIDE states with no second path, K2 with its checkout deleted, and the link test under dash and bash, none of whose text or typing changed.
- **The two cases added last both fail** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243b.log`).
- **A copy of the five work states with no check on the text** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/test_w865f_r3_behaviour.py`) types round after round, and its hand step asserts that the status names the untracked file:
  - At `38e0d503`, all five fail (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243b.log`). Four stop at `git rm -r --cached` (`staged content different from both the file and the HEAD`): (a) staged with an untracked file, with a commit of its own, left out by the rules, and K2 reset with work. K2 committed with work fails the hand step: `WIP.txt on disk but not in its status`.
  - At `4e92867a`, all five pass (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/bh-head243.log`).

Every run went through `run9.sh` or `run9s.sh`, with the real `gh` unreachable and basetemps under `v110/bt/`.

| Log | Git | Head | Files | Result |
|---|---|---|---|---|
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/final243.log` | 2.43 | `4e92867a` | the same 15 files, 3 shards | 721 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/final247.log` | 2.47.1 | `4e92867a` | the same 15 files, 3 shards | 721 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/final243.log` | 2.43 | `38e0d503` | the same 15 files, 3 shards | 714 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/final247.log` | 2.47.1 | `38e0d503` | the same 15 files, 3 shards | 714 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/probe-head-243.log` | 2.43 | `38e0d503` | rv865b's probe, which records and never asserts (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/probe-head-243.jsonl`) | 29 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/final243.log` | 2.43 | `f7072d08` | 15 files, 3 shards (below) | 703 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/final247.log` | 2.47.1 | `f7072d08` | the same 15 files, 3 shards | 703 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/full243.log` | 2.43 | `dde7e9cc` | 15 files, 3 shards: the 3 touched test files, the other 5 `test_family_add_*` files, the trio, `test_family.py`, `test_family_remove_gitmodules.py`, `test_family_head_lost_gitmodules.py`, `test_family_holder_states.py` | 702 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/full247.log` | 2.47.1 | `dde7e9cc` | the same 15 files, 3 shards | 702 passed, rc 0 |
| `va.log` | 2.43 | `2c97e759` | the new file, the issue's Verify files, `test_family_holder_states.py`, the trio | 471 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/vb1.log` | 2.43 | `2c97e759` | the first 33 of the 65 other files that run or import `family.py` | 2070 passed, 24 skipped, 1 xfailed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/vb2.log` | 2.43 | `2c97e759` | the other 32 of those 65 files | 1739 passed, rc 0 |

The 15 files are the 3 that fix round 1 touched, rv865's other five `test_family_add_*` files (`excludes`, `member_id`, `member_variant`, `refusals`, `spellings_remedies`; fix round 2 touches `member_variant` too), the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`), `test_family.py`, and the three files that drive `.gitmodules` refusals for `add` and `remove`. CI's git 2.55 was not run here. macOS and Windows disks are emulated in process (`disk_as`) for lines 1 and 3.

**rv865b's probe at `38e0d503`, git 2.43.** Its typing stops after three rounds and never deletes a checkout by hand, so a state that leaves one shows the checkout named in rounds 2 and 3. Each state ends with the gate passing in all three modes.
- **P1 (a) and (b) as printed, and P2:** round 1 takes the gitlink out of the index. HEAD's gitlinks are then the two members alone, and `git submodule status` exits 0 in the holder and in the recursive clone. Rounds 2 and 3 name the checkout left at the variant.
- **P3, K2:** committed, reset and foreign end the same way. "Deleted" ends in one round, and `add` goes through.
- **P4 and P4b:** the take-out commits after the point-back's commit. HEAD no longer holds `stale` (`head_has_stale: false`).
- **P5:** `members/irrs -> IRRS` is refused (code 2), and so is `members/irrs -> IRSS`.
- **P6:** all four link layouts take two rounds, and the link is gone.
- **L7:** all six states are clean.
- **P7 (a), (b) and (c) as printed:** a plain clone's update exits 0, with every member checked out.
- **P7's control,** the rename typed by hand followed by the gate's own `--unset-all` lines, still gives a plain update that exits 128 while the gate passes. That is the validator's gap, which rv865b named; this PR does not print that rename.

**First round: 30 mutants,** M1 to M27 plus M11b, M13b and M15b, at least one per new hunk, each run `-x` on the test that names its state. All 30 are caught. (The earlier text said 31.)

| Mutant | What it undoes | Caught by |
|---|---|---|
| M1 | `_spelled_target`: no row variant under `members/` | line 3 (`add` exits 0) |
| M2 | `_on_disk_as_named`: on disk beyond a link | line 6, a file under the link |
| M3 | `_disk_spellings`: no row variant on disk | line 3, a directory on disk |
| M4 | `_sections_restored`: no row variant for a staged removal | line 3, the section state |
| M5 | `_rows_held`: an unmerged file not in the way | line 7 |
| M6 | `_in_the_way`: nothing unmerged | line 7 |
| M7 | `_aside_remedy`: an unmerged file on disk not moved | line 7 |
| M8 | no section astray | line 1 (`add` exits 0) |
| M9 | `_disk_found`: no `LINK_OUT` | line 6 |
| M10 | no checkout inside a moved directory | line 2 |
| M11 | `_checkouts_under`: a tracked gitlink named | line 2's control |
| M11b | `_checkouts_under`: a checkout entered (its legs named) | line 2 |
| M12 | `_gitmodules_beside`: no staged deletion | line 5 |
| M13 | `_gitmodules_first`: unmerged not asked first | line 4 |
| M13b | `_gitmodules_first`: an untracked copy not asked first | line 5, the untracked copy |
| M14 | `add` leaves a missing unmerged copy to git | line 4 |
| M15 | `_members_is_a_file`: unmerged taken for merged | line 7, `members` on disk |
| M15b | `_members_is_a_file`: HEAD's file not committed out | line 7, HEAD holds the file |
| M16 | `_lead`: a row variant said as another spelling | line 3 |
| M17 | `_point_back_part`: the commit always printed | line 1, staged |
| M18 | no update for a section alone | line 1, a fresh clone |
| M19 | the update names the moved rows alone | line 1 beside a move back |
| M20 | `_gitmodules_part`: no put-back from HEAD | line 5 |
| M21 | `_set_aside_part`: unmerged not said | line 7 |
| M22 | `_link_part`: never said | line 6 |
| M23 | `_checkout_first_part`: never said | line 2 |
| M24 | an unmerged removal always committed | line 7, the move staged |
| M25 | HEAD's row variants not asked | line 3, in HEAD alone |
| M26 | the index's row variants not asked | line 3, a file in the index |
| M27 | `refused_untouched` ignores `status` | the link test, on git 2.47.1 only |

**Fix round:** 19 mutants of this round's new hunks in `scripts/family.py` (K1 to K18 and K3a), and the 8 of rv865's survivors pinned here run again, 27 in all, on `dde7e9cc`'s `family.py`, which `f7072d08` keeps, each `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut/summary.txt`). **All caught but K2.**

| Mutant | What it undoes | Caught by |
|---|---|---|
| K1 | point back even where a section gives the row's path | the section named after the row |
| K2 | point back a section whose path is a gitlink in the index | survived; fix round 2 drops the clause, and N14, which puts it back, is caught |
| K3 | any variant section is the member's own, not only the one named after the row | the point-back beside a take-out |
| K3a | the take-out set off by any variant section | the variant section left to the gate (it survived until `f7072d08` added that test) |
| K4 | nothing taken out once the member's own is pointed back | the point-back beside a take-out |
| K5 | nothing taken out beside a section giving the row's path | (a) |
| K6 | a take-out never a change to commit by itself | (d) |
| K7 | a point-back never a change to commit by itself | line 1, committed |
| K8 | `_committed` ignores the earlier commit | the staged edit after a pending change |
| K9 | only the `path` key taken out | (a) |
| K10 | the earlier commit is the pending change alone | the staged edit after a rename |
| K11 | the earlier commit leaves out the pending change (rv865's R26) | the staged edit after a pending change |
| K12 | `_variant_in_members`: no `samefile` | the folding disk's checkout |
| K13 | `_variant_in_members`: a row variant on disk never named | the same test on Linux |
| K14 | `_gitmodules_first`: no `gitmodules-missing` for an unmerged copy off disk | line 4 |
| K15 | `_gitmodules_missing`: a staged copy taken for unmerged | `test_family_remove_gitmodules.py` |
| K16 | `_checked_gitmodules_as_staged`: `add` no longer leaves a missing file to git | `test_family_dry_run.py` |
| K17 | `_sections_astray`: an unreadable `.gitmodules` raises there (rv865's R13) | the unreadable `.gitmodules` |
| K18 | the place "beside the section giving its row's path" not listed | (a) |
| R7 | `_checkouts_under`: a moved directory that is a repository entered | the clone moved whole |
| R8 | a clone (`.git` a directory) named too | the clone beside a link |
| R9 | the walk follows links | the clone beside a link |
| R10 | a directory holding a `.git` directory entered | the clone beside a link |
| R23 | an unmerged entry moved with `mv` always a change to commit | line 7, on disk with the move staged |
| R25 | `_lead`: the row sentence alone | the point-back beside a take-out |
| R25b | `_lead`: the joined sentence without the row clause | the point-back beside a take-out |
| R42 | unmerged only where stage 2 is held | line 7, stages 1 and 3 alone |

**Fix round 2: 19 mutants** of the round's new hunks in `scripts/family.py` (N1 to N18 and N7b), on `38e0d503`, each run `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate2.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut2/summary2.txt`). **18 are caught and N2 survives.** N2 was run over all 200 cases of the leftovers, `member_variant`, `move_back_sections` and `case_variants` files, which all passed: see "Not here".

| Mutant | What it undoes | Caught by |
|---|---|---|
| N1 | `_second_gitlink`: any entry, not only a gitlink | line 3, a file on disk |
| N2 | `_second_gitlink`: the row's path need not hold a gitlink | **survives** (200 passed): see "Not here" |
| N3 | `_second_gitlink`: any gitlink while a row's gitlink is held, whatever its path | `test_a_gitlink_the_sparse_rules_leave_out_is_renamed_with_sparse` |
| N4 | the second path renamed with `git mv` again | (a) |
| N5 | a second path with nothing on disk rerouted too | (c) |
| N6 | the second path's removal never takes `--sparse` | the second gitlink left out by the rules |
| N7 | `--sparse` only where the rules leave it out | the second gitlink marked by hand |
| N7b | `--sparse` only where the bit is set | the second gitlink left out by the rules |
| N8 | the second path's part never printed | (a) |
| N9 | `_second_path_part`: `--sparse` never printed | the sparse cases |
| N10 | `_second_path_part`: the git `--sparse` needs never said | the sparse cases |
| N11 | `_removed`: the second path's removal never a change to commit | (a) |
| N12 | `_removed`: a pending change committed first no longer makes a removal one | `test_every_place_is_named_and_the_pending_change_is_committed_first` |
| N13 | `_removed`: a removal said wherever anything is committed | line 3, in HEAD alone |
| N14 | K2's gitlink clause put back | K2 committed |
| N15 | the point-back's own commit not counted for the take-out (NIT 3) | the staged take-out after the point-back's commit |
| N16 | `_variant_in_members`: no `islink` (NIT 1) | the link `members/irrs` |
| N17 | `_closing_sentences`: #828's guard again | the added state |
| N18 | #839's form without its `-L` test | the added state |

**Fix round 3: 13 mutants** of the round's new hunks in `scripts/family.py`, on `4e92867a`, each run `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate3.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut3/summary3.txt`). **12 were caught, and C5 survived** until fix round 4's test `test_a_second_paths_checkout_is_asked_in_its_own_spelling` caught it (on `231c404c`).

| Mutant | What it undoes | Caught by |
|---|---|---|
| F1 | the second path's `-f` never printed | (a) staged with an untracked file |
| F2 | `_forced` always true | (a), which takes no `-f` |
| F3 | the clause on `-f` never said | (a) staged with an untracked file |
| F4 | `_forced` without the question whether a checkout is on disk | the staged second gitlink with nothing on disk |
| F5 | `_forced` without the question whether HEAD records it at that commit | (a) |
| F6 | a second path never forced | (a) staged with an untracked file |
| F7 | the move back's clause dropped, now the shared constant | `test_family_add_move_back_sections.py` |
| C1 | the checkout a second path leaves never named | (a) |
| C2 | that checkout named after the update, not before | K2 committed with work |
| C3 | every second path has a checkout | the second gitlink marked by hand |
| C4 | a second path's checkout never noticed | (a) |
| C5 | the checkout asked whatever the spelling on disk | the second gitlink marked with `Members` a link to `members` (fix round 4) |
| C6 | any directory, a clone of its own too, is a checkout | the clone of its own at a second path |

`git merge-tree --write-tree` of `4e92867a`, after a fetch of main at 08:38Z on 2026-10-18:
- **Clean:** #828's final head `3546118b` (tree `923bb23b`), the restack's first merge.
- **For information:** main `def45de6`, which holds #828's squash `9b0a7038`, conflicts in #828's own files: `scripts/family.py`, `tests/test_family_add_member_variant.py`, `tests/test_family_add_move_back_sections.py` and `tests/test_family_members_case_variants.py`. This PR still holds #828's commits from before the squash, and the restack resolves those files.

Fix round 2's check of `38e0d503` was clean against main `3bf2d3c9` and `5c54af20`, and against #828's `90649e53` and `54b7699e`.

Fix round 1's check of `f7072d08`, at about 02:55Z, was clean against main `4dea85ee`, `90649e53`, and the open PRs that change `scripts/family.py`: #814 `4f6e7f83`, #846 `2d0e884f`, #849 `d98df568`. Those three were not checked again in fix round 2.

The restack onto #828's squash comes next. The first commit's wider check, against every open branch of 2026-10-16 and 2026-10-17 that touches these files, found no conflict of its own.

No line of `README.md`, `docs/handbook.html`, `docs/cli.md`, `AGENTS.md` or `templates/family-root/*.md` states the changed text or any of these codes. `--help` is unchanged, and no finding code is new. The docstrings of the changed functions say what each now does.

- **The validator's own text.** `_wrong_case_move` is lane 5's #839 (`fix/839-holder-bootstrap-texts`), whose guard this PR's text now matches. The validator is a pinned file and is not touched here. rv865b also found that typing the gate's own `--unset-all` lines after a hand rename of a second gitlink leaves the same orphan gitlink while the gate passes, since `_check_gitlinks_named` asks only gitlinks under `members/`. That gap is the validator's, and this PR no longer prints that rename.
- **A harmless `-f` on a folding disk.** On a disk that folds case, a second gitlink marked off disk at `Members/IRRS` resolves to the member's own checkout. So `_forced` prints a harmless `-f` there, as #828's move back does. The checkout question is asked in the path's own spelling (`present`), so no line says to delete that checkout. Fix round 4's test pins this with `Members` a link to `members`, the folding emulation `tests/test_validate_family_remedies_typed.py` uses (rv865b's round-3 NIT 1, mutant C5).
- **A second gitlink whose row's path holds index entries under it** (a gitlink below `members/IRRS`, say) rather than a gitlink at it. No rule here decides whose gitlink the variant is there, so it keeps #574's remedy. Mutant N2, which drops `_second_gitlink`'s question whether the row's path holds a gitlink, survives for that reason. It differs only there, or where the row's path is an unmerged entry whose first stage is no gitlink, which is R21's state. Each of those is a state no rule rules on, and pinning either remedy would decide it.
- **A checkout that is itself the moved directory**, beside an index entry inside it, keeps the index's remedy, as #828's test pins.
- **An unmerged entry outside the sparse definition.** `git rm --cached` of it may need `--sparse`. This was not measured, and no `--sparse` is printed for one.
- **The "stage the change" part's reason** names `git mv` and `git rm` of a gitlink (#685's text). For line 1 alone, the reason is the `git add -- .gitmodules` that follows. The text is unchanged.
- **R20 and R21 are not pinned.** R20 (`_members_is_a_file` takes HEAD's `members` of any kind for the file) differs only where HEAD holds `members` as a tree, a link or a gitlink while the index holds an unmerged file `members`: a directory/file or link/file conflict that a merge may not even leave, and whether a link in HEAD should ask for the commit too is a question of its own. R21 (`_rows_held` takes an entry with any non-gitlink stage as in the way) differs only for an entry at the row's path unmerged with a gitlink at one stage and a file at another, made only by `update-index`; whose gitlink that is, and so which remedy is right, is not ruled.

- **The closing guard kept a link to the row's own directory.** Done in fix round 2, in #839's form (rv865b's NIT 4).
- **`_sections_restored` (#828) points back every variant section of a member it moves back.** It has the fault MUST 1 found in `_sections_astray`, for a member whose gitlink is at a variant: a second variant section, another submodule's, would be pointed back too, and the path given twice. Read from the code, not measured.
- **Found in the checklist pass (t114 row 5), and done in the second commit.** `refused_untouched` and `snapshot` in the tests ask `git status`, which git 2.45.1 and later refuse while a gitlink the index holds is a symbolic link on disk. `snapshot` already took `status=False`. `refused_untouched` now takes it too, and the link test passes it, so the full snapshot is kept except `git status`. Mutant M27 fails the link test on git 2.47.1.

Review: rv865, lane 5's independent review, and rv865b's re-checks of each fix round: round 3 and its restack onto #828's squash READY at `6ef6e4c1` (issuecomment-6248254729), and round 4 (`231c404c`, the C5 pin rv865b asked for, tests only) READY with no findings (issuecomment-6248465765). CI (Brett Heap's ruling 27): H0 38e0d503 (fix round 2, the last head before the restack that CI ran in full: GitHub started no tests run on round 3's 4e92867a) passed tests, tests-git-bash and SonarCloud; the restack 6ef6e4c1 merges #828's final head, then #828's squash 9b0a703 (the branch's own side for #828's files), then main; the head 231c404c, 1 commit on the restack, has tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 629aec2b over main c9ca9a45: the family_add_member_variant, family_add_move_back_sections, family_add_spelling_leftovers, family_members_case_variants, repo_hygiene, agents_md_codes, cli_reference sets, 356 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #840

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