Repository navigation
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
Conversation
…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>
…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>
Reviewer's GuideThe 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 remediationsequenceDiagram
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
State diagram for Git holder-state refusalsstateDiagram-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
Flow diagram for pre-write holder validationflowchart 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"]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(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 The issue's lines
I also ran the new test file against main's Ranked findingsR1 (required, text). Both E1 remedies print a bare Repro, in an On the merge:
A pathspec on the commit works only in some states:
The smallest true fix is the text. In both remedies in
with
As an option, print R2 (required, text). Decision 3 missed one remedy:
That refusal can only be reached once the shape is committed: an uncommitted shape meets
with
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
F4 (should fix, small). "Dirty" compares the index's object with HEAD's but not its mode. Repro: Decision 1 says "the index differs from HEAD", and here it does. One fix: read HEAD's entry from 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.
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 The three decisions, attacked
Destructive remedies
Tests
WindowsI found no concern. There are no Runs on the local merge (git-filter-repo on PATH)
For the checklist (outside the issue)
Lane: openreposhape-1 (openRepoShape-1) |
… 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>
|
(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
N1 (required). Repro:
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 In both states that sentence is untrue: no run stopped, and the change is not the row. Typed anyway, "keep" ( Every stopped if _gitlink_staged(index, held) elsewith if merged and merged != [head] and _gitlink_staged(index, held)
elseWith that one change, both states above got the two-way remedy, and the selected In the body's F5 bullet, replace "Where the index also stages a gitlink under
New, for the checklist:
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>
|
(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 Setup. I merged head
Nothing new to report. I did not see Lane: openreposhape-1 (openRepoShape-1) |
|
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 |
|
…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>
…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>
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>
#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>
…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>
…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>
…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>



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,bumpandremovemet 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 dirtyfamily.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-runalike, in the same words, exit 2. All inscripts/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 mainbf1f099b, 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:membersremedies said to commit "on its own" with a baregit commit, which takes every staged change (lane 1 measured an unrelated staged file committed besideD members, and beside the staged move). Each now says "(git commit, with nothing else staged: a baregit committakes every staged change, andgit statuslists them)", lane 1's words. Notgit commit -- members: it exits 128 where a directory is on disk (lane 1 measured).family-members-unrecognisedsaid "fix it by hand this once.", and the run again then metfamily-manifest-uncommitted. It 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." (Family.rewritten;test_family_add_refusals.pyholds the text.)add/bump/remove: amembersfile deleted unstaged, agit bisect, an empty sparse-checkout file, an emptyrebase-merge/and a merge conflicted infamily.yamlmeet a refusal or remedy that does not fit (#626's gaps) #684 stays open after this PR, for E4 (the last line says so); the closing keyword is gone.git ls-tree -r -z HEADwith no pathspec (_head_entries) and compared with the stage-0 record. Lane 1 measured a stagedchmod 755going intoremove's commit (mode change 100644 => 100755 family.yaml).family.yamland a gitlink undermembers/that HEAD does not hold, as family.pybumpto 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 leaves both, the refusal names the drop ALONE (_gitlink_staged): there "keep" committed the row without its gitlink (lane 1 measured), while the drop and the samebumpagain make the one commit of both paths. That holds forbumponly: a half-finishedaddthen meetsmember-path-taken(lane 1 measured; checklist below).Fix round 2 answers lane 1's re-verification at
4030ee6c(issuecomment-6233920232):family.yaml(a commit insidemembers/IRRSandgit add -- members/IRRS, orgit 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 stoppedaddorbumpstagesfamily.yamlbeside its gitlink, so_gitlink_stagedis 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 bisectin progress.BISECT_LOGis 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, andgit bisect resetfails 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 saidfamily-holder-detachedwith agit switch -cthat would branch from the commit under test; the dry run printed a plan. Now:E5, an empty
rebase-merge/(orrebase-apply/with noapplying). A rebase directory with nohead-nameis named withgit rebase --quit, the exit that works there (--abortand--continueboth fail,could not read '.git/rebase-merge/head-name', measured): "rebase-merge/is what a rebase left behind, and it holds nohead-name, so git can read no rebase from it and there is nothing to continue or give up: clear it without moving HEAD withgit rebase --quit."E3, non-cone sparse rules that hold no pattern (an emptied
.git/info/sparse-checkout). Every staged path is still refused asfamily-sparse-excluded; the remedy now namesgit sparse-checkout disableALONE, and says why: "the rules hold no pattern at all, so they leave out every path, andgit sparse-checkout addwould keep only the paths it is given and take every other file out of the work tree". Measured: theaddit printed took the holder's top level down to.git,.gitmodules,family.yamlandmembers. Asked of git (git sparse-checkout listprints nothing), and only of non-cone rules: a cone of no directories keeps itsgit sparse-checkout add membersremedy, which works there.N4, a merge conflicted in
family.yamlitself. As the issue directs (W612's fallback): the marks are asked only once the parse has failed, so themember-pathrefusal still runs no git command first (test_family_member_path.py::test_the_refusal_is_made_before_any_git_callholds it).yaml-unparsablekeeps 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
membersFILE that HEAD holds and the work tree no longer does. It passed everyaddcheck, and the mount, putting a directory where the file was, tookD membersinto the add's commit. Measured on the code before this round, with the deletion unstaged:addexit 0, its commitA .gitmodules,M family.yaml,D members,A members/IRQS._members_file_left_in_headrefuses both halves asmember-path-unaddable:git mvfamily.py:add,bumpandremoverefuse before their first write a merge, cherry-pick, revert, rebase orgit amin progress, a.gitmodules,members/orfamily.yamlthe sparse checkout leaves out, and a file namedmembers, which each failed after writing and was rolled back, or committed in the wrong place (#612) #626's remedy names, or agit rm): "Commit that change on its own first (git commit, with nothing else staged: a baregit committakes every staged change, andgit statuslists them)".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, orfatal: Directory members is in index and no submodule?where a directory was made), andgit checkout -- membersfirst 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.yamlholding a change HEAD does not (#459 line 66).add,bumpandremoverewritefamily.yamlas the work tree has it and commit it withgit 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:git stash popthat 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.members/(F5) the remediation is the drop alone:git ls-files -s -z(_index_records),git ls-tree -r -z HEAD(_head_entries, mode and object, F4) andgit ls-files -m -z, and NOTgit hash-object:test_family_dry_run.py::test_a_dry_run_runs_no_git_command_that_writesholdshash-objecttobump's nothing-to-do alone.family.yaml(one made by hand, with no commit yet) is not asked:addgives 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).member-pathremedies now say to commit a row put back by hand ("Putpath: members/IRRSback in family.yaml and commit that, orgit checkout -- family.yamlif the edit is not committed, …"; theproject: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:
test_family.py::test_bump_to_the_commit_already_pinned_leaves_an_uncommitted_edit_alone: now refusedfamily-manifest-uncommitted, the edit left alone (it wasnothing to do, exit 1).test_family.py::test_bump_finishes_a_bump_an_earlier_run_staged_and_did_not_commit(family.pybumpto 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): the stagedfamily.yamlis now refused first, naming the drop alone (F5);git checkout HEAD -- family.yaml, then the same bump finishes in ONE commit of both paths, as the test held. This changes what family.pybumpto 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 meets, under Brett Heap's word. Since family.pybumpandremovehave no rollback: when a git step afterwrite_membersfails (a holderpre-commithook that exits 1, a heldindex.lock), they exit 2 withREFUSED a git or gh command failedand leavefamily.yamlrewritten, the member checked out at--to(or deinitialised and removed), and the change staged or half-staged, without a word about any of it, whereaddputs the holder back and names what it put back #365 the bump's rollback (PinRollback) puts back a commit that fails, so the tool itself leaves that state only when it is stopped between the stage and the commit (not measured here).test_family_member_path.py: the[no-path-line]control commits its hand edit first; the two remedy texts.test_family_add_refusals.py: thefamily-members-unrecognisedremedy text (R2).members:line intest_family_bump_remove_rollback.py(2 tests) andtest_family_dry_run.py(2 places) is committed, astest_family_add_refusals.py's twin already was, sofamily-members-unrecognisedis still what they reach.Tests
New file
tests/test_family_holder_states.py, 40 tests, on the helpers oftest_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-runalike, 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.rebase-merge/under each verb, and an emptyrebase-apply/;--abortand--continuefailing there is the premise.init --no-cone), each verb; the premise that theaddit printed left four entries; a cone of none keeps its remedy.family.yaml, each verb; the same line with no act in progress; afamily.yamloutside any repository.members/holding a file, real and dry, thegit mvfailure the premise, and the directory and its file still there after the remedy.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 conflictedgit stash pop), each with the way out typed; a mode staged withgit update-index --chmod=+x, under each verb (F4); family.pybumpto 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 committingfamily.yamlalone (N1); themember-pathremedy for a committed edit typed through to a run.Fix round 1's run over the bytes of head
bc8024fc(scripts/family.pysha256d5cdac4a…, the new filec09dadc1…), 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_referenceandtest_agents_md_codes: 951 passed, 19 skipped, 0 failed, 12m33s. The skips are alltest_family_init_values.py's (a template that quotes its placeholder orid:). Main was not merged this round.Fix round 2's run over the bytes of head
fdc90a6e(scripts/family.pysha25648da8f37…, the new file2800a4cb…):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_referenceandtest_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.pysha25663b17251…, every test file as atbc8024fc):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_referenceandtest_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.pysha256f86d9172…, the new file4b89cf1d…): 32 of the 36 test files that runfamily.py's verbs (test_bootstrap_spelling,test_bump_leg,test_doctor_next_is_a_command,test_family, 17test_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_helperand the fivetest_validate_family_*; the coordinator left outtest_setup_project_py,test_family_init_values,test_shape_pin_files_not_a_listandtest_update_shape_add), the new file,tests/test_repo_hygiene.py,tests/test_cli_reference.pyandtests/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) andtest_shape_doctor.py:10429×3 (the PowerShell line, Windows only). The xfail istest_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):family.py)git ls-files -mnot askedmember-pathchainbumpbump's caseBISECT_LOGdropped from the markshead-nameread as a rebaseyaml-unparsablein an actls-files -m, in words the row does not expect)What it cannot tell
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'subuntu-latestgit and assert a result nobody here has measured.family.yamlthe index marks assume-unchanged (or skip-worktree outside a sparse checkout):git ls-files -mdoes not compare it, and nohash-objectis asked, so an unstaged edit to it is not seen.git update-index --chmodthat the copy on disk does not have, is refused, and theregit commit -- family.yamlhas nothing to commit (exit 1, both measured);git checkout HEAD -- family.yaml, the other way out, works. The docstring says so.core.filemode=false, as on Windows): there only the mode compare refuses the mode row. On Linuxgit ls-files -mlists the copy as well, so the F4 mutant was still refused here, in words the row does not expect, and failed it that way.ls-files -zandls-tree -r -z; the tests write files as bytes, pass no paths on stdin, and stage the mode withgit update-index --chmod=+x, which works wherechmoddoes 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:
add/bump/remove: amembersfile deleted unstaged, agit bisect, an empty sparse-checkout file, an emptyrebase-merge/and a merge conflicted infamily.yamlmeet a refusal or remedy that does not fit (#626's gaps) #684 (R3).!/*) still getgit sparse-checkout add .gitmodules members/IRRS family.yaml. Typed, it took the holder's top level from 14 entries to.git,.gitmodules,family.yamlandmembers(measured): E3's harm, through rules that do hold a pattern.git stash popthat conflicted infamily.yaml(no act in progress) is stillREFUSED yaml-unparsable: …family.yaml: line 1: merge keys are not supported, with no remediation (measured).git rm --cached -- membersdrops a staged edit of themembersfile without a word: it exited 0 with nothing or a directory on disk, and the staged blob601b695b…is then in neither HEAD nor the work tree (measured).add(stopped between its stage and its commit:M .gitmodules,M family.yaml,A members/IRQS): main saysmember-already-present, this PRfamily-manifest-uncommitted(measured, at772105aa). Neither way out finishes it (reasoned): "keep" commits a row with no gitlink, "drop" leaves.gitmodulesand the gitlink staged. (Since F5 the drop alone is named there: item 8.)family.yamloutside a sparse checkout, edited: the dry runs exit 0, and the realremovefails late atgit --literal-pathspecs add -f -- .gitmodules family.yaml, rolled back (status empty afterwards), on main and here alike (pre-existing).validate-family.pyremedies that namefamily.py bump --tofor a work-tree-only defect will now meetfamily-manifest-uncommittedfirst.From lane 1's re-verification at
4030ee6c:add(all three paths staged) now gets the drop alone. Typed, the nextaddsaysREFUSED 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 forbumponly.remove(M .gitmodules,M family.yaml,D members/IRRS) never reaches this check: it stops atREFUSED member-unknown: IRRS is not a member of InkRouter. That ordering is not new in this PR.--filter=tree:0partial clone,ls-tree -r HEADwould fetch every tree from the remote. This is reasoned only, not measured.Shared files
scripts/family.pyand six test files.git merge-tree --write-treeof headbc8024fcagainst fresh main741679e7and every open pull request in those files, each head read withls-remotejust before:741679e7fix/692-raw-folder-pathsab55c29ffix/681-validate-family-remedies(tests/test_family.py)ff7c59b8fix/662-insteadof-helper(tests/test_family_bump_remove_rollback.py)8368d1f8fix/685-spellings-remediesac764679l3-735-sibling-slot(tests/test_family.py)1d149933l3-784-dangling-slot(tests/test_family.py)e966a684fix/686-bump-reachability7d7a69b1fix/690-siblings-inputs(holdersiblings.py; named by lane 4)dc1c8e45fix/688-family-init-values6958dfbcfix/693-local-remote-dir372196a7fix/689-next-push-refspec(alsotests/test_family_add_refusals.py)15c3b5abfix/766-spelled-paths(alsotests/test_family_bump_remove_rollback.py)0e516bbafix/758-git-command-scope(tests/test_family.py,…_bump_remove_rollback.py,…_dry_run.py)b6e15591tests/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, leavesfamily.yamlchanged before anadd,bumporremove(grepped: their edits are committed, go to the validator, or are aninitor 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:
Bug Fixes:
Enhancements:
Tests: