Skip to content

family.py: add, bump and remove name a bisect, an empty rebase directory, sparse rules that hold no pattern, a merge conflicted in family.yaml and a members file HEAD holds and the work tree does not as what each is, with the way out that works there, and refuse a family.yaml holding a change HEAD does not (#684) - #816

Merged
brettheap merged 7 commits into
mainfrom
fix/684-family-holder-states
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

What changed and why

#684 holds six lines from R612's review of #626 (#612), on #459 (comment 6213530456): states of the holder that #626's early refusals did not model, where add, bump and remove met a refusal or a remedy that did not fit. Five are fixed here; E4 is not (see "What it cannot tell"). Brett Heap added one more line to it: #459 line 66 (issuecomment-6192725913), "refuse a dirty family.yaml", relayed by lane 5 on #473 at 2026-10-16T13:56:50Z (issuecomment-6217084172). Every refusal below is made before the verb's first write, under --dry-run alike, in the same words, exit 2. All in scripts/family.py.

The branch holds six commits: the WIP saved at the account swap (70153862: E2, E5, E3, N4 and E1's staged half), a merge of main bf1f099b, the rest (772105aa), fix round 1 (bc8024fc), 4030ee6c, which clears the two Sonar smells this PR added (S1192, "a rebase" a third time; S3358, a nested conditional) with nothing either refusal says changed, and fix round 2 (fdc90a6e).

Fix round 1 answers lane 1's review at 772105aa (issuecomment-6233536729), on lane 4's rulings:

Fix round 2 answers lane 1's re-verification at 4030ee6c (issuecomment-6233920232):

  • N1. F5's drop alone also fired on a gitlink a person staged by hand beside an unstaged comment in family.yaml (a commit inside members/IRRS and git add -- members/IRRS, or git mv -- members/IRRS members/MOVED), where no run stopped, the change is not the row, and the drop would discard the person's only copy of their edit. Every stopped add or bump stages family.yaml beside its gitlink, so _gitlink_staged is now asked only where the index's own copy differs from HEAD too: if merged and merged != [head] and _gitlink_staged(index, held), lane 1's tested change, verbatim. Both states now get the two-way remedy.

E2, a git bisect in progress. BISECT_LOG is asked with the other marks (OPERATION_MARKS), where git keeps it, and LAST: a merge or a cherry-pick begun at the commit under test leaves its own mark beside it, and git bisect reset fails over that act's conflict (you need to resolve your current index first, measured), so that act is named and ended first. The real run said family-holder-detached with a git switch -c that would branch from the commit under test; the dry run printed a plan. Now:

REFUSED family-operation-in-progress: a bisect is unfinished in the family root: git keeps BISECT_LOG at …/BISECT_LOG. `add` makes its commit with `git commit -- `, and git does not refuse that during a bisect: once the bisect has checked out a commit to test, the commit would be made on it, on no branch, and `git bisect reset` would leave it behind. No git command that writes was run and nothing was written.
Remediation: end the bisect with `git bisect reset` once you have what it was for: it takes HEAD back to where the bisect started. Then run the same command again.

E5, an empty rebase-merge/ (or rebase-apply/ with no applying). A rebase directory with no head-name is named with git rebase --quit, the exit that works there (--abort and --continue both fail, could not read '.git/rebase-merge/head-name', measured): "rebase-merge/ is what a rebase left behind, and it holds no head-name, so git can read no rebase from it and there is nothing to continue or give up: clear it without moving HEAD with git rebase --quit."

E3, non-cone sparse rules that hold no pattern (an emptied .git/info/sparse-checkout). Every staged path is still refused as family-sparse-excluded; the remedy now names git sparse-checkout disable ALONE, and says why: "the rules hold no pattern at all, so they leave out every path, and git sparse-checkout add would keep only the paths it is given and take every other file out of the work tree". Measured: the add it printed took the holder's top level down to .git, .gitmodules, family.yaml and members. Asked of git (git sparse-checkout list prints nothing), and only of non-cone rules: a cone of no directories keeps its git sparse-checkout add members remedy, which works there.

N4, a merge conflicted in family.yaml itself. As the issue directs (W612's fallback): the marks are asked only once the parse has failed, so the member-path refusal still runs no git command first (test_family_member_path.py::test_the_refusal_is_made_before_any_git_call holds it). yaml-unparsable keeps its code and line and gains a remediation that names the act, where git keeps it, and its exits; with no act in progress it is one line, as before.

E1, a members FILE that HEAD holds and the work tree no longer does. It passed every add check, and the mount, putting a directory where the file was, took D members into the add's commit. Measured on the code before this round, with the deletion unstaged: add exit 0, its commit A .gitmodules, M family.yaml, D members, A members/IRQS. _members_file_left_in_head refuses both halves as member-path-unaddable:

REFUSED member-path-unaddable: git would add the mount path members/IRQS and take `members` out of the holder with it: `members` is a file HEAD and the index hold and the work tree no longer does, its deletion not staged, and the add's mount, putting a directory where the file was, would take it out of the index, and its commit would take that deletion into itself (`D members`). No git command that writes was run and nothing was written.
Remediation: every member is mounted under members/, which must be a plain directory of the holder's own. Take the file out of the index, which touches no file (`git rm --cached -- members`), and commit the removal on its own first (`git commit`, with nothing else staged: a bare `git commit` takes every staged change, and `git status` lists them), since the add's own commit names .gitmodules, members/IRQS and family.yaml alone, and run the same command again.

This remedy differs from the issue's, and the lane's coordinator ruled it, because the issue's own remedy fails as measured: git mv -- members <new name> there exits 128 (fatal: bad source, or fatal: Directory members is in index and no submodule? where a directory was made), and git checkout -- members first puts the file back over such a directory and takes the directory and its files away. The words are the house's for an index entry with nothing on disk (#318, UNSTAGE). Brett Heap may overrule it at review. A link on disk is left to git's own dry run, as before.

A family.yaml holding a change HEAD does not (#459 line 66). add, bump and remove rewrite family.yaml as the work tree has it and commit it with git commit -- <paths>, so a comment line edited by hand went into their commit beside their own row (measured on the code before this round: remove's commit carried +# a comment line, edited by hand). _checked_manifest_committed, asked right after the operation check in each verb, refuses it, staged or not:

REFUSED family-manifest-uncommitted: family.yaml holds a change HEAD does not: git lists the work tree's copy as modified, a change nobody has staged. `remove` rewrites family.yaml as the work tree has it and commits it, so that change would go into the command's commit beside its own. No git command that writes was run and nothing was written.
Remediation: which change to keep is a person's to say. To keep it, commit it on its own first, `git commit -- family.yaml`; to drop it, put back HEAD's copy, in the index and the work tree, `git checkout HEAD -- family.yaml`. Then run the same command again.

  • What it says of each state: "it is unmerged in the index" (a git stash pop that conflicted leaves that with no act in progress), "its deletion is staged, so the index holds no copy", "a change to it is staged and not committed" (a mode too, F4), and "git lists the work tree's copy as modified, a change nobody has staged", the last two together where both hold.
  • Where a change to it is staged beside a staged gitlink under members/ (F5) the remediation is the drop alone:

Remediation: the index also stages a gitlink under members/ that HEAD does not hold, as a run stopped before its commit leaves it, and `git commit -- family.yaml` would commit the row without it: put back HEAD's copy, in the index and the work tree, `git checkout HEAD -- family.yaml`. Then run the same command again.

  • Read with git ls-files -s -z (_index_records), git ls-tree -r -z HEAD (_head_entries, mode and object, F4) and git ls-files -m -z, and NOT git hash-object: test_family_dry_run.py::test_a_dry_run_runs_no_git_command_that_writes holds hash-object to bump's nothing-to-do alone.
  • A holder whose HEAD holds no family.yaml (one made by hand, with no commit yet) is not asked: add gives the file its first commit there, as it always has (test_family_add_refusals.py::test_the_first_add_to_a_holder_with_no_commit_makes_its_root_commit).
  • The two member-path remedies now say to commit a row put back by hand ("Put path: members/IRRS back in family.yaml and commit that, or git checkout -- family.yaml if the edit is not committed, …"; the project: one alike). Without that, a row put back over an edit already committed met this refusal on the run again.

Existing tests this rule collides with, each changed to keep what it tests:

Tests

New file tests/test_family_holder_states.py, 40 tests, on the helpers of test_family_refuse_up_front.py (#612's proof: exit 2, one REFUSED paragraph, nothing on stdout so no member was read, the holder as it was under the real run and --dry-run alike, the two runs' words identical; then the way out typed as printed and the same command making its one commit). Each state is made with git itself where git can make it.

  • E2: a bisect, each verb; the premise that the verb's partial commit lands on no branch; a cherry-pick stopped inside a bisect, named first, each exit typed.
  • E5: an empty rebase-merge/ under each verb, and an empty rebase-apply/; --abort and --continue failing there is the premise.
  • E3: emptied non-cone rules (with and without init --no-cone), each verb; the premise that the add it printed left four entries; a cone of none keeps its remedy.
  • N4: a merge conflicted in family.yaml, each verb; the same line with no act in progress; a family.yaml outside any repository.
  • E1: the staged move and the staged removal, real and dry; the deletion not staged with nothing left and with a hand-made members/ holding a file, real and dry, the git mv failure the premise, and the directory and its file still there after the remedy.
  • The dirty family.yaml: a comment line not staged, under each verb, the comment then the person's commit's and not the verb's; staged, staged and edited again, a staged deletion and an unmerged copy with no act (from a conflicted git stash pop), each with the way out typed; a mode staged with git update-index --chmod=+x, under each verb (F4); family.py bump to the commit already pinned exits 2 as "REFUSED a git or gh command failed" around git's "nothing to commit", where family.py documents exit 1 for nothing and bump-leg.py answers the same re-run with "nothing to do", exit 1 #210's half-finished bump told the drop alone, in exact words, and then finishing in one commit of both paths (F5); a gitlink staged by hand, in lane 1's two states, beside an unstaged comment, told both ways out, and "keep" then committing family.yaml alone (N1); the member-path remedy for a committed edit typed through to a run.

Fix round 1's run over the bytes of head bc8024fc (scripts/family.py sha256 d5cdac4a…, the new file c09dadc1…), the 14 files lane 4 named: test_family_holder_states, test_family_member_path, test_family_bump_remove_rollback, test_family_dry_run, test_family, test_family_refuse_up_front, test_family_add_refusals, test_family_init_values, test_shape_pin_files_not_a_list, test_update_shape_add, test_setup_project_py, test_repo_hygiene, test_cli_reference and test_agents_md_codes: 951 passed, 19 skipped, 0 failed, 12m33s. The skips are all test_family_init_values.py's (a template that quotes its placeholder or id:). Main was not merged this round.

Fix round 2's run over the bytes of head fdc90a6e (scripts/family.py sha256 48da8f37…, the new file 2800a4cb…): test_family_holder_states, test_family, test_family_add_refusals, test_family_dry_run, test_family_refuse_up_front, test_family_member_path, test_family_bump_remove_rollback, test_repo_hygiene, test_cli_reference and test_agents_md_codes: 569 passed, 0 skipped, 0 failed, 7m20s. The new file has 42 tests.

The Sonar follow-up's run over the bytes of head 4030ee6c (scripts/family.py sha256 63b17251…, every test file as at bc8024fc): test_family_holder_states, test_family, test_family_add_refusals, test_family_dry_run, test_family_refuse_up_front, test_family_member_path, test_repo_hygiene, test_cli_reference and test_agents_md_codes: 538 passed, 0 skipped, 0 failed, 7m16s.

The first round's run over the merged bytes, which are the tree of head 772105aa (scripts/family.py sha256 f86d9172…, the new file 4b89cf1d…): 32 of the 36 test files that run family.py's verbs (test_bootstrap_spelling, test_bump_leg, test_doctor_next_is_a_command, test_family, 17 test_family_* files, test_non_utf8_git_reads, test_refusal_default_remediation, test_shape_doctor, test_siblings_family_name, test_template_text_placeholders, test_update_shape_kept_helper and the five test_validate_family_*; the coordinator left out test_setup_project_py, test_family_init_values, test_shape_pin_files_not_a_list and test_update_shape_add), the new file, tests/test_repo_hygiene.py, tests/test_cli_reference.py and tests/test_agents_md_codes.py: 2230 passed, 4 skipped, 1 xfailed, 0 failed, 32m58s. Skips: test_bump_leg.py:693 (needs a disk that ignores case) and test_shape_doctor.py:10429 ×3 (the PowerShell line, Windows only). The xfail is test_shape_doctor.py's strict xfail for #735. The full suite was not run; CI's Linux run is the gate.

Mutants, each over the new file, in a separate clone (36 tests at 772105aa; 40 for the last two rows, fix round 1's):

mutant result
the code before this round (the merge head's family.py) 12 failed: every new test
E1: the index-held branch returns None 4 failed: the unstaged-deletion cases
dirty: git ls-files -m not asked 5 failed: the three verbs, staged-then-edited, the member-path chain
dirty: the index not compared with HEAD 3 failed: staged, staged-then-edited, the staged deletion
dirty: not asked by bump 1 failed: bump's case
E2: BISECT_LOG dropped from the marks 4 failed
E5: a rebase directory with no head-name read as a rebase 4 failed
E3: rules that hold no pattern not asked 4 failed
N4: no remediation for yaml-unparsable in an act 3 failed
E1: the staged half refused nothing 4 failed
F4: the mode not compared (HEAD's mode taken for the index's) 3 failed: the mode row, each verb (on Linux still refused, through ls-files -m, in words the row does not expect)
F5: no gitlink ever counted as staged 1 failed: the half-finished bump
N1: the old condition (any staged gitlink), over fix round 2's 42 tests 2 failed: both hand-staged rows

What it cannot tell

  • E4, reftable, is not done. This host's git is 2.43, and the issue asks for it to be measured first on git 2.45 or later (git init --ref-format=reftable). The coordinator ruled it skipped with this reason. No test is added for it: one gated on git 2.45 would run on CI's ubuntu-latest git and assert a result nobody here has measured.
  • A family.yaml the index marks assume-unchanged (or skip-worktree outside a sparse checkout): git ls-files -m does not compare it, and no hash-object is asked, so an unstaged edit to it is not seen.
  • A staged deletion over a copy that is HEAD's byte for byte, or a mode staged with git update-index --chmod that the copy on disk does not have, is refused, and there git commit -- family.yaml has nothing to commit (exit 1, both measured); git checkout HEAD -- family.yaml, the other way out, works. The docstring says so.
  • F4 on a disk that keeps no modes (core.filemode=false, as on Windows): there only the mode compare refuses the mode row. On Linux git ls-files -m lists the copy as well, so the F4 mutant was still refused here, in words the row does not expect, and failed it that way.
  • Windows and macOS were not run (CI is Linux only). Reasoned: the new reads are ls-files -z and ls-tree -r -z; the tests write files as bytes, pass no paths on stdin, and stage the mode with git update-index --chmod=+x, which works where chmod does not.

For #459's checklist (outside #684)

From lane 1's review of #816; lane 4 posts them at landing. Lane 1's measurements, on git 2.43:

  1. E4, reftable: not measured (this host's git is 2.43); it stays open on 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 (R3).
  2. Non-cone rules holding only a negative pattern (!/*) still get git sparse-checkout add .gitmodules members/IRRS family.yaml. Typed, it took the holder's top level from 14 entries to .git, .gitmodules, family.yaml and members (measured): E3's harm, through rules that do hold a pattern.
  3. A git stash pop that conflicted in family.yaml (no act in progress) is still REFUSED yaml-unparsable: …family.yaml: line 1: merge keys are not supported, with no remediation (measured).
  4. git rm --cached -- members drops a staged edit of the members file without a word: it exited 0 with nothing or a directory on disk, and the staged blob 601b695b… is then in neither HEAD nor the work tree (measured).
  5. A half-finished add (stopped between its stage and its commit: M .gitmodules, M family.yaml, A members/IRQS): main says member-already-present, this PR family-manifest-uncommitted (measured, at 772105aa). Neither way out finishes it (reasoned): "keep" commits a row with no gitlink, "drop" leaves .gitmodules and the gitlink staged. (Since F5 the drop alone is named there: item 8.)
  6. Skip-worktree on family.yaml outside a sparse checkout, edited: the dry runs exit 0, and the real remove fails late at git --literal-pathspecs add -f -- .gitmodules family.yaml, rolled back (status empty afterwards), on main and here alike (pre-existing).
  7. Not measured: validate-family.py remedies that name family.py bump --to for a work-tree-only defect will now meet family-manifest-uncommitted first.

From lane 1's re-verification at 4030ee6c:

  1. A half-finished add (all three paths staged) now gets the drop alone. Typed, the next add says REFUSED member-path-taken: members/IRQS already exists in the family root but no members: row names it, so the drop finishes a bump but not an add (measured). The docstring's "makes the one commit of both" holds for bump only.
  2. A half-finished remove (M .gitmodules, M family.yaml, D members/IRRS) never reaches this check: it stops at REFUSED member-unknown: IRRS is not a member of InkRouter. That ordering is not new in this PR.
  3. In a --filter=tree:0 partial clone, ls-tree -r HEAD would fetch every tree from the remote. This is reasoned only, not measured.

Shared files

scripts/family.py and six test files. git merge-tree --write-tree of head bc8024fc against fresh main 741679e7 and every open pull request in those files, each head read with ls-remote just before:

against head result
main 741679e7 clean
#731 fix/692-raw-folder-paths ab55c29f clean
#747 fix/681-validate-family-remedies (tests/test_family.py) ff7c59b8 clean
#748 fix/662-insteadof-helper (tests/test_family_bump_remove_rollback.py) 8368d1f8 clean
#767 fix/685-spellings-remedies ac764679 clean
#772 l3-735-sibling-slot (tests/test_family.py) 1d149933 clean
#788 l3-784-dangling-slot (tests/test_family.py) e966a684 clean
#795 fix/686-bump-reachability 7d7a69b1 clean
#805 fix/690-siblings-inputs (holder siblings.py; named by lane 4) dc1c8e45 clean
#809 fix/688-family-init-values 6958dfbc clean
#812 fix/693-local-remote-dir 372196a7 clean
#813 fix/689-next-push-refspec (also tests/test_family_add_refusals.py) 15c3b5ab clean
#814 fix/766-spelled-paths (also tests/test_family_bump_remove_rollback.py) 0e516bba clean
#818 fix/758-git-command-scope (tests/test_family.py, …_bump_remove_rollback.py, …_dry_run.py) b6e15591 CONFLICT in tests/test_adopt_empty_symlinks.py, a file this PR does not touch; #818 conflicts with main alone the same way

#670 merged at 11:38:50Z, so it is now in main. None of those heads' new tests, nor main's since bf1f099b, leaves family.yaml changed before an add, bump or remove (grepped: their edits are committed, go to the validator, or are an init or setup folder). A sibling that lands first still needs the refresh gate.

Part of #684: E1, E2, E3, E5 and N4, and #459's line 66. E4 (reftable) stays open on #684 until it is measured on git 2.45 or later.

🤖 Generated with Claude Code

Summary by Sourcery

Protect family add, bump, and remove operations from incomplete Git states and unintended family.yaml changes by refusing unsafe holders with precise recovery guidance.

New Features:

  • Add upfront safeguards for bisect sessions, unreadable rebase state directories, empty non-cone sparse-checkout rules, merge conflicts in family.yaml, and a tracked members file removed from the work tree.
  • Refuse add, bump, and remove when family.yaml contains changes not present in HEAD, with remediation tailored to staged, unstaged, unmerged, mode, and interrupted-operation states.

Bug Fixes:

  • Prevent family operations from accidentally committing unrelated family.yaml edits, deleting a tracked members file, or proceeding with incomplete Git state.
  • Provide workable remediation commands for each newly recognized holder state, including special handling for interrupted bumps and hand-staged gitlinks.

Enhancements:

  • Improve refusal diagnostics and remediation guidance for malformed manifests and manually restored member rows.
  • Use Git index and HEAD metadata, including file modes and staged gitlinks, to distinguish safe and unsafe operation states.

Tests:

  • Add comprehensive coverage for the new holder-state refusals, dry-run parity, remediation flows, and resulting commits.
  • Update existing tests to reflect dirty family.yaml protection and the revised remediation requirements.

Brett Heap and others added 3 commits October 16, 2026 14:16
…684 holder states (#684)

Saved for an account swap, on the coordinator's order. Not the final commit.

In it: a bisect is refused as `family-operation-in-progress` with `git bisect reset` (E2, asked last); a rebase directory with no `head-name` is named with `git rebase --quit` (E5); non-cone sparse rules holding no pattern name `git sparse-checkout disable` alone (E3); a `family.yaml` that fails to parse while an act is unfinished gains a remediation naming the act (N4); a `members` file HEAD holds and the index no longer does is refused before the member is read (E1, the staged-move half). The new tests and test_family_refuse_up_front.py and test_family_member_path.py passed on these bytes (144 passed); the full targeted set has not run.

Not in it: E1's unstaged-deletion half and E4 (reftable), which wait on rulings; the dirty `family.yaml` refusal (#459 line 66, on Brett Heap's word relayed by lane 5 on #473 at 13:56:50Z).

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main at bf1f099 (#736), 29 commits past the branch's base 4ce9045, among them #671 and #728 in scripts/family.py. No conflicts: git merged every file on its own.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d with `git rm --cached` and a commit, and `add`, `bump` and `remove` refuse a `family.yaml` holding a change HEAD does not, staged or not, both runs alike (#684)

Finishes the WIP commit 7015386 (#684's E2, E5, E3, N4 and E1's staged half) with the rest of the issue and the scope Brett Heap added.

E1's OTHER HALF. A tracked `members` FILE deleted from disk, the deletion not staged, passed every `add` check: the mount's `git submodule add`, putting a directory where the file was, took the entry out of the index, and the commit took `D members` into itself. `_members_file_left_in_head` now refuses it too, as `member-path-unaddable`, before the member is read and under `--dry-run` alike. The remedy is the house #318 one for an index entry with nothing on disk: take it out of the index, which touches no file (`git rm --cached -- members`), and commit the removal on its own first. The issue's `git mv` fails there (`fatal: bad source`, exit 128, measured), and `git checkout -- members` first would put the file back over a `members/` directory a hand made there and take the directory away (measured); the lane's coordinator ruled the remedy for that reason. A link on disk is left to git's own dry run.

A DIRTY `family.yaml` (#459's line from #571's review, comment 6192725913, on Brett Heap's word "refuse a dirty `family.yaml`", relayed by lane 5 on #473 at 2026-10-16T13:56:50Z). `add`, `bump` and `remove` rewrite `family.yaml` as the work tree has it and commit it with `git commit -- <paths>`, so a comment line edited by hand went into their commit beside their own row. `_checked_manifest_committed`, asked right after the operation check in each verb, refuses `family-manifest-uncommitted` where the index holds the file unmerged, holds no copy, or holds one HEAD does not, or where `git ls-files -m` lists the work tree's copy; the remediation names git's two ways out, `git commit -- family.yaml` and `git checkout HEAD -- family.yaml`. It reads the index records, `HEAD:family.yaml` and `ls-files -m`, and no `hash-object`, which a dry run asks only for `bump`'s nothing-to-do. A holder whose HEAD holds no `family.yaml`, one made by hand with no commit yet, is not asked: `add` gives the file its first commit, as it always has. The two `member-path` remedies now say to commit a row put back by hand, or the run again would meet this refusal.

Existing tests that left `family.yaml` changed and expected a later answer now commit that change first, or expect the new refusal: test_family.py's nothing-to-do over an uncommitted edit (now refused, the edit left alone) and #210's half-finished bump (now refused; `git checkout HEAD -- family.yaml` and the same bump finishes it in one commit of both paths); test_family_member_path.py's no-path-line control and its two remedy texts; and the members-line hand edits in test_family_bump_remove_rollback.py and test_family_dry_run.py.

Not in it: #612's review E4 (reftable). This host's git is 2.43, and the issue asks for it to be measured on git 2.45 or later first.

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

The PR hardens add, bump, and remove against five previously unmodeled Git holder states plus dirty family.yaml changes, detecting each before writes and providing state-specific, measured remediations; extensive tests verify real/dry-run parity, refusal ordering, and clean retries.

Sequence diagram for pre-write refusal and remediation

sequenceDiagram
    actor User
    participant Family as family.py
    participant Git as Git holder
    User->>Family: add, bump, or remove
    Family->>Family: Family.open
    alt family.yaml is unparseable
        Family->>Git: _operation_in_progress
        Git-->>Family: conflicted operation or no operation
    else manifest parses
        Family->>Git: _operation_in_progress
        Git-->>Family: operation state
        Family->>Git: _checked_manifest_committed
        Git-->>Family: index, HEAD, and modified-path state
    end
    alt refusal state found
        Family-->>User: Refusal with state-specific remediation
        User->>Git: Resolve state using the printed Git command
        User->>Family: Run the same command again
    else checks pass
        Family->>Family: mount, rewrite, and pin
        Family->>Git: git commit -- <paths>
        Git-->>Family: commit result
        Family-->>User: Success or dry-run plan
    end
Loading

State diagram for Git holder-state refusals

stateDiagram-v2
    [*] --> Clean
    Clean --> OperationInProgress: rebase, bisect, merge, cherry-pick, or stale rebase directory
    Clean --> DirtyManifest: family.yaml differs from HEAD
    Clean --> SparseExcluded: sparse rules exclude target
    Clean --> MembersFileRemoved: HEAD holds members file, work tree no longer does
    Clean --> UnparsableManifest: conflicted family.yaml
    OperationInProgress --> Clean: finish, abort, quit, or git bisect reset
    DirtyManifest --> Clean: git commit -- family.yaml or git checkout HEAD -- family.yaml
    SparseExcluded --> Clean: git sparse-checkout disable or add required patterns
    MembersFileRemoved --> Clean: remove members from index and commit removal
    UnparsableManifest --> Clean: resolve family.yaml conflict and end the act
Loading

Flow diagram for pre-write holder validation

flowchart TD
    Start["add, bump, or remove"] --> Open["Family.open parses family.yaml"]
    Open --> Operation["_checked_no_operation_in_progress"]
    Operation --> Manifest["_checked_manifest_committed"]
    Manifest --> Holder["Run holder and sparse-state checks"]
    Holder --> Refuse["Refuse before any write"]
    Holder --> Proceed["Mount, rewrite, pin, and commit"]
Loading

File-Level Changes

Change Details Files
Expand pre-write holder-state detection and tailor refusal remediations for operations that Git cannot safely continue or that would cause unintended commits.
  • Detect unfinished bisects via BISECT_LOG and identify stale rebase directories lacking head-name.
  • Generate operation-specific exits, including bisect reset and rebase quit, while preserving mark ordering and linked-worktree paths.
  • Name merge conflicts in family.yaml after YAML parsing fails, without running Git checks before earlier manifest validations.
  • Recommend disable alone for empty non-cone sparse rules because sparse-checkout add would remove other files.
scripts/family.py
Prevent add from absorbing destructive members-file transitions into its commit.
  • Detect when HEAD holds members as a file but the index or work tree no longer does.
  • Refuse staged moves/removals and unstaged deletions before mounting a member.
  • Provide separate remedies for committing the transition or removing the stale index entry with git rm --cached.
scripts/family.py
Refuse dirty or staged family.yaml changes before add, bump, and remove rewrite and commit the manifest.
  • Compare HEAD, index records, and Git's modified-path output to distinguish staged, unstaged, deleted, and unmerged states.
  • Apply the check consistently to all three verbs while preserving first-add behavior when HEAD has no family.yaml.
  • Offer explicit keep-by-committing or drop-by-checking-out remedies.
scripts/family.py
Align manifest/member-path remediation text and existing expectations with the new refusal ordering.
  • Require hand-restored member rows to be committed before retrying.
  • Update bump, dry-run, rollback, and member-path tests for dirty-manifest precedence and committed fixture edits.
tests/test_family.py
tests/test_family_bump_remove_rollback.py
tests/test_family_dry_run.py
tests/test_family_member_path.py
Add comprehensive coverage for the five newly modeled holder states and dirty manifest handling.
  • Exercise real and dry-run refusal parity, no writes, exact remediation text, and successful retry after each remedy.
  • Cover bisects, stale rebase directories, empty sparse rules, conflicted family.yaml, members-file deletion/moves, and staged/unstaged/unmerged manifest states.
  • Verify operation ordering, Git behavior premises, and commit contents.
tests/test_family_holder_states.py

Assessment against linked issues

Issue Objective Addressed Explanation
#684 Refuse add when HEAD tracks members as a file but the work tree no longer has that file, and provide a remedy that avoids folding its deletion into the add commit. ✅
#684 Detect an in-progress git bisect before writes and explain how to exit it with a remedy that works. ✅
#684 For an empty non-cone sparse-checkout rules file, give a remedy that restores the checkout without removing other files from the work tree. ✅
#684 Correctly detect cherry-pick and revert operations when their state is stored in a reftable, or establish that this is not a defect. ❌ The PR explicitly leaves the reftable case untested and unchanged because its Git host is version 2.43; it does not verify the behavior on Git 2.45 or later or add a reftable-aware check.
#684 When a rebase state directory has no head-name, distinguish it from a readable rebase and recommend an exit that works, such as git rebase --quit. ✅
#684 When a merge conflict makes family.yaml unparsable, retain the YAML refusal while naming the merge and its remedy, without running Git before earlier member-path refusals. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T11:49:51Z)

Lane 1's independent review of #816 at 772105a: CHANGES NEEDED

Nothing here is a code defect in the five fixes, and the reads are clean. Three printed or posted texts are untrue as they stand, and each needs a small change. A fourth finding is a narrow gap in "dirty" that is worth closing in this PR.

Setup. I merged head 772105aa locally with current main bb439a29. Main moved past the PR body's bf1f099b by #706, which adds one test file. The merge was clean, and scripts/family.py is unchanged by it (sha256 f86d9172…, the new test file 4b89cf1d…, both as the body says). Every probe used a stub gh (exit 97), a bogus token, an empty GH_CONFIG_DIR and GIT_CONFIG_GLOBAL=/dev/null, on the suite's own offline fixtures.

The issue's lines

line on main on the merge verdict
E1 members file gone, unstaged or staged add exit 0, committed dc02f3a29d82: .gitmodules, members/IRQS, family.yaml (it takes D members) refused member-path-unaddable, both halves fixed (texts: R1)
E2 bisect REFUSED family-holder-detached: …/InkRouter is not on a branch… family-operation-in-progress … a bisect … git bisect reset fixed
E3 emptied non-cone rules remedy names git sparse-checkout add … disable alone fixed as worded (checklist 2)
E4 reftable not measured not measured (git 2.43 here too) not fixed (R3)
E5 empty rebase-merge/ …git rebase --abort drops it git rebase --quit fixed
N4 merge conflicted in family.yaml yaml-unparsable, no remediation the merge, its mark and its exits fixed
#459 line 66, dirty family.yaml the hand edit goes into the verb's commit refused, staged or not fixed (one gap: F4)

I also ran the new test file against main's family.py: 31 of 36 failed. The 5 that pass are the premise and control tests.

Ranked findings

R1 (required, text). Both E1 remedies print a bare git commit and say "on its own", but a bare commit takes every staged change. _commit_one's own docstring names this hazard ("a bare git commit takes whatever anyone staged").

Repro, in an empty_holder with a file named members committed:

echo x > README-other.txt && git add -- README-other.txt
rm members                                   # or: git mv -- members members-file
family.py add …                              # REFUSED member-path-unaddable, remedy as printed
git rm --cached -- members                   # unstaged half only
git commit -m 'as printed: git commit'
git show --name-status --format= HEAD

On the merge:

  • The unstaged-deletion remedy's commit was A README-other.txt, D members.
  • The staged-move remedy's commit was A README-other.txt, R100 members members-file.
  • On main, add takes the deletion into its own commit instead (table above).

A pathspec on the commit works only in some states:

  • After git rm --cached -- members with nothing on disk, git commit -- members exited 0. It committed only D members, and A README-other.txt stayed staged.
  • Where a hand-made members/ directory is on disk, the same command exits 128 (error: 'members' does not have a commit checked out, fatal: updating files failed).
  • For the staged move, git commit -- members members-file exited 0 with only R100 members members-file.

The smallest true fix is the text. In both remedies in _members_file_left_in_head, and in MOVED_REMEDY and DELETED_REMEDY in tests/test_family_holder_states.py, replace

(git commit)

with

(git commit, with nothing else staged: a bare git commit takes every staged change, and git status lists them)

As an option, print git commit -- members where nothing is on disk at members.

R2 (required, text). Decision 3 missed one remedy: family-members-unrecognised still sends the person straight into the new refusal. Measured with remove:

  1. Commit a comment on the members: line. remove then says REFUSED family-members-unrecognised: … has 0 members: line(s) … with "fix it by hand this once."
  2. Fix the line by hand and run remove again. It now says REFUSED family-manifest-uncommitted: family.yaml holds a change HEAD does not: git lists the work tree's copy as modified, ….

That refusal can only be reached once the shape is committed: an uncommitted shape meets family-manifest-uncommitted first. So "commit that" is always the right instruction there. In Family.rewritten, and in tests/test_family_add_refusals.py:453, replace

fix it by hand this once.

with

fix it by hand this once and commit that (git commit -- family.yaml), then run the same command again.

R3 (required, PR text). "Closes #684" would close the issue with E4 unmeasured. The issue itself says to "tick them there when this closes" on #459, so closing it ticks E4 there falsely. No other issue carries E4: the only other reftable issue is #764, which is bootstrap's. Either file E4 on its own before landing and name it in the body, or replace Closes #684 with:

Part of #684: E1, E2, E3, E5 and N4, and #459's line 66. E4 (reftable) stays open on #684 until it is measured on git 2.45 or later.

F4 (should fix, small). "Dirty" compares the index's object with HEAD's but not its mode. Repro: chmod 755 family.yaml && git add -- family.yaml, then a real remove. It exited 0, and git show --summary HEAD printed:

 mode change 100644 => 100755 family.yaml
 delete mode 160000 members/IRRS

Decision 1 says "the index differs from HEAD", and here it does. One fix: read HEAD's entry from git ls-tree -z HEAD (no pathspec, as _tree_entries reads it) and compare mode and object with the stage-0 record. Otherwise, add it to "What it cannot tell".

F5 (not required; lane 4's call under Brett's word). In #210's state, the "keep" way out makes a commit whose row and gitlink disagree.

  • Typed as printed, git commit -- family.yaml committed M family.yaml alone.
  • HEAD:members/IRRS stayed 5876436b… while the row named 3657b10a….
  • The same bump run again finished it in a second commit (IRRS 3657b10af614 -> 3657b10af614, committed 944110eecee1: members/IRRS, family.yaml).

The "drop" way out works, as the adjusted test proves. The writer's docstring already knows this. If the refusal is to say it, it could name the drop alone where the index also stages a members/ gitlink that HEAD does not hold.

The three decisions, attacked

  1. What "dirty" means. I found no false "dirty" on a clean holder. In each case below, git status --porcelain and ls-files -m were empty and all three dry runs exited 0:

    • mtime moved +5 s;
    • the same bytes rewritten;
    • racily clean (file mtime equal to the index's);
    • core.autocrlf=true;
    • .gitattributes family.yaml text eol=crlf with a CRLF copy, then touched;
    • core.filemode=false with chmod 755.

    The only hole is F4. Assume-unchanged is disclosed: measured, +# hidden edit went into remove's commit. Skip-worktree is pre-existing (checklist 6). A holder with no family.yaml in HEAD is not asked, and its first-add test passes.

  2. family.py bump to the commit already pinned exits 2 as "REFUSED a git or gh command failed" around git's "nothing to commit", where family.py documents exit 1 for nothing and bump-leg.py answers the same re-run with "nothing to do", exit 1 #210 refused. This follows the ruling, and the "drop" way out is proven. See F5.

  3. "and commit that". Right for both member-path remedies, but incomplete: see R2.

Destructive remedies

  • git rm --cached -- members touches no file. A hand-made members/X holding its own git repository was intact afterwards (exit 0; X/f and X/.git still there). A symlink at members meets git's own dry-run refusal (pathspec 'members/IRQS' is beyond a symbolic link). The one loss is checklist 4.
  • git checkout HEAD -- family.yaml drops only what the refusal offers to drop, and it says so ("to drop it"). In the stash-pop state, git keeps the stash entry on a conflicted pop. That is git's documented behaviour; I did not measure it here.
  • Holder states. E2 and E5 are read with rev-parse --git-path and lexists, and E3 with sparse-checkout list; all are read-only. Each refusal precedes the first write, and test_a_dry_run_runs_no_git_command_that_writes passes.
  • No new network call.

Tests

  • Reverts. I spot-checked three mutants against the new file:
    • remove's _checked_manifest_committed call removed: 6 failed.
    • E1's index-held branch returning None: 4 failed, matching the PR's row.
    • ls-files -m not asked: 5 failed, matching the PR's row.
  • The six adjusted tests are honest. Each old behaviour was changed by the ruling, and none hides a regression:
  • Byte-identical output, main against merge. For add, bump and remove, each real and dry, on the same holder copy at the same path with fixed dates: stdout, stderr and the resulting HEAD were identical in 6 of 6.
  • Sonar, by reading only, not analysed:
    • no composite asserts and no pytest.raises;
    • the new functions' cognitive complexity looks well under 15;
    • the new test file repeats "remove", "members" and "commit" as its landed neighbours do (test_family_refuse_up_front.py has "remove" 12 times).

Windows

I found no concern. There are no os.name paths. The tests write bytes. The git mv exit 128 and the rebase --abort text are git's own on every OS. The N4 test builds its path with as_posix() from the same resolved root the code uses.

Runs on the local merge (git-filter-repo on PATH)

  • test_family_holder_states, test_family_member_path, test_family_bump_remove_rollback, test_family_dry_run: 169 passed.
  • test_family, test_family_refuse_up_front, test_family_add_refusals: 248 passed.
  • The four files the writer left out (test_family_init_values, test_shape_pin_files_not_a_list, test_update_shape_add, test_setup_project_py): 384 passed, 19 skipped.
  • test_repo_hygiene, test_cli_reference, test_agents_md_codes: 146 passed.

For the checklist (outside the issue)

  1. E4 reftable, wherever R3 puts it.
  2. Non-cone rules holding only a negative pattern (!/*) still get git sparse-checkout add .gitmodules members/IRRS family.yaml. Typed, it took the holder's top level from 14 entries to .git, .gitmodules, family.yaml and members (measured): E3's harm, through rules that do hold a pattern.
  3. A git stash pop that conflicted in family.yaml (no act in progress) is still REFUSED yaml-unparsable: …family.yaml: line 1: merge keys are not supported, with no remediation (measured).
  4. git rm --cached -- members drops a staged edit of the members file without a word. It exited 0 with nothing or a directory on disk, and the staged blob 601b695b… is then in neither HEAD nor the work tree (measured).
  5. A half-finished add (stopped between its stage and its commit, M .gitmodules, M family.yaml, A members/IRQS):
    • Main says member-already-present; the merge says family-manifest-uncommitted (measured).
    • Neither way out finishes it (reasoned): "keep" commits a row with no gitlink, and "drop" leaves .gitmodules and the gitlink staged.
  6. Skip-worktree on family.yaml outside a sparse checkout, edited. The dry runs exit 0, and the real remove fails late at git --literal-pathspecs add -f -- .gitmodules family.yaml. It was rolled back (status empty afterwards), on main and the merge alike (pre-existing).
  7. Not measured: validate-family.py remedies that name family.py bump --to for a work-tree-only defect will now meet family-manifest-uncommitted first.

Lane: openreposhape-1 (openRepoShape-1)

Brett Heap and others added 2 commits October 17, 2026 12:11
… staged change, `family-members-unrecognised` says to commit the fix, and a dirty `family.yaml` is compared by mode as well as object and told the drop alone beside a staged gitlink (#684)

Fix round 1, lane 1's review of #816 at 772105a (issuecomment-6233536729), on lane 4's rulings.

R1. Both remedies in `_members_file_left_in_head` said to commit "on its own" with a bare `git commit`, which takes every staged change: lane 1 measured an unrelated staged file committed beside `D members`, and beside the staged move. Each now says "(`git commit`, with nothing else staged: a bare `git commit` takes every staged change, and `git status` lists them)", lane 1's words. Not `git commit -- members`: it exits 128 where a directory is on disk (lane 1 measured).

R2. `family-members-unrecognised` said "fix it by hand this once.", and the run again then met `family-manifest-uncommitted`. That refusal is reached only once the shape it names is committed, so it now says "fix it by hand this once and commit that (`git commit -- family.yaml`), then run the same command again."

F4. "Dirty" compared the index's object with HEAD's and not its mode: a `chmod 755` staged went into `remove`'s commit as `mode change 100644 => 100755 family.yaml` (lane 1 measured). HEAD's entry is now read, mode and object, from `git ls-tree -r -z HEAD` with no pathspec (`_head_entries`), and compared with the stage-0 record. A mode staged with `git update-index --chmod=+x`, under each verb, is a new row; there `git commit -- family.yaml` has nothing to commit (measured), and the drop is the way out typed.

F5. Where the index also stages a gitlink under `members/` that HEAD does not hold, as #210's half-finished bump leaves one, the "keep" way out committed the row without its gitlink (lane 1 measured). The refusal there names the drop alone (`_gitlink_staged`), and the same command run again makes the one commit of both paths.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…TION_MARKS, and `_checked_manifest_committed` says the index's state with a plain if/elif chain, clearing the two Sonar smells #816 added (#684)

S1192: `_rebase_unreadable` returned the literal "a rebase" a third time; it now returns `(finish, abort, effect)` alone, and the caller keeps `what` from the `rebase-merge` or `rebase-apply` row it already holds. S3358: the nested conditional that chose between no change, a staged change and a staged deletion is a flat if/elif chain, with the unmerged case first and `ls-files -m` asked only where the path is merged, as before. Nothing either refusal says changes.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T12:33:04Z)

Lane 1's re-verification of #816 at 4030ee6: CHANGES NEEDED

All five fixes hold. One thing still has to change: F5's new check also fires on a gitlink a person staged by hand, and there it prints a remedy that throws away the person's own uncommitted edit (N1 below).

Setup. I merged head 4030ee6c locally with current main 741679e7. The merge was clean, and family.py auto-merged with #670. Every probe used a stub gh, a bogus token and GIT_CONFIG_GLOBAL=/dev/null.

item measured on the merge verdict
R1 Both remedies print "(git commit, with nothing else staged: a bare git commit takes every staged change, and git status lists them)". Typed bare, the commit still carries A README-other.txt, which is git's behaviour and is now stated. done
R2 The remedy now says "…and commit that (git commit -- family.yaml), then run the same command again." After the fix and its commit, remove exits 0. done
R3 closingIssuesReferences: [] and #684 is OPEN. The body's last line is "Part of #684: … E4 (reftable) stays open on #684 …". Its E4 sentence is true: git here is 2.43.0. done
F4 A staged chmod +x is now REFUSED family-manifest-uncommitted: … a change to it is staged and not committed; before, it was exit 0 with mode change 100644 => 100755. Mutant comparing the object only: 3 failed. done
F5 #210's state now gets the drop alone, and typed, the bump makes one commit of both paths. Mutant with _gitlink_staged always false: 2 failed (the new test and test_family.py's #210 test). done, but see N1
No false dirty The nine states from the first round are still clean. A committed 100755 is clean when checked out (disk 755), and clean under core.filemode=false with the disk at 644. Under core.filemode=true with the disk at 644, git itself says M family.yaml, so the refusal matches git. All dry runs exit 0 in the clean cases. holds
family.yaml a symlink in HEAD (120000) All three dry runs exit 0, on main and on the merge. holds
Unborn HEAD test_the_first_add_to_a_holder_with_no_commit_makes_its_root_commit passes. holds
Cost of ls-tree -r On a holder of 20,019 entries, ls-tree -r -z HEAD took 0.021 s for 1.4 MB. remove --dry-run took 0.26–0.28 s on main and 0.37–0.42 s on the merge, for all the new reads together. acceptable
Ordinary output add, bump and remove, real and dry, against current main: stdout, stderr and the resulting HEAD identical in 6 of 6. holds
Runs, one mode Holder states, member path, rollback and dry run: 173 passed. test_family, refuse-up-front, add refusals: 248 passed. The four files left out first time plus hygiene, CLI reference and AGENTS codes: 531 passed, 19 skipped. green
SonarCloud at the head Gate OK. One open issue: S3776 on _checked_mount_path, complexity 31. Main has the same function at 30 since #670, and this PR's E1 call adds one point. OK
CI at the head SonarCloud, package and tests-git-bash passed. tests was still running when I checked. pending

N1 (required). _gitlink_staged fires on any staged gitlink, not only one a stopped run left.

Repro:

  1. Make a commit inside members/IRRS, then git add -- members/IRRS.
  2. Write a comment line at the top of family.yaml and leave it unstaged.
  3. Run remove.

The refusal names the drop alone: "the index also stages a gitlink under members/ that HEAD does not hold, as a run stopped before its commit leaves it, and git commit -- family.yaml would commit the row without it: put back HEAD's copy, …". The same happens after git mv -- members/IRRS members/MOVED plus a comment.

In both states that sentence is untrue: no run stopped, and the change is not the row. Typed anyway, "keep" (git commit -- family.yaml) committed M family.yaml alone, which is harmless. The printed drop would have discarded the person's only copy of their edit.

Every stopped add or bump stages family.yaml beside its gitlink, so ask _gitlink_staged only where the index's own copy differs from HEAD. In _checked_manifest_committed, replace

         if _gitlink_staged(index, held) else

with

         if merged and merged != [head] and _gitlink_staged(index, held)
         else

With that one change, both states above got the two-way remedy, and the selected family_yaml, F5 (stopped_before) and #210 (bump_finishes) tests had 0 failures. Please add a test for the hand-staged gitlink with an unstaged comment.

In the body's F5 bullet, replace "Where the index also stages a gitlink under members/ that HEAD does not hold, as #210's half-finished bump leaves one," with:

Where the index stages a change to family.yaml and a gitlink under members/ that HEAD does not hold, as #210's half-finished bump leaves both,

New, for the checklist:

  • A half-finished add (all three paths staged) now gets the drop alone. Typed, the next add says REFUSED member-path-taken: members/IRQS already exists in the family root but no members: row names it, so the drop finishes a bump but not an add (measured). The docstring's "makes the one commit of both" holds for bump only.
  • A half-finished remove (M .gitmodules, M family.yaml, D members/IRRS) never reaches this check: it stops at REFUSED member-unknown: IRRS is not a member of InkRouter. That ordering is not new in this PR.
  • In a --filter=tree:0 partial clone, ls-tree -r HEAD would fetch every tree from the remote. This is reasoned only, not measured.

Lane: openreposhape-1 (openRepoShape-1)

…l` is staged beside a gitlink under `members/`, so a gitlink staged by hand beside an unstaged edit is told both ways out (#684)

Fix round 2, lane 1's re-verification of #816 at 4030ee6 (issuecomment-6233920232), N1. `_gitlink_staged` fired on any staged gitlink: a commit inside `members/IRRS` and `git add -- members/IRRS`, or `git mv -- members/IRRS members/MOVED`, beside a comment line not staged in `family.yaml`, was told to drop the edit, its only copy, under a sentence that says a run stopped there. Every stopped `add` or `bump` stages `family.yaml` beside its gitlink, so the gitlink is asked only where the index's own copy differs from HEAD too (lane 1's tested change, verbatim). Both states get the two-way remedy; a test holds each, and the old condition fails both. The docstrings say the drop and the same command make the one commit of both for `bump` only: a half-finished `add` then meets `member-path-taken` (lane 1 measured).

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T12:50:04Z)

Lane 1's re-verification of #816 at fdc90a6: PR-READY

Nothing must change at this head. The tests CI job was still running at 12:49Z, so a green Linux run is the one thing left to wait for.

Setup. I merged head fdc90a6e locally with current main cbff780c (#812 on top). The merge was clean, and family.py auto-merged. Every probe used a stub gh, a bogus token and GIT_CONFIG_GLOBAL=/dev/null.

item measured on the merge verdict
N1, a gitlink committed and staged by hand, plus an unstaged comment, through remove Exit 2 with "Remediation: which change to keep is a person's to say. To keep it, commit it on its own first, git commit -- family.yaml; to drop it, …". Typed, "keep" committed M family.yaml alone. fixed
N1, git mv -- members/IRRS members/MOVED plus an unstaged comment, through remove The same two-way remedy. "Keep" committed M family.yaml alone. fixed
N1 mutant (the old condition, any staged gitlink) 2 failed: test_a_gitlink_staged_by_hand_beside_an_unstaged_edit_is_told_both[commit-and-add] and [git-mv]. the test is honest
#210's stopped bump The real run and the dry run both name the drop alone ("the index also stages a gitlink under members/ …"). After git checkout HEAD -- family.yaml, bump exits 0 with ONE commit, M family.yaml and M members/IRRS. HEAD's gitlink is --to, and status is clean. holds
A half-finished add Drop alone, then member-path-taken, as the body's checklist item 8 now says. text true
No false dirty Clean, all dry runs exit 0, in each state: touch, same bytes, racily clean, core.autocrlf=true, eol=crlf attribute, core.filemode=false with chmod, committed 100755 checked out, committed 100755 under filemode=false with the disk at 644, and a family.yaml symlink (120000). Under filemode=true with the disk at 644, git itself says M family.yaml, so the refusal matches git. holds
Ordinary output against current main add, bump and remove, real and dry: stdout, stderr and the resulting HEAD identical in 6 of 6. holds
Runs, one mode Holder states, member path, rollback and dry run: 175 passed. test_family, refuse-up-front and add refusals: 248 passed. green
SonarCloud at the head Quality Gate passed. One open issue: S3776 on _checked_mount_path (31). Main has the same function at 30, and the PR's E1 call adds one point; the gate is OK. OK
CI at the head SonarCloud, package and tests-git-bash passed. tests was still running when I checked at 12:49Z. wait for it
Body texts The F5 bullet opens with "Where the index stages a change to family.yaml and a gitlink under members/ …" and says the one commit of both paths holds for bump only. The N1 bullet quotes the condition verbatim. Checklist items 8–10 are there. The last line is "Part of #684: …". closingIssuesReferences is [] and #684 is OPEN. true

Nothing new to report. I did not see squash-body.md, because it lives in lane 4's tree.

Lane: openreposhape-1 (openRepoShape-1)

@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 day and 23 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit fa04d2c into main Oct 17, 2026
5 checks passed
@brettheap
brettheap deleted the fix/684-family-holder-states branch October 17, 2026 17:40
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…mand-scope, and tests/test_local_remote_dir_urls.py calls `allow_file_protocol()`, so #812's test file still imports once #758 lands (#758)

The retarget after #748 landed as 431c8cf. This merge has three parents:
b6e1559 (this branch), 8368d1f (#748's final head, the restack of
deb029c this branch stood on) and fa04d2c (main). Merging #748's head
first gives git the history #748's squash dropped, so main's copy of #713
and #748 meets its own lines here. Main was d17564d when the conflicts
below were resolved; its next two landings, #809 and #816, then merged
without a conflict.

Conflicts, and how each was resolved:

- tests/test_adopt_empty_symlinks.py, against 8368d1f, which carries #388
  through main 741679e. Both sides kept: #388's two targets
  (`with_each_target`, `SAID`, the `target` parameters) and #758's
  `symlinks_off()`, built at the call and appended to the command scope the
  environment holds. The `env` parameter is `off`, and
  `test_the_link_given_a_target_adopts_end_to_end`, which git did not flag,
  calls `symlinks_off()` too: left as it was, the file fails at collection.
- tests/test_clone_just_pushed.py, tests/test_doctor_legs_members_next.py
  and tests/test_family_bump_remove_rollback.py, against main d17564d.
  Main's copy of each was 8368d1f's byte for byte (#748 squashed as
  431c8cf, and nothing since had touched them), and this branch's copy is
  8368d1f's plus #758's own hunks, so this branch's side is taken.

Against main, the merged tree differs in #758's 25 files and in
tests/test_local_remote_dir_urls.py. Each of the 25 differs by #758's own
added and removed lines, tests/test_adopt_empty_symlinks.py aside.

rv818's MUST 1 (lane 2): tests/test_local_remote_dir_urls.py came with #812
and imported `ALLOW_FILE_PROTOCOL` from test_family, which #758 replaces
with `allow_file_protocol()`, so the file failed at collection and none
of its 12 tests (27 cases) ran. It now imports `allow_file_protocol` and
calls it at the paste. No other file on main uses a name #758 removes
(`SYMLINKS_OFF`, `SYMLINKS_ON`, `CONFIG_COUNT`, `CONFIG_VALUE`,
`PASTE_ENV`, `ALLOW_FILE_PROTOCOL`), and `NO_MAINTENANCE`, `QUOTE_PATH_OFF`
and `tool_env` are used only in their own files.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…der check, and the unparsable-config state is kept as a row of its own (#686)

#816 (fa04d2c) gave `bump` its `family-manifest-uncommitted` check, whose `git ls-files -m -z` in the holder reads each gitlinked mount's repository. A mount config git cannot parse (an unclosed `[remote "origin"` header) is now refused there, as git's failure of that command, before the fetch, so `test_a_mount_config_git_cannot_read_is_gits_own_failure` no longer reached `git remote get-url origin` on the merged tree (1 failed, 417 passed on 9ee0435).

The test keeps its subject on a state that reaches the question: a `url` with no value under `[remote "origin"]`. Measured on git 2.43: the holder's `git ls-files -m -z` and `git status` exit 0, the mount's `git config --list` exits 0, and `git remote get-url origin` and the fetch both exit 128, "error: missing value for 'remote.origin.url'" and "fatal: bad config variable 'remote.origin.url' in file '<config>' at line 14". Both runs now print `git remote get-url origin`, exit 128 and git's output exactly, exit 2, with nothing moved (`both_runs`).

The unclosed header is kept as `test_a_mount_config_git_cannot_parse_is_gits_own_failure_where_met`: git's own failure, exit 128 quoted, exit 2, dry and real alike, "bad config line", nothing about `origin`, the network or a credential, and the holder's gitlink, index, family.yaml, work tree and objects unmoved, without pinning which command fails first.

Mutants on this tree, against this file and test_family_bump_to_local_only.py (20 tests): M5, `_has_origin` False on any failure, is killed (1 failed: the `url` row, member HEAD changed); M4, `_has_origin` always True, is killed (4 failed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained #748 (#662, `431c8cf8`), #731 (#692, `d66a9054`), #810
(#674, `d17564d9`), #809 (#688, `42aed788`), #816 (refs #684,
`fa04d2cd`), #813 (#689, `f6d17bc5`) and #723 (#663, `bad05d21`) since
64ec47c. No conflict, and none of them touches README.md, the handbook,
AGENTS.md, docs/ or the README cap's ledger, so every handbook marker
reads as before. README.md is 1474 lines, its cap; AGENTS.md 494, its cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
#813 and #723, v1.0.0's quoted-header and plain-path-clone limits closed and the counts current to main bad05d2 (#563)

Each entry is the one RV630 corrected in its pre-review of the B2 stage,
put at its anchor, and checked against its squash on main: the
`git merge-tree --write-tree` of main before the squash with the head the
entry was written from equals the squash's tree for all seven.

- #810 (#674, `d17564d9`): a new bullet, "`apply`'s lines, its branch and
  its headers.", after "Headers, branches and messages.". It closes
  v1.0.0's quoted `files:`/`shape:` header (v1.0.0.md:439) and `apply`'s
  half of the paths listed as staged (:433); `bump-leg.py`'s half waits
  for #746.
- #731 (#692, `d66a9054`): appended to "Folders, asked before they are
  made.".
- #813 (#689, `f6d17bc5`): appended to "`--tracking-branch`.".
- #809 (#688, `42aed788`): a new bullet, "What `family.py init` records
  and commits as.", after "Names that are not UTF-8.".
- #816 (part of #684, `fa04d2cd`): a new bullet, "More holder states,
  named.", after "family.py refuses before it writes.". #816 closes no
  issue; #684 keeps its reftable case and is on v1.1.1.
- #748 (#662, `431c8cf8`), then #723 (#663, `bad05d21`): in the CI
  bullet, after #713's sentence. With #554 and #578, #748 closes v1.0.0's
  limit on fixtures that clone a just-pushed remote by plain path (:530).

The counts, in the header comment, the counts paragraph, "What changed"
and the open list: main `bad05d2`; 120 pull requests since `bfed143`;
at 18:02Z, 3 of the `v1.1.0` milestone's 133 issues open and 130 closed.
The open list is #563 (#630), #672 (#746) and #686 (#795); #684 and #695
(#738) are on v1.1.1. #746 and #795 have not landed, so neither has an
entry yet. Nothing since 2000488 touches pyproject.toml,
src/openreposhape/, install.sh or .github/workflows/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…ver the mount's refspec and drops the `origin/` refs no ref of `origin` backs, says a tag of the remote reaches a commit no branch does instead of "a pin the rest of the world cannot fetch", and tells a mount with no `origin` it has none instead of to fix the network (#686) (#795)

Three states the words of #613's `member-to-local-only` were untrue of, from
R613's review of #625 (R613 p07, p03 and p10 on #459), and the fetch they read
from now prunes (#459 line 105). The rule is kept: a commit no branch of the
member's `origin` reaches is still refused, in both runs, before anything is
written.

A fetch refspec narrowed by hand (p07). `bump` fetched with a plain `git fetch
-q origin`, which obeys the mount's own `remote.origin.fetch`. A single-branch
mount (`+refs/heads/main:refs/remotes/origin/main`), or an `origin` with a URL
and no refspec at all, left no remote-tracking ref for the branch holding
`--to`, so a commit pushed to that branch was refused `member-to-local-only`,
"reachable from no branch". `cmd_bump` now fetches `-q origin
+refs/heads/*:refs/remotes/origin/*`, as `bump-leg.py` fetches a leg, keeping
the `fetched = False` path. The refspec is given on the command line, so the
mount's configuration is left as the person set it. Tags follow by git's own
rule, as before.

A branch the remote has deleted (#459 line 105, issuecomment-6203972266, on
Brett Heap's word relayed by lane 5, #473 issuecomment-6217084172, 13:56:50Z:
"prune the fetch the tool already makes"). The same fetch carries `--prune
--no-prune-tags`. A commit pushed to a branch that was deleted there after the
mount last fetched kept its remote-tracking ref, so `bump` pinned it with no
ref of the remote keeping it; both runs now refuse it `member-to-local-only`.
`--prune` drops every ref under `refs/remotes/origin/` that no refspec of the
fetch maps a ref of `origin` to, in the dry run too. A refspec on the command
line replaces the configured ones, so the mount's own would have lost their
refs to it (a pull request refspec, `+refs/pull/*/head:refs/remotes/origin/
pr/*`, which `git fetch origin` kept); `_origin_fetch_refspecs` therefore puts
each configured positive refspec whose destination is under
`refs/remotes/origin/` on the command line after the first, forced, so what
the person's configuration keeps there stays and is pruned only against its own
refspec. A configured refspec that writes elsewhere (a mirror's
`+refs/*:refs/*`) is never added. A configured negative refspec
(`^refs/heads/wip`) is skipped: a narrowing refspec never decides
reachability, as a positive one that leaves a branch out is overridden by
the first refspec, and git applies a negative one only to a fetch with no
refspec of its own (measured, 2.43). So a commit only an excluded branch
reaches is found on that branch and pinned, and an excluded branch the
remote has deleted is pruned like any other. A ref put
under `origin/` by hand, which no refspec maps, is dropped. Nothing outside
`refs/remotes/origin/` goes: the mount's own branches and tags stay, and
`origin/HEAD` stays, dangling where it named a deleted branch. `--no-prune-tags`
is insurance: main's plain fetch, in a mount with `remote.origin.pruneTags`,
deleted the mount's tags, which the explicit refspec already keeps, and git
2.43 prunes no tag in an explicit-refspec fetch even with `--prune-tags`, so a
mutant without the flag is equivalent and survives. Offline nothing is pruned,
and #613's rule that a bump is never refused for being offline is unchanged.

A commit only a tag of the remote reaches, which the mount holds too (p03).
A fresh clone holds it and a fetch by sha works, so "A pin the rest of the
world cannot fetch is a family the rest of the world cannot bootstrap" was
untrue there. It is still refused (D2, option 1: the rule is branches), and the
words now say what is true: `--to <c> is in the member's own object store, in
members/<P>, and a tag of <org>/<P> reaches it, but no branch of <org>/<P>
does`, then `Remediation: push it to a branch of <org>/<P> and run the same
command again: `bump` pins only a commit a branch of the member's remote
reaches, and a tag is not a branch.` `_origin_tag_reaches` asks it after a
fetch that worked, and only when a tag of the mount contains the commit. Then
`git ls-remote --tags --refs origin` says which of those tags `origin` has,
compared by object id. A tag only the mount has, or the remote's tag of the
same name at another commit, keeps #613's words, which are true of it. A
listing that fails keeps them too.

A mount with no remote named `origin`, renamed to `upstream` or removed (p10).
Its fetch failed with no network at fault, and it was told the stale-refs
sentence and to "fix the network or the credential", or, for an id it lacks,
to "check `gh auth status` or your SSH key". When the fetch fails,
`_has_origin` asks `git remote get-url origin`, whose exit 2 means "No such
remote" and nothing else; any other failure is git's, quoted, exit 2. With
none, the note reads `could not fetch members/<P>: it has no remote named
`origin``. `member-to-local-only` says the mount has no remote named `origin`,
so whether a branch reaches the commit is not known. `member-to-unknown` says
it has none to fetch from. Both tell the person to give the mount back a remote
named `origin` for the member, since `bump` asks no other remote, and to push
the commit first if it is not pushed. A mount with an `origin` whose fetch
fails gets the same words as before, byte for byte.

`bump-leg.py`'s twins are left alone. They are "somebody committed inside the
leg's mount" for a tag-held commit, the same stale-refs sentence for a leg with
no `origin`, and a fetch that does not prune; all three are #458's file.

Tests. tests/test_family_bump_reachability_words.py holds each case in both
runs: the narrowed refspec (`main` only, and none), with the refspec left as
set; the branch deleted on the remote, with the mount's own branch, tag and
`origin/HEAD` kept in a mount set to prune tags, a pull request refspec's
`origin/pr/7` kept and its deleted `pr/8` and a hand-made ref dropped, and a
notes refspec writing nothing; a negative refspec skipped, the excluded
branch's commit pinned; the tag-held
commit, typed as the commit and as its annotated tag's own id; the same-name
control; `origin` renamed and removed, each with the way out taken; the unknown
id with no `origin`; a mount whose `origin` has a `url` with no value,
where both runs quote `git remote get-url origin`, exit 128; and a mount
config git cannot parse at all, which #816's `git ls-files -m` in the
holder meets first, git's own failure either way. `local_only` in
tests/test_family_bump_to_local_only.py gains `tagged`. That file's
`add --at`-from-a-tag test is the tag-held state, so its one assertion moves to
the new words. Ten mutants, each reverting one part, are run: nine are killed,
and the one without `--no-prune-tags` survives, as said above.

Closes #686

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…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) (#846)

R612 E4, the last item of #684 (#816 landed the other five). Lane 5's probe, on git 2.47.1 with `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, and `MERGE_HEAD`, `rebase-merge/` and `sequencer/todo` stay files. `_operation_in_progress` asked for files alone, so `add`, `bump` and `remove` found neither and went on to their commit.

- `_in_ref_store` asks `git show-ref --exists <mark>`, the exact name, in the `CHERRY_PICK_HEAD` and `REVERT_HEAD` rows, where no file is there.
  - Exit 0 is held, and 2 is not.
  - 129 is not held either: that is a git before 2.43, which cannot open a reftable at all (measured on 2.43 and 2.40.4).
  - Any other exit is a failed git command, exit 2, before the first write. It is never read as "nothing in progress".
- The refusal, and `yaml-unparsable`'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. Every file check stays. `MERGE_HEAD` is a file on both backends, so its file is the whole answer.
- `tests/test_family_operation_reftable.py` has 18 cases:
  - a stand-in git holds the refusal under both runs, the 129 answer and git's own failure (this host's git, 2.43, makes no reftable);
  - the exact-name rows (a branch `CHERRY_PICK_HEAD` and a tag `REVERT_HEAD`) and the order rows run on every git;
  - the stand-in rows and the order rows check that the ask runs in the holder (rv846's M10, which asked in `Path.cwd()`, is killed on git 2.43);
  - four real-reftable rows skip, saying why, where `git init --ref-format=reftable` fails.
- Mirrors #815's `_in_ref_store` in the assembly root's `bootstrap.py`, without importing it.

Closes #684

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 17, 2026
…nd what they tell git to the `GIT_CONFIG_COUNT` entries the environment holds, so a pasted line keeps the session's maintenance settings and the Windows job's two (#758) (#818)

Against main. #748 landed as `431c8cf8`; this branch merged #748's final head and main (see Fix round 1). After that merge, #723 (`bad05d21`) and #746 (`fc867207`) landed. The branch is refreshed over main `fc867207` as `4a553e17`, one merge commit. Its only conflict was `tests/test_non_utf8_git_reads.py`'s `from conftest import` block, resolved by keeping both sides' names, as rv818 asked. The merged tree differs from main in #818's own 27 files, and #818's count scan flags no file. Tests only: 25 files under `tests/`, no shipped code, no pinned file, no README, handbook, `AGENTS.md` or `docs/cli.md` change.

Two new commits on `b6e15591`: the retarget merge `2908e46f`, then the fix commit `eaa72d9e`. Tests only, as before.

### The retarget: merge commit `2908e46f`
#748 landed as `431c8cf8` from its final head `8368d1f8`, and GitHub moved this PR's base to main. The merge commit has three parents: `b6e15591`, `8368d1f8` and main `fa04d2cd`. #748's head goes in first, so git sees the history the squash dropped.

- **#748's head did not merge clean**, as rv818 found. `tests/test_adopt_empty_symlinks.py` conflicts, because `8368d1f8` holds main `741679e7`, which carries #388.
  - I took w758's earlier resolution of that file, which rv818 checked. Main's side of the file is the same at `974d4827`, `8368d1f8` and `fa04d2cd`, so it still fits.
  - Both sides stay: #388's two targets (`with_each_target`, `SAID`, the `target` parameters) and #758's `symlinks_off()`, built at the call.
  - `test_the_link_given_a_target_adopts_end_to_end` calls `symlinks_off()` too. Git does not flag it, and left as it was the file fails at collection.
- **Main then conflicted in three files** (main was `d17564d9` when I resolved them): `tests/test_clone_just_pushed.py`, `tests/test_doctor_legs_members_next.py` and `tests/test_family_bump_remove_rollback.py`.
  - Main's copy of each was `8368d1f8`'s byte for byte.
  - This branch's copy is `8368d1f8`'s plus #758's own hunks, so this branch's side is taken.
  - Main's next two landings, #809 and #816, merged with no conflict.
- **rv818 MUST 1.** `tests/test_local_remote_dir_urls.py` came with #812 and imported `ALLOW_FILE_PROTOCOL` from `test_family`.
  - Without a change, the file fails at collection: `ImportError: cannot import name 'ALLOW_FILE_PROTOCOL' from 'test_family'`, and none of its 27 cases run.
  - It now imports `allow_file_protocol` and calls `pasted(lines, paste, allow_file_protocol())`.
  - `git grep` finds no other file on main that uses a name #758 removes (`SYMLINKS_OFF`, `SYMLINKS_ON`, `CONFIG_COUNT`, `CONFIG_VALUE`, `PASTE_ENV`, `ALLOW_FILE_PROTOCOL`). `NO_MAINTENANCE`, `QUOTE_PATH_OFF` and `tool_env` change shape, and each is used only in its own file.
- **Checked:**
  - Against main, the merged tree differs in #758's 25 files and in `tests/test_local_remote_dir_urls.py`.
  - Each of the 25 differs by exactly #758's own added and removed lines, `tests/test_adopt_empty_symlinks.py` aside.
  - Collecting the whole suite at the head finds 12,320 tests and no error.

### The fix commit `eaa72d9e`
- **SHOULD 2:** `configure` in `tests/test_adopt_reencoded_files.py`, the seventh append from #758's line 2, now sets what `appended_to_command_scope(os.environ, pairs)` returns. It gives the same entries in the same order, so no test can fail on the old form.
- **SHOULD 3:** the scan also reads a name the module binds to `"GIT_CONFIG_COUNT"` (`CONFIG_COUNT = "GIT_CONFIG_COUNT"`, annotated or not) as the count. It does so in a dict key, a setter's first argument and a subscript.
  - rv818's repro: append the base's two lines to `tests/test_adopt_root_symlinks.py`.
  - The old scan passes. The new one names line 2137.
- **SHOULD 4:** `counts_set_to_a_number` is split into one reader per node type: `in_a_dict`, `in_a_call` and `by_subscript`. The walk takes each node's reader from `READERS`. By hand, no function scores more than 5 on S3776. I could not run Sonar.
- **NIT 5:** `conftest.py` is no longer skipped. It scans clean, at the head and on today's main.
- **NIT 6:**
  - New "sees" cases: `os.putenv("GIT_CONFIG_COUNT", "1")` and a bare `setenv("GIT_CONFIG_COUNT", "1")`.
  - The walk is now `files_off_their_count(directory)`. A new test runs it over a folder of its own, and checks three things:
    - `conftest.py` and a helper module are read;
    - a `.txt` file is not;
    - a file `OWN_COUNTS` allows is named for fewer counts as well as for more.
  - Four of rv818's six surviving mutants now fail a case: `putenv` dropped, the bare-name setter dropped, `!=` changed to `>`, and `glob("test_*.py")`.
  - The fifth, `conftest.py` scanned, is now how the code works, and putting the skip back fails the new test.
  - The sixth, the count's value taking any string, still survives: it is a stricter scan. Git refuses a count that is not a number (`bogus count in GIT_CONFIG_COUNT`, exit 128), so a test that holds one fails on its own.
- **NIT 7, in part:**
  - An empty count (`""`) is now a count of its own. Git 2.43 and 2.47.1 read it as no entries at all. Measured: `GIT_CONFIG_COUNT= GIT_CONFIG_KEY_0=a.b GIT_CONFIG_VALUE_0=c git config --get a.b` exits 1.
  - The docstring says the scan reads the count's value as a constant only.
  - Left out:
    - a value built by a call (`str(len(pairs))`) or held in a variable;
    - an int;
    - `dict()`, or `environ={}` by keyword, handed to the helper;
    - `setenv` with keyword arguments;
    - an annotated subscript.

    rv818 judged only `str(len(entries))` likely, and telling it from a count worked out needs what the call means, not how it is spelled.
- **NIT 8:** the paste case deletes `GIT_CONFIG_PARAMETERS` first. Measured on the merge before the fix commit, with `GIT_CONFIG_PARAMETERS="'probe.param'='p'"`, the case failed (`Left contains one more item: 'probe.param=p'`). It passes now.
- **NIT 9, left as it is:** main's three appends built by hand each work the count out right: `tests/test_family_head_lost_gitmodules.py:97`, `tests/test_family_bootstrap_gate_order.py:135` and `tests/test_family_add_spellings_remedies.py:137`. Each of those files is also changed by another open PR (#828 `fix/761-add-move-back-gitmodules`, #820 `fix/762-validate-family-remedies-texts`, `fix/763-holder-docs-agree`), so converting them here would only add conflicts.
- **NIT 10:** the comment on `NO_MAINTENANCE` now says its `maintenance.auto` and `gc.auto` repeat the session's two, which reach these gits in the command scope. Only `gc.autoDetach` adds a setting. The two stay, so the constant alone still keeps maintenance off for these gits.
- **NIT 11:** the count was in lane 5's #473 summary, not in this body. The seven sets held 2,401 distinct passes, not 2,609: the 208-test run repeats three files of the 256-test run.

### Tests at `eaa72d9e`
All runs went through `run9.sh`, with the real `gh` unreachable. All passed, none skipped:
- **The 26 test files the PR changes against main**, in one full-file run each: 2,206 passed (1,108 + 1,098).
  - These are #758's 24 test files, `tests/test_local_remote_dir_urls.py` and `tests/test_adopt_reencoded_files.py`.
  - The count matches the collection exactly.
- **The 43 files that import any of those 26, with `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`:** 1,646 passed (825 + 821).
- **The same merge made on main `d17564d9`** (a local commit, `c16a7e1a`, never pushed), before the fix commit: `tests/test_local_remote_dir_urls.py` and `tests/test_no_auto_maintenance.py`, 55 passed.

### Fails on the base
The new tests were run against the base's scan and walk, as `b6e15591` has them, with `GIT_CONFIG_PARAMETERS` set: 6 failed, 30 passed. The six:
- the paste case;
- the `nothing`, `a-name-bound-to-it`, `an-annotated-name` and `a-name-by-subscript` cases;
- the walk test.

The three other new cases (`a-bare-setenv`, `putenv`, `a-name-bound-to-another-key`) are there to kill mutants, and they pass on any correct scan. MUST 1 fails on main's form at collection, as above. SHOULD 2 and NIT 10 change no behaviour.

### Mutants
I made 26 mutants. 25 are caught, and one survives as rv818 said it would:
- **22 on the scan and the paste case**, each run against `tests/test_no_auto_maintenance.py`:
  - the delenv dropped;
  - `putenv` dropped from `SETTERS`, or the bare-name setter;
  - the name branch of `is_the_count` dropped, or any name taken as the count;
  - `""` no longer a count;
  - the `Assign` names dropped, or the `AnnAssign` names;
  - no names collected;
  - each reader ignoring the names;
  - each of the three readers dropped from `READERS`;
  - `conftest.py` skipped;
  - `!=` changed to `>` or to `<`;
  - `glob("test_*.py")`, or `glob("*")`;
  - `by_subscript` taking any value;
  - the walk pointed at an empty folder.

  The last passes on a clean tree, like any change to an assertion that nothing is found. It is caught by rv818's SHOULD 3 repro, where the real walk fails and the mutant passes.
- **The survivor:** the count's value taking any string. It makes the scan stricter (see NIT 6).
- **`configure`:** with `appended_to_command_scope({}, pairs)`, the scan names the file. With the pairs dropped, three of its tests fail.
- **MUST 1:** main's import put back fails collection with `ImportError`.

### Merge checks at `eaa72d9e`
I ran `git merge-tree --write-tree` against `origin/main` (`fa04d2cd`) and the 53 other `fix/*` and `l3-*` heads on origin:
- **Clean:** `origin/main` and 43 heads.
- **#792 (`l3-785-non-ascii-paths`)** merges clean, but `tests/test_doctor_non_ascii_paths.py` imports `PASTE_ENV`. Whichever PR lands second changes `:50` (the import), the `:266` docstring and `:268` to `paste_env()`.
- **#723 (`fix/663-non-utf8-name-tests`), #746 (`fix/672-bump-leg-branch-and-push-line`, which now holds #723) and #806 (`fix/757-non-utf8-test-dirs`)** conflict only in the `from conftest import` block of `tests/test_non_utf8_git_reads.py` (and of `tests/test_adopt_git_reads.py`, for #806). Both sides add names, so keep them all.
- **Seven heads conflict with main itself, not with this PR:**
  - `l3-732-latin1-section` and `l3-733-ignored-sections` (`tests/test_doctor_legs_members_next.py`);
  - `fix/712-validate-pins-contracts`, `fix/766-spelled-paths` and `fix/798-adopt-legs-asked-first`;
  - `l3-677-pins-hand-step-wip` and `l3-678-naming-checkout-wip`.
- I scanned each clean merged tree's changed test files for a name #758 removes, or a count the scan flags. Only #792 shows up.
- **Main moved after the push.** #723 landed as `bad05d21`, and the two now conflict in one place: the `from conftest import` block of `tests/test_non_utf8_git_reads.py`. The fix is to keep both sides' names: `ORG, PROJECT, REPO, appended_to_command_scope, cloned_just_pushed, git, run_script, skip_message, skip_where_fsencode_gives_a_surrogate_no_byte, windows_fsencode`. Every other change merges in. On that merged tree, with the block resolved that way, the scan flags no file, and no file uses a name #758 removes.

On `deb029c9` a probe test imported `PASTE_ENV` and pasted `git config --show-origin --list` through `paste()`:
- `PASTE_ENV` held `GIT_CONFIG_COUNT=1` (`protocol.file.allow`), a stale `GIT_CONFIG_KEY_1=user.email` left from collection, and no `GIT_CONFIG_GLOBAL`.
- `os.environ` at the call held a count of 4 and the session's global file.
- The pasted git listed one command-line entry, `protocol.file.allow=always`, and `git config --get maintenance.auto` exited 1. So a pasted line ran with git's automatic maintenance on, in both scopes, as the issue said.

`paste_env()` replaces it. It reads `os.environ` at each call (minus `PYTHONPATH`), adds the identity and `PYTHONDONTWRITEBYTECODE`, and appends `protocol.file.allow` with `appended_to_command_scope`. The six uses call it. The import-time copy also predated the session's `gh` guard on `PATH`, as `test_doctor_next_is_a_command.py`'s docstring says of its own environment; a pasted line now gets the `PATH` of the call. That docstring now says `paste_env()` reads at the call too.

- **The seven.** Six are on this base, each now one call to `appended_to_command_scope`: `test_adopt_git_reads.py`, `test_adopt_nested_attributes.py`, `test_adopt_ignored_shape_files.py`, `test_adopt_root_symlinks.py`, `test_bootstrap_after_bump.py`, `test_family_bump_remove_rollback.py`. The seventh, `test_adopt_reencoded_files.py`, came with #743 and is converted in fix round 1. `test_clone_just_pushed.py:96` stays, as the issue says: it is the helper's own test.
- **The other literals.** Each sets a count of its own and is laid over `os.environ`, where the session's two settings and the Windows job's two sit, so each replaced them (`NO_MAINTENANCE` put the two maintenance keys back and dropped the job's two). I read each; each is converted, none left:
  - `test_family.py`: `ALLOW_FILE_PROTOCOL` is now `allow_file_protocol()`; its two importers (`test_siblings_family_name.py`, `test_family_siblings_placement.py`) and the four uses in `test_family.py` call it.
  - `test_family_dry_run.py`: `NO_MAINTENANCE` is a tuple of entries, appended by `isolated()`.
  - `test_non_utf8_git_reads.py`: `QUOTE_PATH_OFF` is a tuple of entries, which `tool_env()` appends; `user_name()` appends too.
  - `test_adopt_empty_symlinks.py`: `SYMLINKS_OFF` is `symlinks_off()`.
  - `test_adopt_root_symlinks.py`: `SYMLINKS_OFF` and `SYMLINKS_ON` are gone; the four tests that set the first call `links_as_files` and the two that set the second call `told_git`, which already appended.
  - one-off environments: `test_scaffold_reuse_seeded_legs.py`, `test_adopt_split_branch.py`, `test_scaffold_free_text_values.py`, `test_adopt_plan_yaml_quoting.py`, `test_setup_project_py.py`, `test_setup_sh.py`, `test_doctor_next_is_a_command.py`.
  - The two tests that build a held environment on purpose to see it kept (`test_adopt_git_reads.py`, `test_clone_just_pushed.py`) now build it with `appended_to_command_scope({}, ...)`.

Measured with a probe on a throwaway bare remote after a push (18 loose objects): the hand-built options and `clone_just_pushed`'s give the same clone, one pack and no loose object, with `upload-pack` and `pack-objects` in `GIT_TRACE`, and `origin` keeps the plain path. A plain local clone of the same remote keeps all 18 loose objects. For a clone that is not recursive the helper's `insteadOf` rewrite is inert, because no submodule is cloned; the helper adds one place for the options, not a different read.
- `test_bump_leg_dry_run_truth.py:98`, the private bare copy of the session's spec remote: a session remote. `cloned_just_pushed(..., bare=True)`; the fixture now asserts the copy is bare.
- `test_bump_leg_dry_run_truth.py:110`, `private_work`, a clone of that private remote, which tests push tags into: `cloned_just_pushed(..., recurse=False)`.
- `test_doctor_legs_members_next.py:1828` and `:1929`, `Ghost`, a clone of the first member's checkout in the test's own holder copy: not a remote, and the hand-built `-c protocol.file.allow=always` was dead weight on a direct clone. Both call `clone_member`, one helper over `cloned_just_pushed(..., recurse=False)` that asserts the legs stay empty.
- `test_family_bump_to_checks.py:235`, a clone of `a_private_remote`, a bare repository filled by a push: `cloned_just_pushed(remote, work, "main", recurse=False, env=env)`.
- `test_doctor_pin_restore_merges.py:714` is **left as it is**: a `--filter=blob:none` clone of a file URI. The helper takes a path, and has no way to pass `--filter`.

- `test_family_dry_run.py`: the comment on `NO_MAINTENANCE` says what the entries are for, the gits `isolated()` hands an environment to, whose global file `excluding()` replaces, and that the session fixture covers the module's own setup commits, which pass no `env=`.
- `tests/conftest.py`: the two i645 replacements, "repacks once its estimate, taken from `objects/17` alone, is above 256 loose objects, so two loose objects there are enough", and the `objects changed` sentence. **#713's body holds the matching sentence and needs the same two replacements; I cannot edit a body.**

`tests/test_no_auto_maintenance.py` has 17 new cases (28 in the file, 11 before):
- `test_a_pasted_line_keeps_the_entries_the_environment_holds_at_the_call`: the environment holds two entries as the Windows job does, then the two settings as the session fixture appends them, set after the doctor tests were imported. `git config --show-origin --list` through `paste()` lists the four, in order, then `protocol.file.allow`, and reads both settings from the session's global file.
- `test_no_test_sets_a_git_config_count_of_its_own`: reads every `tests/*.py` but `conftest.py` with `ast` and fails a `GIT_CONFIG_COUNT` set to a number (dict, keyword, `setenv`/`setdefault`/`putenv`, subscript) and an `appended_to_command_scope` call handed a dict written out. Three files are allowed the ones that build a held environment on purpose, with a count each in `OWN_COUNTS`.
- 15 cases that hold the scan itself: seven spellings it must see, eight it must leave (`env.get("GIT_CONFIG_COUNT", "0")` is a read).

Runs at the head `b6e15591` through `run9.sh` (the real `gh` unreachable), all passed, none skipped:
- `test_no_auto_maintenance.py`, `test_doctor_legs_members_next.py`, `test_doctor_next_is_a_command.py`: 208 passed.
- `test_clone_just_pushed.py` and `test_doctor_validator_rows.py` (it imports the doctor file), with those three: 256 passed, run one commit earlier (the later commit only moved two blank lines and reworded one failure message).
- The sweep files: `test_adopt_git_reads.py`, `test_adopt_nested_attributes.py`, `test_adopt_root_symlinks.py`, `test_adopt_ignored_shape_files.py`, `test_bootstrap_after_bump.py`, `test_family_bump_remove_rollback.py`, `test_adopt_empty_symlinks.py`, `test_adopt_split_branch.py`: 445 passed.
- `test_non_utf8_git_reads.py`, `test_family.py`, `test_siblings_family_name.py`, `test_family_siblings_placement.py`, `test_scaffold_reuse_seeded_legs.py`, `test_scaffold_free_text_values.py`, `test_adopt_plan_yaml_quoting.py`: 999 passed.
- `test_family_dry_run.py`, the five files that import its `isolated` (`test_family_bump_to_a_tag.py`, `test_family_bump_to_checks.py`, `test_family_bump_to_local_only.py`, `test_family_refuse_up_front.py`, `test_family_remove_gitmodules.py`) and `test_bump_leg_dry_run_truth.py`: 216 passed.
- `test_setup_project_py.py`, `test_setup_sh.py` and their importers (`test_adopt_shape_pin_and_work_dir.py`, `test_dry_run_asks_remotes.py`, `test_clone_url_bytes.py`, `test_setup_by_hand_commands.py`): 313 passed.
- `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_doctor_pin_restore_merges.py`, `test_git_helper_identity.py`: 172 passed. `render-cli-reference.py --check` is ok.

**On the base (`deb029c9`)**, with the new `test_no_auto_maintenance.py` copied in: 2 of the 17 fail, the paste case and the scan (it finds 12 files). The other 15 hold the scan itself and pass on any tree.

Each of the new hunks was broken in a throwaway and run against its tests:
- `paste_env`: a literal count of 1, a snapshot taken at import, and `appended_to_command_scope({}, ...)`: each fails the paste case, and the first and third the scan too.
- The 18 sites that call `appended_to_command_scope(os.environ, ...)` (or `env`), each with `{}` in place of the environment: each fails the scan. Sites: the six of line 2, `allow_file_protocol`, `isolated`, `tool_env`, `user_name` (non-UTF-8), `symlinks_off`, `git_reads_github_from`, `github_at`, `user_name` (free text), `test_adopt_plan_yaml_quoting.py`, the two setup tests, `test_doctor_next_is_a_command.py`.
- The scan: eight changes (ignore dicts, keywords, `setenv` calls, subscripts, `appended_to_command_scope({}`; flag `os.environ`; flag a read; allow fewer in `OWN_COUNTS`) each fail a scan case.
- The clone sites: dropping `bare=True` from the private remote, `recurse=False` from `clone_member`, and `"main"` from the checks clone each fail. Two **survive as equivalents**: `recurse=False` in `private_work` (the spec leg has no `.gitmodules`, so a recursive clone of it is the same clone) and `env=env` in the checks clone (a clone reads no config the isolated environment changes).
- The two comments cannot be mutated.

`git merge-tree --write-tree` of this head against `origin/main` (`974d4827`), #748 (`deb029c9`), #713 (`979bb394`) and every `fix/*` and `l3-*` head on origin (46 refs):
- **Clean:** #748, #713, and 24 of the 46 heads: 21 `fix/*` heads (every lane-5 PR but #723 and #756, and most of lane 4's) and `l3-677`, `l3-678`, `l3-rehearsal-v1.1.0`.
- **`origin/main`: one file, `tests/test_adopt_empty_symlinks.py`** (four hunks), against #388's landing (`5c990708`). The parent #748 merges clean, so this arrives with the retarget. Resolve by keeping main's lines and my `symlinks_off()`. Two more spots need the same change and git does not flag them:
  - `tests/test_adopt_empty_symlinks.py::test_the_link_given_a_target_adopts_end_to_end` uses `SYMLINKS_OFF`, which this change removes: give it the `off` parametrization.
  - `tests/test_adopt_reencoded_files.py::configure`, the seventh hand-built append, goes to `appended_to_command_scope(os.environ, pairs)`.
  - `tests/test_family_head_lost_gitmodules.py::on_disk` (on main) is one more hand-built append: `{**env, **appended_to_command_scope(env, [("core.ignorecase", ignorecase)])}`. The scan does not read for hand-built appends, so it is not required, but it is the same thing.

  I made that merge in a scratch tree (main at `bf1f099b`), resolved it as above, and ran the files this change touches that main also changed, with `test_adopt_empty_symlinks.py`, `test_adopt_reencoded_files.py`, `test_family_head_lost_gitmodules.py`, `test_no_auto_maintenance.py` and the files that import `isolated` or the doctor helpers: 653 and 245 passed, in two runs. The scan finds no other file of main's with a count set by hand.
- **#723 (`fix/663-non-utf8-name-tests`):** `tests/test_non_utf8_git_reads.py`, the `from conftest import` block, where both add names.
- **`l3-732`, `l3-733`:** `tests/test_doctor_legs_members_next.py` (the `from conftest import` block) and `tests/test_adopt_empty_symlinks.py`.
- **19 more** (`fix/664`, `fix/729` (#756) and 17 `l3-*` heads): `tests/test_adopt_empty_symlinks.py` only, because they carry main's #388 as main does.
- **Not a textual conflict, but it breaks on merge:** `l3-785-non-ascii-paths` imports `PASTE_ENV` (`tests/test_doctor_non_ascii_paths.py:47` and `:223`); it must call `paste_env()`. #812 (`fix/693-local-remote-dir`) landed first, with `tests/test_local_remote_dir_urls.py` importing `ALLOW_FILE_PROTOCOL` (`:51`, `:349`). This PR's retarget merge makes it call `allow_file_protocol()` (rv818 MUST 1).

- A pasted `git submodule update --init` still clones a local path: `paste()` takes no remotes, so no `insteadOf` rewrite reaches it (rv662's "build it from `read_through_git`"). It runs under the session's maintenance settings now, which is what the issue asks.
- Three more appends built by hand beside `appended_to_command_scope`, in files this base does not hold: `tests/test_family_head_lost_gitmodules.py:97` (main), `tests/test_family_bootstrap_gate_order.py:135` (#682), `tests/test_family_add_spellings_remedies.py:137` (#767). The scan reads for counts set to a number, not for appends worked out by hand.

Review: lane 2's independent review (issuecomment-6236874291) and its re-verification of eaa72d9, READY (issuecomment-6238781272); its new SHOULD is taken in #848, and 4a553e1 merges main over eaa72d9. CI (Brett Heap's ruling 27): H0 is the head 4a553e1 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base fc86720; and the local gate passed on its predicted squash tree 9d6c099 over main d8b8eb3: the adopt_empty_symlinks, adopt_git_reads, adopt_ignored_shape_files, adopt_nested_attributes, adopt_plan_yaml_quoting, adopt_reencoded_files, adopt_root_symlinks, adopt_split_branch, bootstrap_after_bump, bump_leg_dry_run_truth, clone_just_pushed, doctor_legs_members_next, doctor_next_is_a_command, family, family_bump_remove_rollback, family_bump_to_checks, family_dry_run, family_siblings_placement, local_remote_dir_urls, no_auto_maintenance, non_utf8_git_reads, scaffold_free_text_values, scaffold_reuse_seeded_legs, setup_project_py, setup_sh, siblings_family_name, repo_hygiene, agents_md_codes, cli_reference sets, 2359 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 #758

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

Development

Successfully merging this pull request may close these issues.

1 participant