Repository navigation
update-shape.py, family.py and bump-leg.py: the branch HEAD is on is read as the bytes git wrote, so a branch ending in U+00A0 is no longer read as its neighbour, and a name that is not UTF-8 is refused by name before anything is written (#765) - #849
Conversation
…read as the bytes git wrote, so a branch ending in U+00A0 is no longer read as its neighbour, and one whose name is not UTF-8 is refused by name before anything is written, where a push line named a branch that is not there (#765) Each of the three tools reads `git symbolic-ref -q HEAD` as bytes in one helper: exactly one newline and exactly `refs/heads/` come off, and the rest is decoded with `surrogateescape`. No `strip()`, no `errors="replace"`. - update-shape.py: `_symbolic_head` is the one read; `current_branch` takes the branch from it, and `Rollback` reads HEAD with it too. `current_branch` refuses `update-branch-not-utf8`, and `cmd_apply` asks it before the first write of every `--branch` run. - scripts/family.py: `_holder_branch` refuses `family-branch-not-utf8`; the real run of `add`, `bump` and `remove` asks it before the member is read. - scripts/bump-leg.py: `current_branch` refuses `bump-leg-branch-not-utf8`, the dry run alike, before the fetch. Each refusal names the branch as Python spells it and ends with `git -C <root> branch -m <a new name>`. No push line is printed for a name the tool cannot spell. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideThe PR makes all three tools derive the current branch from Git’s raw symbolic-ref bytes, preserving branch names exactly and refusing names containing undecodable UTF-8 before they can produce invalid push or rollback commands; it also adds early update-shape validation and comprehensive byte-level regression tests. Sequence diagram for byte-preserving branch validationsequenceDiagram
participant Tool
participant Git
participant Validator
participant Workspace
Tool->>Git: symbolic-ref --quiet HEAD
Git-->>Tool: raw ref bytes
Tool->>Validator: decode with surrogateescape
Validator->>Validator: branch.encode(utf-8)
alt branch name is valid UTF-8
Validator-->>Tool: current branch
Tool->>Workspace: fetch, write, commit, or print push line
else branch name is not UTF-8
Validator-->>Tool: Refusal
Tool-->>Workspace: no fetch, write, or commit
end
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-17T23:07:15Z) Lane 1's independent review of #849 at 25692a3: CHANGES NEEDED Read-only review by r765 (lane openRepoShape-1), against main
Findings, ranked1. BLOCKING: CI
|
| Revert row, at the head | Result |
|---|---|
| U3 | 1 failed: ..._not_utf8_is_refused_first |
| F1 | 1 failed: family ..._no_break_space_is_pushed_as_itself |
| B1 | 2 failed: bump-leg [bump], [dry-run] |
| R1 (new) | 62 passed (finding 3) |
Windows
U+00A0, U+2028 and U+0085.
- NTFS takes them, and Win32 trims only a trailing space or dot, so the loose ref works and the three reads keep it.
- Family's NEXT is in PowerShell single quotes, which take the character literally.
- A redirected cp1252 stdout cannot hold U+2028 or U+0085.
escape_what_stdout_cannot_encodethen writes\u2028, which, pasted, names another branch: the push failssrc refspec ... does not match anyand sends nothing. POSIX's C locale does the same. - The body says this only of U+00A0.
The byte 0xE9 (read, not run).
- Git for Windows' path conversion (
xutftowcsn) maps invalid bytes 0xA0-0xFF to U+00A0-U+00FF, so the loose ref is the filecaf<U+00E9>. - HEAD's file keeps the raw 0xE9, which
symbolic-refprints. - So the tools refuse as on Linux, and
branch -mrenames the branch. - The new cases skip on Windows. Their names are NTFS-legal, and no test counts text-mode bytes.
Exact text replacements
- How, family bullet (with Fix A). Replace
Its .strip() is gone. This is the only family.py hunk.with:Its
.strip()is gone. Its remedy'sbranch -mline is quoted for a POSIX shell (shell_line(..., "posix")), as bump-leg's and update-shape's are, so README's sentence on the lines quoted for PowerShell stays true. This is the only family.py hunk. - How, update-shape (with Fix 2). Add:
Rollback._undo_branchhands git the branch HEAD started on through_as_git_argument, sosubprocesswrites the name's own UTF-8 bytes under any locale, as bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 does forbootstrap.py. UnderLC_ALL=C PYTHONUTF8=0, a rollback fromfeat<U+00A0>raisedUnicodeEncodeErrorpart way and left the copies written; one from<U+65E5><U+672C>did so on main too.
- Known limits, bullet 1. Replace with:
update-shape.pynow refuses runs that main completed. On main,apply --branch <new>from HEAD oncaf<0xE9>committed and pushed the new branch, and one with nothing to do saidnothing to do, exit 0, since neither names the branch HEAD started on unless a rollback runs. Everyapply --branchrun on such a branch is refused now, exit 2, as the coordinator decided for all three tools: refuse a name the tool cannot spell, before anything is written.applywithout--branchnames no branch and runs as on main. The way out is onegit branch -m.
- Known limits (only if Fix 2 is not taken). Add:
- Under
LC_ALL=C PYTHONUTF8=0,update-shape.py's rollback raises on a branch name that is not ASCII, which it hands back to git as UTF-8 text. Fromfeat<U+00A0>it now raisesUnicodeEncodeErrorpart way, leaving the new branch and the copies; main read the name asfeatand put HEAD back there.<U+65E5><U+672C>raised on main too.
- Under
- Overlaps, bullet 1. Replace with:
- Lane 4's family.py:
bumpfetches every branch of the member'soriginwhatever the mount's refspec and drops theorigin/refs no ref oforiginbacks, 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 nooriginit has none instead of to fix the network (#686) #795, which editedscripts/family.py'sbumpfetch, landed as6f64eea. This PR's only family.py hunk is_holder_branch, so the two touch different lines, and the head merges with main677f9bfawithout a conflict.
- Lane 4's family.py:
- Not in this PR, bullet 1. Replace with:
scripts/repo_shape.py, which is a pinned shape copy. bootstrap.py and repo_shape.py: a leg on a branch whose name is not UTF-8 makes every latermake bootstrapend in a UnicodeDecodeError, and a character the filesystem encoding lacks still ends step (a) in a traceback #710's ruling (Option 2, landed in bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 as0dee22b) keepsgit_out's strict decode and itsstrip(), sofamily.py's_member_head, which reads a MEMBER's HEAD through it for a rollback, is unchanged; it goes to Tracking: adopt-project.py, converting an existing repository in place #457's checklist.
- Tests.
- Add
tests/test_readme_windows_quoting_sentence.pyto the guards. - Add the rollback case to the case table, and R1 to the revert table.
- Refresh both modes' counts.
- Add
For the checklist
- family
_member_headreads a member's HEAD forPinRollbackthroughgit_out's strict decode andstrip(). Read from the source, not measured:- a member on
feat<U+00A0>besidefeatat the same commit is put back onfeat; - a member on a non-UTF-8 branch raises
UnicodeDecodeErrorasPinRollbackstarts, since onlyRefusalis caught.
- a member on
- bump-leg
restore_legpasses the leg's branch throughchecked_value(SAFE_ARG_RE, ASCII only), so a leg on<U+65E5><U+672C>is refusedunsafe-valueinside the rollback. That is besidesleg_placement'serrors="replace"andstrip(). Read from the source. - update-shape under
LC_ALL=C PYTHONUTF8=0: any non-ASCII branch reaches finding 2's raise, on main too. - No test holds the family dry run's late refusal (
_branch_said).
Lane 1 takes a fix round on the same branch now: Fix A for finding 1, Fix 2 with the rollback test for findings 2 and 3, and the text replacements. The same reviewer re-verifies.
Lane: openreposhape-1 (openRepoShape-1)
…branch it started on as git's own bytes, the `branch -m` remedy is quoted for a POSIX shell, and a branch that is not UTF-8 is named by its bytes, so the rollback no longer raises under a C locale and README's quoting sentence holds (#765) Fix round 1 for #849, after r765's review (issuecomment-6242841859) and the coordinator's rulings. - scripts/family.py: `_holder_branch`'s `branch -m` remedy is `shell_line([...], "posix")`, so `tests/test_readme_windows_quoting_sentence.py` passes again (finding 1, Fix A). - update-shape.py: `_as_git_argument` spells a name read from git as the argument that hands git the same bytes under any locale, as #824 does for bootstrap.py; `Rollback._undo_branch`'s checkout uses it. Under `LC_ALL=C PYTHONUTF8=0` a rollback from `feat<U+00A0>` raised `UnicodeEncodeError` part way (finding 2). - tests: r765's `test_a_rollback_puts_head_back_on_the_branch_it_started_on`, in both locales (finding 3); `held_by_git` also holds each `.git/modules` git directory's refs, objects and FETCH_HEAD, so "before the fetch" is asserted (finding 4). - The three `*-branch-not-utf8` refusals name the branch by its bytes, `b'caf<0xE9>'`, as `trailer-not-utf8` names a trailer. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-1, 2026-10-18T00:48:37Z) Lane 1's re-verification of #849 at d98df56: PR-READY Read-only re-verification by r765 (lane openRepoShape-1) of w765's fix round 1 ( The four findings
The bytes form
The writer's claim: only
|
| Row | Result |
|---|---|
| FA: host quoting back | 1 failed: the quoting test, [scripts/family.py] |
R2: no _as_git_argument |
1 failed: rollback [c-locale] |
U1: _symbolic_head .strip() back |
3 failed: update-shape no-break space, rollback [locale] and [c-locale] |
BN: bump-leg {branch!r} back |
2 failed: bump-leg not UTF-8, [bump] and [dry-run] |
With nothing reverted, the same selection gives 30 passed. All four rows match w765's table.
Gate: merge 1e6fecb6, dirty 0, git-filter-repo on PATH
The files:
- the PR's three test files;
test_shape_doctor.py,test_scaffold_leg_path_collisions.pyandtest_cli_reference.py;- all 29
test_family*.py; test_agents_md_codes.py,test_git_output_decoding.py,test_non_utf8_git_reads.py,test_update_shape_partial_rollback.py,test_bootstrap_non_utf8_branch.pyandtest_readme_windows_quoting_sentence.py;- w765's guards:
test_validator_child_encoding.py,test_update_shape_kept_helper.py,test_doctor_next_is_a_command.py,test_openreposhape_package.py,test_setup_project_py.py,test_ruleset_hint_tracking_branch.py,test_template_values_single_pass.py,test_pinned_copy_text.py,test_refusal_default_remediation.pyandtest_repo_hygiene.py.
Result: 2,999 passed, 26 skipped, 1 xfailed, in seven shards, each EXIT=0.
Texts
The body:
- is 21,095 characters by
len(); - has the Lane, Claim and v1.1.1 lines,
Closes #765and the robot line; - holds no non-ASCII character except the robot face.
Every other sentence I checked is true at the head, including the Known limits for the rulings, the revert table, and the Why and Windows sections. Two sentences are false:
-
What, last bullet. Replace
update-shape.py'sRollback._undo_branchis the one place any of the three tools hands a branch it READ back to git, and it now goes through_as_git_argument, as bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 does forbootstrap.py.with
update-shape.py'sRollback._undo_branchis the one place a branch returned by one of these three reads goes back to git, and it now goes through_as_git_argument, as bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 does forbootstrap.py. Two rollbacks outside this PR hand git a branch they read for themselves: a member's in family.py (_member_head, then_head_back) and a leg's in bump-leg.py (leg_placement, thenrestore_leg). Both are under "Not in this PR". -
Overlaps, bullet 3. Replace
gains four helpers and two constantswithgains three helpers and two constants.- The helpers are
git_answer_bytes,held_by_gitandon_a_branch_that_is_not_utf_8. - The constants are
NOT_UTF_8_BRANCHandNOT_UTF_8_BRANCH_NAMED.
- The helpers are
-
Optional, Overlaps bullet 1.
merges with main d8b8eb33 without a conflictis still true. The merge with main112eeed2is clean too (measured), if you want the newer sha.
A nit that does not block: one docstring line in tests/test_family_tracking_branch.py is 103 characters long (escape, which name another branch. Refused by name now, by its bytes, before anything is fetched or). Rewrap it the next time the file is touched.
For the checklist
- family
_member_head->_head_back. Besides thestrip()and the strict decode, a member on a non-ASCII UTF-8 branch is handed back to git as text underLC_ALL=C PYTHONUTF8=0. That is the class Fix 2 closed in update-shape. Read from the source. - bump-leg
restore_leg.checked_valuetakes ASCII only, so a non-ASCII leg branch is refusedunsafe-valueinside the rollback. - No test holds the family dry run's late refusal (
_branch_said).
Lane 1 applies texts 1 and 2 to the body now, and names the newer main. The docstring nit is non-blocking. #849 joins lane 1's landing turns.
Lane: openreposhape-1 (openRepoShape-1)
…kept, and the two `*-branch-not-utf8` refusals name the root as `shown_path` names it, as #766 (5fc049b) names it in the refusals beside them (#765) The conflicts were in two test files, from main's 5fc049b (#766, #814): - tests/test_bump_leg_branch_and_push_line.py, the module docstring: both paragraphs kept, #765's then #766's, and #766's says the `bump-leg-branch-not-utf8` refusal names the root as the others do. - tests/test_bump_leg_branch_and_push_line.py, the conftest imports: #849's names and #766's import line, both kept. - tests/test_family_tracking_branch.py, the conftest imports: the same, with `SHOWN_NAMES` and `named_as` added to #766's line for the new row. One change in meaning. `bump-leg-branch-not-utf8` is raised in `_branch_to_bump_from` and `family-branch-not-utf8` in `_checked_branch`, the checks whose other refusals #766 made name the root and the holder as `shown_path` names a path. Both named it raw, so a root under a folder holding an ESC or a line break sent it to the terminal from this one refusal. Both details use `shown_path(root)` now. A printable path is named byte for byte, so #849's own cases are unchanged. The `branch -m` remedy is a command to paste and holds the path raw inside its quotes, as the `switch -c` beside it does. New rows, one per tool, over `SHOWN_NAMES`: `test_a_root_on_a_branch_that_is_not_utf8_is_named_safe_to_show` and `test_a_holder_on_a_branch_that_is_not_utf8_is_named_safe_to_show`. Also rewrapped: the 103-character docstring line r765 noted in tests/test_family_tracking_branch.py. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-1, 2026-10-18T05:24:59Z) # r765m: re-check of the merge round of PR #849 (issue #765) at Verdict: PR-READY. No code change is needed.
1.
|
| Reverted | Result |
|---|---|
| nothing | 6 passed |
both tools back to {root} |
4 failed, 2 passed: [ESC] and [line-break] fail in both |
| bump-leg only | 2 failed, 4 passed ([ESC], [line-break]) |
| family only | 2 failed, 4 passed ([ESC], [line-break]) |
| restored | 6 passed; git diff empty, git status --porcelain empty |
- Bytes-level check of the new tests:
- They assert exit 2, an empty stdout, and the whole detail line.
- They compare against
spelt, which isrepr(str(path)), not againstshown_path, so the oracle is independent.
- Writer's three revert rows re-run at
da3fe1c8on the selection of 12 old cases plus 6 new rows:
| Row | Result | Writer's table |
|---|---|---|
| none | 18 passed |
18 |
F1: .strip() back in _holder_branch |
1 failed (family no-break space) |
1 |
B3: .strip() back in bump-leg current_branch |
2 failed (beside a branch, beside the tracking branch) |
2 |
U1: .strip() back in _symbolic_head |
3 failed (no-break space, rollback [locale], [c-locale]) |
3 |
3. Targeted gate on a local-only merge
- Merge:
da3fe1c8withorigin/main33419925, the current main when I fetched.- Merge commit
246eb9ed, treec34d4048, the same tree the writer reported. merge-tree --messagesprintedAuto-merging scripts/bump-leg.pyandAuto-merging update-shape.py, with no conflict.
- Merge commit
- Worktree and files: the worktree was under
_lane1-scratch/r765m/and is removed. The 44 files:- every
test_family*.py(31) andtest_bump_leg*.py(8); - the PR's three test files, which are among those;
test_update_shape_apply_lines_and_headers.py,test_agents_md_codes.py,test_readme_windows_quoting_sentence.py,test_shape_doctor.pyandtest_cli_reference.py.
- every
- How: each file in its own pytest run, 5 shards,
/usr/bin/python3 -m pytest -p no:cacheprovider, stubghfirst on PATH,GH_TOKEN=bogus-r765m, an emptyGH_CONFIG_DIR,GIT_CONFIG_GLOBAL=/dev/null,GIT_SSH_COMMAND=false.GIT_ALLOW_PROTOCOLwas never set.
| Mode | Result | Shard EXIT |
File exits |
|---|---|---|---|
git-filter-repo on PATH (/home/brett/.local/bin/git-filter-repo) |
2,048 passed, 27 skipped, 0 failed | all 0 | all 0 |
hidden (PATH=<stub gh>:/usr/local/bin:/usr/bin:/bin, command -v git-filter-repo rc 1) |
2,043 passed, 32 skipped, 0 failed | all 0 | all 0 |
- Hidden mode: the 5 extra skips are 4 in
test_shape_doctor.pyand 1 intest_update_shape_apply_lines_and_headers.py. These match the body's "14 + 4 + 1" split. The 14 istest_update_shape_merged_away.py, which is outside my 44 files. - Dirty state: every shard log records
DIRTY=0atHEAD=246eb9ed. - Match with the writer: the per-file counts of all 44 files are identical to the writer's logs at
da3fe1c8in both modes. So main33419925changed no result in these files. - The body's "201 passed": the five files named in the body give
44 + 28 + 42 + 40 + 47 = 201 passed. This agrees with the body. - Main moved again. It is now
3bf2d3c9(5c54af20,3bf2d3c9).- Neither commit touches a PR file.
merge-treewith it is clean (Auto-merging scripts/bump-leg.py,Auto-merging update-shape.py).- I did not re-run the gate on it.
- The
shape-doctor.pychange in3bf2d3c9is main's own business.
4. CI at da3fe1c8
gh pr view 849 reads head da3fe1c8, draft, merge state UNKNOWN at the last read. The checks:
| Check | State |
|---|---|
| SonarCloud Code Analysis | pass (1m11s) |
| package | pass (32s) |
| tests-git-bash | pass (2m14s) |
| tests | pending |
- SonarCloud:
- The issues API (
pullRequest=849&resolved=false) returned"total":0,"issues":[]. - The quality gate is
OK:new_duplicated_lines_densityis0.0, the ratings are1, and security hotspots reviewed are100.0.
- The issues API (
tests:- Run
39098477406hasheadShada3fe1c8. The job was in step 7, "tests", at 05:23:54Z. - It had run 33 minutes at my last read, and I did not wait for it.
- The earlier run
39080013572atd98df568took 64m25s and passed.
- Run
5. The body
The posted body equals pr765-body.md: 28,135 characters, and the only non-ASCII character is the robot face. Everything in "Merge of main", and every line it dates "before the merge", checks out against the head:
- Merge facts:
da3fe1c8, parentsd98df568andd6071c7d, nothing squashed or rebased. Three hunks in two files, all from family.py, scaffold-project.py and bump-leg.py:init's plan rows and seven refusals name a holder, a folder, a lock, a remote path or a root as it is, so an ESC, a line break or U+202E in a path reaches the terminal as #692's refusals no longer let it #766 (family.py, scaffold-project.py and bump-leg.py:init's plan rows, seven refusals and bump-leg's root name each path asshown_pathnames it, so an ESC or a line break in a holder, folder, lock, local remote or root no longer reaches the terminal from those plan rows or those refusals' details #814). The table rows and the "both" resolutions match the remerge diff. - Unchanged files:
- Main
d6071c7dagainstfc867207leavesupdate-shape.pyandtests/test_update_shape_apply_lines_and_headers.pyunchanged. - The
+/-lines of those two files and ofconftest.pyhave identical md5s acrossd98df568–fc867207andda3fe1c8–d6071c7d. scripts/bump-leg.py,scripts/family.pyandtests/conftest.pyauto-merged, so the body is right that they needed no hand-merge.
- Main
- The change in meaning:
- The refusals'
{root}was raw, and their docstrings say the others useshown_path. I read both docstrings. - Printable paths are unchanged.
- The
branch -mremedy is unchanged and keeps the path raw, asswitch -cdoes. update-shape.pyuses noshown_path.
- The refusals'
- New tests and reverts:
- The two new test names match.
- "2 failed, 4 passed" holds, as measured above.
- F1, B3 and U1 match.
- "12 cases and 6 new rows (18 passed)" holds.
- Gate counts:
- The family, bump-leg and update-shape counts (31, 8 and 18) are right at
da3fe1c8(d98df568had 27 family files). - The 66-file list is 31+8+18+9, as stated.
- From the writer's logs: mode 1 is 3,026 passed and 32 skipped, and mode 2 is 3,007 passed with 37 logged skips plus the 14 from the re-run (
14 skipped, inm2-rerun-merged-away.out) = 51. - Every shard logs
HEAD=da3fe1c8,DIRTY=0andEXIT=0. - The skip breakdown matches the log's
SKIPPEDlines: 19, 4, 3, 3, 2 and 1.
- The family, bump-leg and update-shape counts (31, 8 and 18) are right at
- Main moved:
75d70502(repo_shape.py's YAML reader: Unicode spaces are stripped from text YAML keeps, the empty block key andk: {?: 1}still read, and three texts about the reader are one word out of date (what #635 left) #715) changes onlyProject.trackingin bump-leg andtracking_branch_ofin update-shape, as the body says. My merge reproduces treec34d4048and the two Auto-merging lines. - Other statements:
- "This is the only family.py hunk" holds (1 hunk against main).
- conftest gains 58 lines, 0 deleted, three helpers and two constants.
- update-shape has 4 hunks, unchanged by the merge.
- No document names the new codes (
git grepoutside tests and tools finds none).
One out-of-date sentence, optional. "Fix round 1", line 29, ends: "The remedy is shell_line([...], "posix") now; the file gives 9 passed."
- That was true at
d98df568. At the headtests/test_readme_windows_quoting_sentence.pygives40 passed, which is its count in my gate. - The "(9 tests)" in the revert-table line is already dated "before the merge", so it is fine.
- It is not a false claim about the PR, only a stale number in a history section. If you edit the body, replace
the file gives9 passed.withthe file gave9 passedthen (it holds 40 tests at the head, all passing).
Checks I could not make
- I did not run the full suite or any file outside the 44. Only
testsin CI can speak for the rest. - I measured nothing on Windows.
Files:
/workspace/projects/openRepoShape-worktrees/_lane1-scratch/r765m/logs/holds the gate logs (m1-s*.log,m2-s*.log)./workspace/projects/openRepoShape-worktrees/_lane1-scratch/r765m/gate.shandpt.share the scripts I used.- Both scratch worktrees are removed with
git worktree remove --force. Nothing was pushed, posted or committed to a shared branch. The shared checkout still shows only?? handoffs.
Next (lane 1's coordinator): the optional body fix is applied ("the file gave 9 passed then (it holds 40 tests at the head, all passing)."). #849 takes lane 1's next turn, after lane 4's #862, on condition that CI tests at da3fe1c8 finishes green. Its gate g849r is running on the predicted squash over main plus #771 and #862.
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 3 days and 13 hours by commenting |
…w's path under `members/`, a link or checkout in a move's way, an unmerged entry and a staged or unmerged `.gitmodules` are named first, so each remedy types clean (#840) (#865) # Title family.py add: a section astray beside its gitlink, a variant of a row's path under `members/`, a link or checkout in a move's way, an unmerged entry and a staged or unmerged `.gitmodules` are named first, so each remedy types clean (#840) # Body Was stacked on #828, which landed on main as `9b0a7038` (its final head `3546118b`). GitHub retargeted this PR to main, and the branch is restacked onto it (`6ef6e4c1`). **Goal (#840):** where `add` refuses a member recorded in another case, no printed command leaves a section of `.gitmodules` naming another spelling, moves a live checkout out from under its `.git` file, or fails through a link. #840 lists six states, and a seventh came from lane 2's rv828 review (SHOULD 3). The coordinator added an eighth on the way: a link at a variant the index holds as the member's gitlink. Each state was first typed as printed at `ce0f2a5f`, on git 2.43, with the gate run in all three modes and the same `add` run after. The link states were also typed on git 2.47.1. Every change is in `scripts/family.py`, which is not a pinned copy, so no `upstream-changed` row follows. The validator is not touched. The commit after the restack, `231c404c`, adds one test for rv865b's NIT 1, `test_a_second_paths_checkout_is_asked_in_its_own_spelling`. The state is a gitlink at `Members/IRRS`, committed, with its skip-worktree bit set, and `Members` a link to `members`, which makes `Members/IRRS` resolve to the member's own checkout, as a disk that folds case would. The test asserts the whole text: - the link is moved out first; - then `git rm -r --cached --sparse -- Members/IRRS` and its commit; - and no line says to delete a checkout. It then types that text as printed, and the gate passes. Mutant C5, which asks for the checkout whatever the spelling on disk, is caught by it. The sixth and seventh commits, `adb20242` and `4e92867a`, answer rv865b's findings. The round is two commits because the first was a pause's work in progress, and nothing is amended. - **MUST 1.** The second path's `git rm -r --cached` now takes `-f` where the move back's would. That is where the index stages the gitlink at a commit HEAD does not record there and a checkout is on disk at it (`_forced`, #828's rule). It prints the move back's own words on what `-f` does (`FORCED_SAID`, now one constant for both), and `--sparse -f` where both apply. At `38e0d503`, plain `git rm --cached` was refused there (`staged content different from both the file and the HEAD`, exit 1), for (a) staged with an untracked file or a commit of its own, and for K2 reset with work in the checkout. - **SHOULD 1.** The checkout a second path leaves on disk is named to delete by hand in the same round, with its `git -C <path> status`, before the update. - At `38e0d503`, K2 committed's update pointed the store that checkout shares with the member at `members/IRRS`, so the status printed nothing while `WIP.txt` was there. The next run then said to delete the checkout. - The update is not left to the next run instead, because nothing would print it there once the checkout is gone, and the member would be checked out nowhere. - (a), (b), K2 committed, reset and foreign, and the sparse state now each take one round. - **NIT 1.** The count for `_members_spellings` is corrected and recounted (line 4). - **NIT 2.** `typed_as_printed`'s `def` line is `f7072d08`'s again. - **N2.** rv865b ruled it acceptable as is, so nothing changed for it. The fifth commit, `38e0d503`, answers rv865b's findings: - **MUST 1.** A gitlink at a case variant of a row's path, beside the member's own gitlink at that path, is now taken out of the index with `git rm -r --cached -- <variant>`, the gate's own line for a second path. It is no longer renamed with `git mv`. At `f7072d08`, `git mv` carried the section that gives its path along under the same name, and the take-out then stripped it, so `renamed-1` was a gitlink no section names. The gate passed, but a plain clone's `git submodule update --init --recursive` exited 128, and that is the holder's own CI path (`validate.yml`). The checkout left on disk is the next run's `CHECKOUT`, deleted by hand. See line 1. - **K2.** The clause "never point back a section whose path is a gitlink in the index" is dropped, together with the change above, as rv865b rules. Its four states are tests now. - **NIT 1.** `_variant_in_members` asks `os.path.islink` before `samefile`, so a link `members/irrs -> IRRS` is named again on Linux. See line 3. - **NIT 2.** rv865b's surviving mutants X1, X2 and X6 are pinned. - **NIT 3.** A take-out printed after the point-back's own commit is committed too: that commit takes a staged `.gitmodules` into HEAD. - **NIT 4.** The closing guard takes #839's form, `[ ! -L V ] && { [ ! -e V ] || [ V -ef R ] || [ -e V/.git ]; } || mv -- V ../<V>`, with its words. That is the form lane 5's #839 prints in `validate-family.py` (`77f8a6c8`), so the two texts agree. A link at the variant is now moved in the first round. See the added state. - **NIT 5.** This body: line 1's claim now carries its caveat, and the merge checks name main `3bf2d3c9`. The third and fourth commits, `dde7e9cc` and `f7072d08`, answer rv865's findings: - **MUST 1.** A section beside the member's own section is no longer pointed back. It is taken out with the gate's own `--unset-all` lines. Only the section named after the row's path is pointed back, and only where no section gives that path. See line 1. - **SHOULD 1.** On a disk that folds case, the member's own checkout listed in another spelling is no longer refused. See line 3. - **NIT 1.** R7, R8, R9, R10, R13, R23, R25, R25b, R26 and R42 are pinned by tests now. R20 and R21 are not, and "Not here" says why. - **NIT 2.** This body's mutant count and merge checks are corrected. - **NIT 3.** `_checked_gitmodules_as_staged` has base's `def` line again, and its complexity went down. See line 4. A point-back printed after a commit is now committed too. That earlier commit (a pending change, or the index's rename or removal) takes a staged `.gitmodules` into HEAD. At `4268f804`, a staged section edit beside a committed `Members/README` left the point-back staged, and the gate found `member-gitmodules-absent` in every mode. **Measured at `ce0f2a5f`.** The gitlink stays at `members/IRRS`, and the section is hand-edited to `Members/IRRS`. - Committed or staged, `add` exits 0 and commits that `.gitmodules` beside the new member. - The gate exits 1 in every mode: `gitmodules-path-unnamed`, plus `member-gitmodules-absent` once committed. - With the edit not staged, `gitmodules-unstaged` comes first; typed, it leads to the staged state, and `add` goes through. - A section giving `members/irrs` behaves the same way. **What changed.** `_sections_astray` finds each section that gives a case variant of a row's path, where the index holds the member's gitlink at that path. `_row_sections_astray` then tells two cases apart, as the gate does: - **No section gives the row's path.** This is the gate's `member-gitmodules-absent` case. The section named after the row's path, the name `add` gives a member's section, is pointed back with the gate's own line: `git config -f .gitmodules submodule.members/IRRS.path members/IRRS`, then `git add -- .gitmodules`. Its place is "in .gitmodules, its gitlink at its row's path". A gitlink at its path is taken out of the index first (below), which leaves the section where the point-back finds it, in the same round. - **A section gives the row's path.** Each other one is a second section beside it, which the gate's `gitmodules-path-unnamed` calls `STRAY`. It is taken out, every key it holds, with the gate's own lines: `git config -f .gitmodules --unset-all submodule.Members/IRRS.path` and the same for `url`, each key quoted for a POSIX shell, then `git add -- .gitmodules`. Its place is "in .gitmodules, beside the section giving its row's path". - **A gitlink at such a path, beside the member's own gitlink,** is a second path to the member's directory (fix round 2). It is taken out of the index alone, `git rm -r --cached -- Members/IRRS`, as the gate's own line does (`UNSTAGE_SECOND`). It takes `--sparse` where the sparse rules leave it out or its bit is set, and `-f` where the index stages it at a commit HEAD does not record there beside a checkout (fix round 3). That comes before the take-out, and `git rm --cached` leaves the section for it. A checkout there that `git submodule add` or `git mv` made, on disk in its own spelling, is named to delete by hand in the same round, with its `git -C <path> status`, before the update (fix round 3). A clone of its own is not named: the next run moves it out whole. - Once the member's own section is pointed back, any other variant section is beside it, and is taken out the same way. - A variant section that is neither is left to the gate, which names it and prints no command for it. The code is `family-members-dir-variant`, as for any other path under another spelling of `members/`. Each part commits where HEAD's copy holds what it changes, or where a commit printed before it took a staged `.gitmodules` into HEAD: a pending change, the index's rename or removal, or, for a take-out, the point-back's own commit (fix round 2). After a staged edit alone there is nothing to commit, and `git commit` would fail. A change nobody has staged is staged first, as #685 does for the move back. A point-back closes with the update, `git submodule update --init --recursive`, where nothing is checked out at the row's path. That is the state of a fresh clone: no section gives `members/IRRS`, so nothing is checked out there. Beside a move back of another member, the one closing update names both rows. A take-out needs no update: the member's own section gave its path all along. **Why the take-out (rv865's MUST 1).** At `4268f804`, every variant section was pointed back, whatever submodule it belonged to. Measured again on that head with the text checks left out, on git 2.43: after `git submodule add <IRSS's remote> Members/IRRS` (kept, at `members/irrs`, or with its checkout deleted), or with the section `git rm --cached` leaves behind, the remedy typed as printed gave `members/IRRS` twice. The gate exited 1 in every mode, and a recursive clone of the pushed branch exited 128: `Fetched in submodule path 'members/IRRS', but it did not contain <commit>`. **Caveat on fix round 1's claim (rv865b's MUST 1).** The first fix round said that the same four states and a fifth type clean, that the recursive clone checks `members/IRRS` out, and that the gate passes. That held for a recursive clone only. In (a) and (b), the take-out ran after `git mv` of the same gitlink. It stripped the section `git mv` had carried along, and left `renamed-1` a gitlink no section names. A plain clone's `git submodule update --init --recursive` then exited 128 (`No url found for submodule path 'renamed-1' in .gitmodules`), and so did `git submodule status`, in the holder and in the clone. The gate cannot see this, since it asks only gitlinks under `members/`. **Since fix round 2,** that gitlink is taken out of the index and never renamed. Measured at `4e92867a` on git 2.43 and 2.47.1, the five states and two more type clean: - the two more are a staged section taken out after the point-back's commit, and a staged one whose name needs quoting; - each takes one round; in (a) and (b), the checkout `git rm --cached` leaves is deleted by hand where the text says (two rounds at `38e0d503`); - a recursive clone checks `members/IRRS` out from IRRS's remote at its commit; - a plain clone's `git submodule update --init --recursive` exits 0 with every member checked out; - `git submodule status` exits 0 in the holder and in both clones; - the gate passes in all three modes, and the same `add` goes through. rv865b's four K2 states (the gitlink at `Members/IRRS` beside the member's own, with the member's section astray: committed, reset, checkout deleted, and beside a foreign section) do the same, in one round each. **Work in the second path's checkout (fix round 3).** These states type clean in one round too: - (a) staged with an untracked file; - (a) staged with a commit of its own; - (a) staged and left out by the sparse rules, which gives `--sparse -f`; - K2 reset with work; - K2 committed with work. Where the text says to delete the checkout, its `git -C Members/IRRS status` still names the untracked file, and its commit is kept in its store. **Measured at `ce0f2a5f`.** `Members/X/notes` is committed, and a checkout that `git submodule add` made at `Members/X/Y` is taken out of the index. #574's `git mv -- Members/X <new name>` typed as printed moved the checkout too. `git -C renamed-1/Y status` then said `fatal: not a git repository: .../renamed-1/Y/../../../.git/modules/Members/X/Y`. A checkout the index still records under that directory is moved by `git mv` with its `.git` file mended: `git -C renamed-1/Y status` exits 0. **What changed.** Each checkout git does not track, inside a directory that a `git mv` (`RENAME`) or a plain `mv` (`MOVE`) moves, is named first (`CHECKOUT_FIRST`, `_checkouts_in_the_way`). It is to be deleted by hand once nothing only it holds is needed, as #828's `CHECKOUT` says of one found on disk. Its commits stay in the holder's store. `git -C <checkout> status` shows what is not committed. - The walk does not follow links, and does not enter a directory that holds a `.git` entry, so a checkout's own legs are not named again. - A gitlink the index holds is not named, and neither is a clone of its own, whose `.git` is a directory: the move takes it whole. - The moved directory itself is not entered when it holds a `.git` entry. - The plain `mv` case is the same fault, so it is covered too. The fix round pins the last three (rv865's R7, R8, R9, R10) with a clone of its own at `Members/X`, legs checked out, and with a plain `Members/X` holding such a clone and a link to a directory outside the holder. In both, only `mv -- Members/X <new name>` is printed. #828's `test_a_checkout_under_an_index_entry_is_the_indexs_to_name` did not pin R7: that directory has no subdirectories. **Measured at `ce0f2a5f`,** beside the row `members/IRRS`. In each of these states `add` exits 0, so a person meets nothing: - a file `members/irrs` in the index; - the same file on disk; - that file in HEAD alone, its removal staged; - an untracked directory there; - a second gitlink there; - the member's own gitlink moved there, #646's state under `members/`. The gate passes the first four. It refuses the second gitlink in every mode (`member-gitlink-unnamed`). For the member's own gitlink it exits 2 in members mode, and `member-gitlink-unnamed` under `--no-members`. **What changed.** `_spelled_target` takes the rows' paths. Under `members/` itself, a case variant of a row's path that is no row's own path is now a target, as a variant of the mount path already was. - It flows through the index, HEAD and disk checks, so each state gets #574's remedy. - The member's own gitlink gets #646's move back. - A section whose gitlink's removal is staged gets #828's `IN_GITMODULES` put-back. - A second gitlink that `git submodule add` made there, with its own section, is taken out of the index, its section is taken out, and its checkout is named to delete by hand in the same round (line 1, fix rounds 2 and 3). A second gitlink with nothing on disk is taken out of the index as before. The code is `family-members-dir-variant`. Its first sentence says what was found (`_lead`): "a case variant of the path of a `members:` row under members/, the same path as that row's on a case-insensitive disk". It is joined to the other-spelling sentence where both are found. A folding disk, asked in process with `disk_as`, gives the same text. **On a disk that folds case (rv865's SHOULD 1),** `_variant_in_members` lets through an entry of `members/` that the disk takes for the row's own path (`os.path.samefile`, as `_members_dir_on_disk` asks of `members/`). macOS and Windows list a directory in the spelling it was made in. So after `git mv members/IRRS members/irrs` and line 3's move back, whose closing guard `[ members/irrs -ef members/IRRS ]` is true there, the member's only checkout is listed as `members/irrs`. At `4268f804` the next `add` refused it as on disk and untracked, every time. Now it goes through. On Linux the two are two paths, `members/IRRS` holds nothing, and `members/irrs` is still named. **A link is never the row's own (rv865b's NIT 1).** `samefile` follows a link, so at `f7072d08` a link `members/irrs -> IRRS` was let through on Linux and `add` went through beside it; `4268f804` named it. `_variant_in_members` now asks `os.path.islink` first, as `_members_dir_on_disk` never takes a link for `members/`. The link is named on disk, `on disk and untracked: members/irrs (a symlink)`, and moved out of the holder, the link alone. **Measured at `ce0f2a5f`.** `git mv members Members` is committed, then `.gitmodules` is left unmerged (stages 1, 2 and 3) and deleted from the work tree. `_checked_gitmodules_as_staged` leaves a missing copy to git for `add`, so the rename back was printed. Its commit failed: `Committing is not possible because you have unmerged files`. In #646's state the commit fails the same way, and the update says `No url found`. **What changed.** `_gitmodules_first` asks first. For this state it raises `gitmodules-missing` (`_gitmodules_missing`) with the restore git takes there, as `remove` prints it: `git checkout --merge -- .gitmodules`, resolve it, `git add .gitmodules`. The detail says why for `add`. `remove`'s text is unchanged, byte for byte. An unmerged copy is now asked first wherever the spelling refusal is raised, not only beside a gitlink, since any printed commit fails beside it. **NIT 3.** `_checked_gitmodules_as_staged` has the `def` line base had, `def _checked_gitmodules_as_staged(root: Path, verb: str) -> None:`, and no `spelled` parameter. The missing-file refusal moved into `_gitmodules_missing`, which it raises for `remove` and `_gitmodules_first` raises for `add`. By hand, its cognitive complexity went from 35 at `4268f804` (34 at base) to about 18, so Sonar keeps that issue as the old one, lower. The new functions are under 15 by hand: `_gitmodules_missing` about 12, `_row_sections_astray` about 8 (9 before fix round 2 dropped K2's clause), `_variant_in_members` about 9 (8 before NIT 1's `islink`). Fix rounds 2 and 3, measured with rv865b's estimator (`cogc.py`, comprehensions not counted): - **New functions:** `_second_gitlink` 2, `_second_path_part` 5, `_second_checkout_part` 2, `_forced` 1, `_removed` 2. - **`_point_back_part`:** up 1, to 3. - **`_spellings_refusal`:** down 2, from 38 at `f7072d08` to 36, since the removal's condition moved into `_removed`. - **`_move_back_line`:** unchanged at 9. - **`_members_spellings`:** from 41 at `f7072d08` to 45 at `38e0d503`, then 46 now. Round 2 added 4: its `if`, nesting, its `and`, and `out = … or …`. Round 3 adds 1, the `and` in `checkout = present and …`. The fix round 2 body said 3. Its `def` line is unchanged, and it was over 15 before this PR. - **`typed_as_printed`:** 31, as at `f7072d08`, and its `def` line is that commit's again. **Measured at `ce0f2a5f`.** `git rm .gitmodules`, with HEAD holding the file. The printed rename back, or move back, and its commit took `.gitmodules` out of HEAD with every member's section. The gate then found `member-gitmodules-absent` for each member. With `git rm --cached .gitmodules`, a copy stays on disk, untracked: - beside the rename back, `git mv` staged that copy again, and the result passed; - beside the move back, the commit took the deletion as well, and left the copy untracked. **What changed.** `_gitmodules_beside` tells the two apart: `GITMODULES_DELETED` where the work tree has no copy, and `GITMODULES_UNTRACKED` where it has one. - Deleted: the refusal's first part puts HEAD's copy back, `git checkout HEAD -- .gitmodules`, as `remove` prints it and as #767 does for a copy deleted from disk. - Untracked copy: `gitmodules-unstaged` comes first ("its deletion is staged"), since which copy to keep is the person's. Both apply wherever anything is found, so a lone `Members/README` rename is covered too: its commit took the deletion as well. **Measured at `ce0f2a5f`, which does not answer it.** #828's set-aside part does not reach this state. `git rm -r --cached --ignore-unmatch -- Members/IRRS Members/IRSS` exits 128: `fatal: not a git repository: Members/IRRS/../../.git/modules/members/IRRS`. The rest of the line then ran, but the gitlinks stayed under `Members/`, and the next `add` refused again. Through the same link, plain `git mv -- Members/README x` moves the file in from where the link points. **What changed.** A symbolic link that the index holds entries under is named on disk and moved out of the way first (`LINK_OUT`, `_disk_found`). The words are those `member-path-unaddable` has for `members` itself: "must be a plain directory of the holder's own". `_on_disk_as_named` now takes nothing beyond a link as on disk. So a file under it is taken out of the index (`git rm -r --cached`), not given a `git mv` that would fail once the link is gone (`bad source`). Typed as printed: - the link moves; - the move back runs with both sections; - the guarded `mv` moves nothing; - the update checks both members out at their rows' paths. The directory the link named is left where it is. A checkout there was unusable already, since its `.git` file is relative. **Measured at `ce0f2a5f`.** After `git mv members/IRRS Members/IRRS`, an unmerged file at or under `members/IRRS` left the row neither blocked nor a stray. So the gitlink kept #574's `git mv -- Members/IRRS <new name>`, which took it away from its row, and the commit failed. An unmerged file at `members` itself, off disk: each `git update-index` ran, and the commit failed. On disk, `member-path-unaddable` printed `git mv -- members <new name>`, which exits 128 (`fatal: conflicted`). **What changed.** - `_rows_held` counts a file or link at any stage as in the way. - `_set_aside` takes an unmerged entry out of the index, every stage, with `git rm -r --cached`, which git allows there (`ASIDE_UNMERGED`). Where the entry is on disk as the index holds it, it is then moved out of the way with `mv` (`ASIDE_UNMERGED_MV`). - Measured why the `mv` is needed: a file left at the row's path made the update fail (`could not create directories for members/IRRS/.git`). One left under it stayed in the member's checkout, which `git status` then lists as modified. - The removal is a change to commit only where HEAD holds the entry. With the move staged, no commit is printed. - An entry held at stages 1 and 3 alone, which this side deleted, is unmerged too. - `_members_is_a_file` gives an unmerged `members` the same two steps, plus the commit on its own where HEAD holds the file. **Already answered at `ce0f2a5f`.** Measured on git 2.43 and 2.47.1, with #646's state and a link `Members/IRRS` on disk, pointing to the member's checkout or to nowhere. git 2.47.1 refuses `git status` itself: `error: expected submodule path 'Members/IRRS' not to be a symbolic link`. `add` reads no `git status` on this path, and gives its spelling refusal first: no git `fatal:` and no traceback, for `add --dry-run` and the real run alike. At `f7072d08`, typed as printed on 2.47.1: - the move back ran; - the closing guard kept a link to the row's own directory (`[ Members/IRRS -ef members/IRRS ]` follows it); - the next `add` named it on disk, with its `mv`; - typed, the gate passed and `add` went through: three rounds. **Since fix round 2 (rv865b's NIT 4),** the closing guard takes #839's form, which asks `[ ! -L Members/IRRS ]` first, so a link there is always moved, the link and never what it points at. The guard now moves the link in the first round, and the next `add` goes through: two rounds, typed under dash and under bash, on git 2.43 and 2.47.1. A link to nowhere was moved in the first round before, and still is. `test_a_link_at_a_variant_the_index_holds_is_refused_first` pins this. Its refusal check is `refused_untouched(..., status=False)`, so `snapshot` skips `git status`, which git 2.45.1 and later refuse here, and keeps every other key. The guard holds `; ` inside its braces, so the tests' `typed_as_printed` now splits the remedy into parts at `; ` outside backticks only (`remedy_parts`). **New file `tests/test_family_add_spelling_leftovers.py`, 70 cases.** Each typing test types every printed command as printed, with `typed_as_printed`, in the state it describes. Each one then checks that every member is where its row says, that the gate passes in all three modes, and that the same `add` goes through. | Line | Cases | |---|---| | 1 | committed, staged, not staged, under `members/` itself, a fresh clone, beside a move back; a staged edit after a pending change, a rename and a removal; rv865's four states, the point-back beside a take-out, a staged take-out after the point-back's commit, and a staged one whose name needs quoting; rv865b's four K2 states; the second gitlink left out by the sparse rules or marked by hand; five states with work in the second path's checkout; a staged second gitlink with nothing on disk, which takes no `-f`; a clone of its own at a second path, moved whole on the next run; a second gitlink marked at `Members/IRRS` with `Members` a link to `members`, which names no checkout (fix round 4); the section named after the row beside one giving its path; a variant section left to the gate; an unreadable `.gitmodules` | | 2 | under a `git mv`, under an `mv`, a tracked gitlink's control, and a clone of its own moved whole, alone and beside a link | | 3 | six states, a section whose gitlink's removal is staged, both disks in process for two of them, the member's checkout a folding disk lists in another case, on both disks, and a link `members/irrs -> IRRS` | | 4 | one case | | 5 | beside the rename back, the move back and a file, and the untracked copy | | 6 | the gitlinks alone, and a file under the link | | added state | two cases, typed under dash and under bash | | 7 | five unmerged states, two with the move staged, one at stages 1 and 3 alone; `members` off disk, and `members` on disk with and without HEAD's file | Every case of line 1 that a section beside the member's own or a second path takes part in (rv865's four, the two staged take-outs, the K2 states, the sparse ones and the work states) ends the same way (`typed_through`): - HEAD is pushed to a branch of its own; - a recursive clone checks `members/IRRS` out from IRRS's remote at its commit; - a plain clone, as the holder's CI makes it, runs `git submodule update --init --recursive`, which must exit 0 with every member checked out; - `git submodule status` must exit 0 in the holder and in both clones. Where the text says to delete a checkout by hand, it is deleted there, after the parts before it and before the update. In the work states, the hand step first asserts that `git -C <checkout> status` still names its untracked file. **Changed:** `tests/test_family_add_move_back_sections.py`. Its `set_aside` builds the unmerged part (`unmerged=`, `moved=`). In fix round 2, one docstring names the guard's new form. **Changed, in the second commit:** `tests/test_family_members_case_variants.py`. `refused_untouched` takes a keyword `status=True` and passes it to both of its snapshots. The link test calls it with `status=False`, and the narrower helper it had (`refused_beside_a_link`) is gone. In the third commit, its one literal `Spelled` tuple gains the new `keys` field, and in fix round 3 the new `checkout` field. **Changed, in fix round 2:** `tests/test_family_add_member_variant.py`. - `guarded` and the closing words take #839's form. - `typed_in_sh` types into the module's `SHELL`, `sh` unless a test sets `dash` or `bash` with `monkeypatch` (fix round 3). Its `def` line and `typed_as_printed`'s are `f7072d08`'s, so the remedy's split is their only change (rv865b's NIT 2). - The remedy is split into parts at `; ` outside backticks (`remedy_parts`). No test needs a git newer than 2.34, but for the sparse case. The second gitlink left out by cone-mode rules needs git 2.38 or later (`sparse_cone`, as #828's tests). So does the work state left out by those rules. The sparse cases skip where git has no `git sparse-checkout check-rules`, as does the staged second gitlink marked by hand. Symlink cases skip on Windows, the added state skips where its shell is missing, and states that need two names differing only in case skip on a disk that folds case, as in #828's files. **Fails on the base.** The first 35 cases, run against `ce0f2a5f`'s `family.py`: **33 failed and 2 passed** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/base.log`). The two passes pin what the base already does: the added state and line 2's control. **Fails on `4268f804`.** The fix round's 16 new cases, run against `4268f804`'s `family.py`: **9 failed and 7 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/oldfinal243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/oldfinal247.log`). - The 9 that fail are the fixes: rv865's four states and the point-back beside a take-out, the section named after the row, the variant section left to the gate, the staged edit after a rename, and the folding disk's checkout. - The 7 that pass pin code that was already right, as rv865 found it: the staged edit after a pending change (R26), the unreadable `.gitmodules` (R13), the two clones (R7 to R10), the two new unmerged states (R23, R42), and the Linux side of the folding-disk test. **Fails on `f7072d08`.** Fix round 2's 17 new or changed cases, run against `f7072d08`'s `family.py`: **16 failed and 1 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/old243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/old247.log`). The one that passes is the staged edit after a removal (X2), which pins code that was already right. A copy with no check on the text was also run on both heads, git 2.43: each state is typed as printed, round after round, then put through the same checks (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/test_w865f_r2_behaviour.py`). - At `f7072d08`: 11 of 16 failed (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/bh-old243.log`). - The plain clone's update failed for (a), (b) and both sparse states. - HEAD's `.gitmodules` was not the holder's for the K2 states committed, reset and foreign, and for the staged take-out after the point-back's commit, which left `stale` in HEAD. - The link `members/irrs` was let through. - The link at the variant took 2 rounds of typing before `add` went through, in dash and in bash. - At `38e0d503`: 16 passed (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/bh-head243.log`). - The 5 that pass at `f7072d08` are (c), (d), the point-back beside a committed take-out, the staged name that needs quoting (X1, X6: `f7072d08` already quoted the key and printed no commit), and K2 with its checkout deleted, which took two rounds there and ended right. **Fails on `38e0d503`.** Fix round 3's new and changed cases, run against `38e0d503`'s `family.py`: - **Of the 20 cases with the round's changes, 12 failed and 8 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old247.log`). - The 12 that fail are (a), (b), K2 committed, reset and foreign, both sparse states, and the five work states. - The 8 that pass are the BESIDE states with no second path, K2 with its checkout deleted, and the link test under dash and bash, none of whose text or typing changed. - **The two cases added last both fail** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243b.log`). - **A copy of the five work states with no check on the text** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/test_w865f_r3_behaviour.py`) types round after round, and its hand step asserts that the status names the untracked file: - At `38e0d503`, all five fail (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243b.log`). Four stop at `git rm -r --cached` (`staged content different from both the file and the HEAD`): (a) staged with an untracked file, with a commit of its own, left out by the rules, and K2 reset with work. K2 committed with work fails the hand step: `WIP.txt on disk but not in its status`. - At `4e92867a`, all five pass (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/bh-head243.log`). Every run went through `run9.sh` or `run9s.sh`, with the real `gh` unreachable and basetemps under `v110/bt/`. | Log | Git | Head | Files | Result | |---|---|---|---|---| | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/final243.log` | 2.43 | `4e92867a` | the same 15 files, 3 shards | 721 passed, rc 0 | | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/final247.log` | 2.47.1 | `4e92867a` | the same 15 files, 3 shards | 721 passed, rc 0 | | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/final243.log` | 2.43 | `38e0d503` | the same 15 files, 3 shards | 714 passed, rc 0 | | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/final247.log` | 2.47.1 | `38e0d503` | the same 15 files, 3 shards | 714 passed, rc 0 | | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/probe-head-243.log` | 2.43 | `38e0d503` | rv865b's probe, which records and never asserts (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/probe-head-243.jsonl`) | 29 passed, rc 0 | | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/final243.log` | 2.43 | `f7072d08` | 15 files, 3 shards (below) | 703 passed, rc 0 | | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/final247.log` | 2.47.1 | `f7072d08` | the same 15 files, 3 shards | 703 passed, rc 0 | | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/full243.log` | 2.43 | `dde7e9cc` | 15 files, 3 shards: the 3 touched test files, the other 5 `test_family_add_*` files, the trio, `test_family.py`, `test_family_remove_gitmodules.py`, `test_family_head_lost_gitmodules.py`, `test_family_holder_states.py` | 702 passed, rc 0 | | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/full247.log` | 2.47.1 | `dde7e9cc` | the same 15 files, 3 shards | 702 passed, rc 0 | | `va.log` | 2.43 | `2c97e759` | the new file, the issue's Verify files, `test_family_holder_states.py`, the trio | 471 passed, rc 0 | | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/vb1.log` | 2.43 | `2c97e759` | the first 33 of the 65 other files that run or import `family.py` | 2070 passed, 24 skipped, 1 xfailed, rc 0 | | `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/vb2.log` | 2.43 | `2c97e759` | the other 32 of those 65 files | 1739 passed, rc 0 | The 15 files are the 3 that fix round 1 touched, rv865's other five `test_family_add_*` files (`excludes`, `member_id`, `member_variant`, `refusals`, `spellings_remedies`; fix round 2 touches `member_variant` too), the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`), `test_family.py`, and the three files that drive `.gitmodules` refusals for `add` and `remove`. CI's git 2.55 was not run here. macOS and Windows disks are emulated in process (`disk_as`) for lines 1 and 3. **rv865b's probe at `38e0d503`, git 2.43.** Its typing stops after three rounds and never deletes a checkout by hand, so a state that leaves one shows the checkout named in rounds 2 and 3. Each state ends with the gate passing in all three modes. - **P1 (a) and (b) as printed, and P2:** round 1 takes the gitlink out of the index. HEAD's gitlinks are then the two members alone, and `git submodule status` exits 0 in the holder and in the recursive clone. Rounds 2 and 3 name the checkout left at the variant. - **P3, K2:** committed, reset and foreign end the same way. "Deleted" ends in one round, and `add` goes through. - **P4 and P4b:** the take-out commits after the point-back's commit. HEAD no longer holds `stale` (`head_has_stale: false`). - **P5:** `members/irrs -> IRRS` is refused (code 2), and so is `members/irrs -> IRSS`. - **P6:** all four link layouts take two rounds, and the link is gone. - **L7:** all six states are clean. - **P7 (a), (b) and (c) as printed:** a plain clone's update exits 0, with every member checked out. - **P7's control,** the rename typed by hand followed by the gate's own `--unset-all` lines, still gives a plain update that exits 128 while the gate passes. That is the validator's gap, which rv865b named; this PR does not print that rename. **First round: 30 mutants,** M1 to M27 plus M11b, M13b and M15b, at least one per new hunk, each run `-x` on the test that names its state. All 30 are caught. (The earlier text said 31.) | Mutant | What it undoes | Caught by | |---|---|---| | M1 | `_spelled_target`: no row variant under `members/` | line 3 (`add` exits 0) | | M2 | `_on_disk_as_named`: on disk beyond a link | line 6, a file under the link | | M3 | `_disk_spellings`: no row variant on disk | line 3, a directory on disk | | M4 | `_sections_restored`: no row variant for a staged removal | line 3, the section state | | M5 | `_rows_held`: an unmerged file not in the way | line 7 | | M6 | `_in_the_way`: nothing unmerged | line 7 | | M7 | `_aside_remedy`: an unmerged file on disk not moved | line 7 | | M8 | no section astray | line 1 (`add` exits 0) | | M9 | `_disk_found`: no `LINK_OUT` | line 6 | | M10 | no checkout inside a moved directory | line 2 | | M11 | `_checkouts_under`: a tracked gitlink named | line 2's control | | M11b | `_checkouts_under`: a checkout entered (its legs named) | line 2 | | M12 | `_gitmodules_beside`: no staged deletion | line 5 | | M13 | `_gitmodules_first`: unmerged not asked first | line 4 | | M13b | `_gitmodules_first`: an untracked copy not asked first | line 5, the untracked copy | | M14 | `add` leaves a missing unmerged copy to git | line 4 | | M15 | `_members_is_a_file`: unmerged taken for merged | line 7, `members` on disk | | M15b | `_members_is_a_file`: HEAD's file not committed out | line 7, HEAD holds the file | | M16 | `_lead`: a row variant said as another spelling | line 3 | | M17 | `_point_back_part`: the commit always printed | line 1, staged | | M18 | no update for a section alone | line 1, a fresh clone | | M19 | the update names the moved rows alone | line 1 beside a move back | | M20 | `_gitmodules_part`: no put-back from HEAD | line 5 | | M21 | `_set_aside_part`: unmerged not said | line 7 | | M22 | `_link_part`: never said | line 6 | | M23 | `_checkout_first_part`: never said | line 2 | | M24 | an unmerged removal always committed | line 7, the move staged | | M25 | HEAD's row variants not asked | line 3, in HEAD alone | | M26 | the index's row variants not asked | line 3, a file in the index | | M27 | `refused_untouched` ignores `status` | the link test, on git 2.47.1 only | **Fix round:** 19 mutants of this round's new hunks in `scripts/family.py` (K1 to K18 and K3a), and the 8 of rv865's survivors pinned here run again, 27 in all, on `dde7e9cc`'s `family.py`, which `f7072d08` keeps, each `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut/summary.txt`). **All caught but K2.** | Mutant | What it undoes | Caught by | |---|---|---| | K1 | point back even where a section gives the row's path | the section named after the row | | K2 | point back a section whose path is a gitlink in the index | survived; fix round 2 drops the clause, and N14, which puts it back, is caught | | K3 | any variant section is the member's own, not only the one named after the row | the point-back beside a take-out | | K3a | the take-out set off by any variant section | the variant section left to the gate (it survived until `f7072d08` added that test) | | K4 | nothing taken out once the member's own is pointed back | the point-back beside a take-out | | K5 | nothing taken out beside a section giving the row's path | (a) | | K6 | a take-out never a change to commit by itself | (d) | | K7 | a point-back never a change to commit by itself | line 1, committed | | K8 | `_committed` ignores the earlier commit | the staged edit after a pending change | | K9 | only the `path` key taken out | (a) | | K10 | the earlier commit is the pending change alone | the staged edit after a rename | | K11 | the earlier commit leaves out the pending change (rv865's R26) | the staged edit after a pending change | | K12 | `_variant_in_members`: no `samefile` | the folding disk's checkout | | K13 | `_variant_in_members`: a row variant on disk never named | the same test on Linux | | K14 | `_gitmodules_first`: no `gitmodules-missing` for an unmerged copy off disk | line 4 | | K15 | `_gitmodules_missing`: a staged copy taken for unmerged | `test_family_remove_gitmodules.py` | | K16 | `_checked_gitmodules_as_staged`: `add` no longer leaves a missing file to git | `test_family_dry_run.py` | | K17 | `_sections_astray`: an unreadable `.gitmodules` raises there (rv865's R13) | the unreadable `.gitmodules` | | K18 | the place "beside the section giving its row's path" not listed | (a) | | R7 | `_checkouts_under`: a moved directory that is a repository entered | the clone moved whole | | R8 | a clone (`.git` a directory) named too | the clone beside a link | | R9 | the walk follows links | the clone beside a link | | R10 | a directory holding a `.git` directory entered | the clone beside a link | | R23 | an unmerged entry moved with `mv` always a change to commit | line 7, on disk with the move staged | | R25 | `_lead`: the row sentence alone | the point-back beside a take-out | | R25b | `_lead`: the joined sentence without the row clause | the point-back beside a take-out | | R42 | unmerged only where stage 2 is held | line 7, stages 1 and 3 alone | **Fix round 2: 19 mutants** of the round's new hunks in `scripts/family.py` (N1 to N18 and N7b), on `38e0d503`, each run `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate2.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut2/summary2.txt`). **18 are caught and N2 survives.** N2 was run over all 200 cases of the leftovers, `member_variant`, `move_back_sections` and `case_variants` files, which all passed: see "Not here". | Mutant | What it undoes | Caught by | |---|---|---| | N1 | `_second_gitlink`: any entry, not only a gitlink | line 3, a file on disk | | N2 | `_second_gitlink`: the row's path need not hold a gitlink | **survives** (200 passed): see "Not here" | | N3 | `_second_gitlink`: any gitlink while a row's gitlink is held, whatever its path | `test_a_gitlink_the_sparse_rules_leave_out_is_renamed_with_sparse` | | N4 | the second path renamed with `git mv` again | (a) | | N5 | a second path with nothing on disk rerouted too | (c) | | N6 | the second path's removal never takes `--sparse` | the second gitlink left out by the rules | | N7 | `--sparse` only where the rules leave it out | the second gitlink marked by hand | | N7b | `--sparse` only where the bit is set | the second gitlink left out by the rules | | N8 | the second path's part never printed | (a) | | N9 | `_second_path_part`: `--sparse` never printed | the sparse cases | | N10 | `_second_path_part`: the git `--sparse` needs never said | the sparse cases | | N11 | `_removed`: the second path's removal never a change to commit | (a) | | N12 | `_removed`: a pending change committed first no longer makes a removal one | `test_every_place_is_named_and_the_pending_change_is_committed_first` | | N13 | `_removed`: a removal said wherever anything is committed | line 3, in HEAD alone | | N14 | K2's gitlink clause put back | K2 committed | | N15 | the point-back's own commit not counted for the take-out (NIT 3) | the staged take-out after the point-back's commit | | N16 | `_variant_in_members`: no `islink` (NIT 1) | the link `members/irrs` | | N17 | `_closing_sentences`: #828's guard again | the added state | | N18 | #839's form without its `-L` test | the added state | **Fix round 3: 13 mutants** of the round's new hunks in `scripts/family.py`, on `4e92867a`, each run `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate3.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut3/summary3.txt`). **12 were caught, and C5 survived** until fix round 4's test `test_a_second_paths_checkout_is_asked_in_its_own_spelling` caught it (on `231c404c`). | Mutant | What it undoes | Caught by | |---|---|---| | F1 | the second path's `-f` never printed | (a) staged with an untracked file | | F2 | `_forced` always true | (a), which takes no `-f` | | F3 | the clause on `-f` never said | (a) staged with an untracked file | | F4 | `_forced` without the question whether a checkout is on disk | the staged second gitlink with nothing on disk | | F5 | `_forced` without the question whether HEAD records it at that commit | (a) | | F6 | a second path never forced | (a) staged with an untracked file | | F7 | the move back's clause dropped, now the shared constant | `test_family_add_move_back_sections.py` | | C1 | the checkout a second path leaves never named | (a) | | C2 | that checkout named after the update, not before | K2 committed with work | | C3 | every second path has a checkout | the second gitlink marked by hand | | C4 | a second path's checkout never noticed | (a) | | C5 | the checkout asked whatever the spelling on disk | the second gitlink marked with `Members` a link to `members` (fix round 4) | | C6 | any directory, a clone of its own too, is a checkout | the clone of its own at a second path | `git merge-tree --write-tree` of `4e92867a`, after a fetch of main at 08:38Z on 2026-10-18: - **Clean:** #828's final head `3546118b` (tree `923bb23b`), the restack's first merge. - **For information:** main `def45de6`, which holds #828's squash `9b0a7038`, conflicts in #828's own files: `scripts/family.py`, `tests/test_family_add_member_variant.py`, `tests/test_family_add_move_back_sections.py` and `tests/test_family_members_case_variants.py`. This PR still holds #828's commits from before the squash, and the restack resolves those files. Fix round 2's check of `38e0d503` was clean against main `3bf2d3c9` and `5c54af20`, and against #828's `90649e53` and `54b7699e`. Fix round 1's check of `f7072d08`, at about 02:55Z, was clean against main `4dea85ee`, `90649e53`, and the open PRs that change `scripts/family.py`: #814 `4f6e7f83`, #846 `2d0e884f`, #849 `d98df568`. Those three were not checked again in fix round 2. The restack onto #828's squash comes next. The first commit's wider check, against every open branch of 2026-10-16 and 2026-10-17 that touches these files, found no conflict of its own. No line of `README.md`, `docs/handbook.html`, `docs/cli.md`, `AGENTS.md` or `templates/family-root/*.md` states the changed text or any of these codes. `--help` is unchanged, and no finding code is new. The docstrings of the changed functions say what each now does. - **The validator's own text.** `_wrong_case_move` is lane 5's #839 (`fix/839-holder-bootstrap-texts`), whose guard this PR's text now matches. The validator is a pinned file and is not touched here. rv865b also found that typing the gate's own `--unset-all` lines after a hand rename of a second gitlink leaves the same orphan gitlink while the gate passes, since `_check_gitlinks_named` asks only gitlinks under `members/`. That gap is the validator's, and this PR no longer prints that rename. - **A harmless `-f` on a folding disk.** On a disk that folds case, a second gitlink marked off disk at `Members/IRRS` resolves to the member's own checkout. So `_forced` prints a harmless `-f` there, as #828's move back does. The checkout question is asked in the path's own spelling (`present`), so no line says to delete that checkout. Fix round 4's test pins this with `Members` a link to `members`, the folding emulation `tests/test_validate_family_remedies_typed.py` uses (rv865b's round-3 NIT 1, mutant C5). - **A second gitlink whose row's path holds index entries under it** (a gitlink below `members/IRRS`, say) rather than a gitlink at it. No rule here decides whose gitlink the variant is there, so it keeps #574's remedy. Mutant N2, which drops `_second_gitlink`'s question whether the row's path holds a gitlink, survives for that reason. It differs only there, or where the row's path is an unmerged entry whose first stage is no gitlink, which is R21's state. Each of those is a state no rule rules on, and pinning either remedy would decide it. - **A checkout that is itself the moved directory**, beside an index entry inside it, keeps the index's remedy, as #828's test pins. - **An unmerged entry outside the sparse definition.** `git rm --cached` of it may need `--sparse`. This was not measured, and no `--sparse` is printed for one. - **The "stage the change" part's reason** names `git mv` and `git rm` of a gitlink (#685's text). For line 1 alone, the reason is the `git add -- .gitmodules` that follows. The text is unchanged. - **R20 and R21 are not pinned.** R20 (`_members_is_a_file` takes HEAD's `members` of any kind for the file) differs only where HEAD holds `members` as a tree, a link or a gitlink while the index holds an unmerged file `members`: a directory/file or link/file conflict that a merge may not even leave, and whether a link in HEAD should ask for the commit too is a question of its own. R21 (`_rows_held` takes an entry with any non-gitlink stage as in the way) differs only for an entry at the row's path unmerged with a gitlink at one stage and a file at another, made only by `update-index`; whose gitlink that is, and so which remedy is right, is not ruled. - **The closing guard kept a link to the row's own directory.** Done in fix round 2, in #839's form (rv865b's NIT 4). - **`_sections_restored` (#828) points back every variant section of a member it moves back.** It has the fault MUST 1 found in `_sections_astray`, for a member whose gitlink is at a variant: a second variant section, another submodule's, would be pointed back too, and the path given twice. Read from the code, not measured. - **Found in the checklist pass (t114 row 5), and done in the second commit.** `refused_untouched` and `snapshot` in the tests ask `git status`, which git 2.45.1 and later refuse while a gitlink the index holds is a symbolic link on disk. `snapshot` already took `status=False`. `refused_untouched` now takes it too, and the link test passes it, so the full snapshot is kept except `git status`. Mutant M27 fails the link test on git 2.47.1. Review: rv865, lane 5's independent review, and rv865b's re-checks of each fix round: round 3 and its restack onto #828's squash READY at `6ef6e4c1` (issuecomment-6248254729), and round 4 (`231c404c`, the C5 pin rv865b asked for, tests only) READY with no findings (issuecomment-6248465765). CI (Brett Heap's ruling 27): H0 38e0d503 (fix round 2, the last head before the restack that CI ran in full: GitHub started no tests run on round 3's 4e92867a) passed tests, tests-git-bash and SonarCloud; the restack 6ef6e4c1 merges #828's final head, then #828's squash 9b0a703 (the branch's own side for #828's files), then main; the head 231c404c, 1 commit on the restack, has tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 629aec2b over main c9ca9a45: the family_add_member_variant, family_add_move_back_sections, family_add_spelling_leftovers, family_members_case_variants, repo_hygiene, agents_md_codes, cli_reference sets, 356 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green". Closes #840 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-1 (openRepoShape-1)
Claim: #765 (comment)
A v1.1.1 draft. It lands only after the v1.1.0 tag, in the order lane 3 posts.
What
The three tools that read the branch HEAD is on now read it as the bytes git wrote, in one helper each:
update-shape.py_symbolic_headcurrent_branch(commit_on_branch,_awaits_its_pull_request,cmd_apply), andRollback.new_branch,_undo_branchand_head_isscripts/family.py_holder_branch_checked_branch(the real run ofadd,bump,remove) and_branch_said(their dry runs)scripts/bump-leg.pycurrent_branch_branch_to_bump_from(both runs)Each reads
git symbolic-ref -q HEADas bytes, takes off exactly one trailing newline and exactlyrefs/heads/, and decodes the rest withsurrogateescape. None of the three reads has astrip()or anerrors="replace"left.update-branch-not-utf8,family-branch-not-utf8andbump-leg-branch-not-utf8, one per tool, under the prefix each tool already uses.b'caf\xe9', astrailer-not-utf8names a trailer (b'Jos\xe9').git -C <root> branch -m <a new name>, quoted for a POSIX shell in all three tools.git branch -mrenames the branch HEAD is on, so the remedy never has to spell the old name: a pastedb'caf\xe9'would reach git as the textbcaf\xe9(measured withsh).update-shape.py'sRollback._undo_branchis the one place a branch returned by one of these three reads goes back to git, and it now goes through_as_git_argument, as bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 does forbootstrap.py. Two rollbacks outside this PR hand git a branch they read for themselves: a member's in family.py (_member_head, then_head_back) and a leg's in bump-leg.py (leg_placement, thenrestore_leg). Both are under "Not in this PR".Fix round 1 (after r765's review, #849 issuecomment-6242841859)
tests/test_readme_windows_quoting_sentence.pyfailed:_holder_branch's remedy calledshell_linefor the host, which README's sentence on the lines quoted for PowerShell does not name. The remedy isshell_line([...], "posix")now; the file gave9 passedthen (it holds 40 tests at the head, all passing).LC_ALL=C PYTHONUTF8=0, a rollback fromfeat<U+00A0>raisedUnicodeEncodeErrorpart way and left the copies written._as_git_argumentfixes it, and a rollback from<U+65E5><U+672C>, which raised on main too, now exits 2 with every byte git holds as it was.test_a_rollback_puts_head_back_on_the_branch_it_started_on, in both locales.held_by_gitalso holds every git directory under.git/modules(refs, object count, FETCH_HEAD with its write time), so the two cases' "before the fetch" is asserted. A bump-leg dry run on a bump branch changes thespecentry, and a familybumpon a branch changesmembers/IRRS, so the snapshot sees a fetch.Merge of main (after r765's PR-READY at
d98df568)The branch conflicted with main, so main
d6071c7dis merged into it. The new head is the merge commitda3fe1c8, whose parents ared98df568andd6071c7d. Nothing was squashed or rebased.The conflicts. All of them come from main's
5fc049b7(#766, #814), which names the root and the holder in bump-leg.py's and family.py's refusals asshown_pathnames a path:tests/test_bump_leg_branch_and_push_line.pyAND THE BRANCH'S BYTES (#765)paragraph against #766'sTHE ROOT ABOVE THEM (#766)bump-leg-branch-not-utf8, raised in the same check, names the root the same wayconftestimports: #849'sNOT_UTF_8_BRANCH_NAMED,held_by_gitandon_a_branch_that_is_not_utf_8against #766's second import linetests/test_family_tracking_branch.pyconftestimports, the same waySHOWN_NAMESandnamed_as, for the new row belowscripts/bump-leg.py,scripts/family.pyandtests/conftest.pymerged by themselves.update-shape.pynortests/test_update_shape_apply_lines_and_headers.py. Against main, the+/-lines of those two files and oftests/conftest.pyare the ones they had againstfc867207.One change in meaning. The two not-UTF-8 refusals now name the path as
shown_pathnames it.bump-leg-branch-not-utf8is raised fromcurrent_branch, which_branch_to_bump_fromasks.family-branch-not-utf8is raised from_holder_branch, which_checked_branchasks.init's plan rows and seven refusals name a holder, a folder, a lock, a remote path or a root as it is, so an ESC, a line break or U+202E in a path reaches the terminal as #692's refusals no longer let it #766, every other refusal of those two checks names the root or the holder asshown_pathnames a path, and their docstrings say so. update-shape.py, family.py and bump-leg.py: the branch HEAD is on is read as the bytes git wrote, so a branch ending in U+00A0 is no longer read as its neighbour, and a name that is not UTF-8 is refused by name before anything is written (#765) #849's two named it raw ({root}), so a root under a folder whose name holds an ESC or a line break sent it to the terminal from this one refusal.{shown_path(root)}now. A printable path is named byte for byte, so update-shape.py, family.py and bump-leg.py: the branch HEAD is on is read as the bytes git wrote, so a branch ending in U+00A0 is no longer read as its neighbour, and a name that is not UTF-8 is refused by name before anything is written (#765) #849's own cases, whose paths are printable, assert the same lines as before.branch -mremedy is a command to paste and is unchanged. It holds the path raw inside its shell quotes, as theswitch -cbeside it does (Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459, for family.py).update-shape.pynames no path throughshown_path, on main or here, soupdate-branch-not-utf8is unchanged.Two new rows hold it, each over
SHOWN_NAMES(an ESC, a line break then a forged line, and a printable name). Each asserts exit 2, an empty stdout and the refusal's whole detail line:tests/test_bump_leg_branch_and_push_line.py:test_a_root_on_a_branch_that_is_not_utf8_is_named_safe_to_show[ESC],[line-break]and[printable];tests/test_family_tracking_branch.py:test_a_holder_on_a_branch_that_is_not_utf8_is_named_safe_to_show[ESC],[line-break]and[printable], withremove.With
{root}put back in either tool, in a scratch clone ofda3fe1c8, that tool's[ESC]and[line-break]rows fail (2 failed, 4 passed).[printable]passes, as #766's printable rows pass by design.Revert rows re-run at
da3fe1c8, each alone in a scratch clone, against the 12 cases in the table below and the 6 new rows (18 passed with nothing reverted):.strip()back in family's_holder_branch: 1 failed, the family no-break space..strip()back in bump-leg'scurrent_branch: 2 failed, the bump-leg no-break space beside a branch and beside the tracking branch..strip()back in update-shape's_symbolic_head: 3 failed, the update-shape no-break space and the rollback in both locales.Each matches the revert table.
r765's nit is taken too: the 103-character docstring line in
tests/test_family_tracking_branch.pyis rewrapped.Gate at
da3fe1c8, dirty 0. Each run used/usr/bin/python3 -m pytest -p no:cacheprovider, a stubghfirst on PATH,GH_TOKEN=bogus-w765m, an emptyGH_CONFIG_DIR,GIT_CONFIG_GLOBAL=/dev/nullandGIT_SSH_COMMAND=false. Each mode ran in six shards, with each file in a pytest run of its own. The 66 files:tests/test_family*.py(31 files),tests/test_bump_leg*.py(8) andtests/test_update_shape*.py(18), which take in the PR's three test files;tests/test_agents_md_codes.py,test_readme_windows_quoting_sentence.py,test_git_output_decoding.py,test_non_utf8_git_reads.py,test_bootstrap_non_utf8_branch.pyandtest_no_auto_maintenance.py;tests/test_shape_doctor.py,test_scaffold_leg_path_collisions.pyandtest_cli_reference.py.Results:
test_family_init_values.pycases for a template that quotes the value;test_no_auto_maintenance.pycases, because the session began withGIT_CONFIG_GLOBALset, as the gate sets it;PATH=<stub gh>:/usr/local/bin:/usr/bin:/bin, wherecommand -v git-filter-repofinds nothing): 3,007 passed, 51 skipped, 3,058 collected. The 19 extra skips are the tests that need git-filter-repo: 14 intest_update_shape_merged_away.py, 4 intest_shape_doctor.pyand 1 intest_update_shape_apply_lines_and_headers.py.Every shard log names the head, dirty 0, and ends
EXIT=0, and every file's run exited 0. The log's filter missed one summary line,14 skippedfortest_update_shape_merged_away.pywith git-filter-repo hidden. That line comes from a re-run of the file alone at the same head, which exited 0.Main moved on to
33419925after the fetch, and its75d70502(#715) touchesscripts/bump-leg.pyandupdate-shape.py, in the tracking-branch reads, not the reads this PR changes. A local merge ofda3fe1c8with it, which was not pushed, is clean (Auto-merging scripts/bump-leg.py,Auto-merging update-shape.py; treec34d4048). On that merge, the PR's three test files,test_readme_windows_quoting_sentence.pyandtest_agents_md_codes.pygive 201 passed, each file EXIT=0.Why (repro on main)
Measured on main
fc867207, where #746, #810 and #813 have all landed, and then on this branch's head before the merge of main. Each run used local bare remotes, a stubghfirst on PATH,GH_TOKEN=bogus-w765, an emptyGH_CONFIG_DIR,GIT_CONFIG_GLOBAL=/dev/nulland git 2.43.0.Line 1: the branch read strips Unicode whitespace
Each helper, in process, over a repository on
featplus one character, with a branchfeatbeside it:update-shape.pycurrent_branchfamily.py_holder_branchbump-leg.pycurrent_branchfeat<U+00A0>feat. Branch:feat<U+00A0>feat. Branch:feat<U+00A0>feat<U+00A0>(#746)feat<U+2028>feat. Branch:feat<U+2028>feat. Branch:feat<U+2028>feat<U+2028>feat<U+0085>feat. Branch:feat<U+0085>feat. Branch:feat<U+0085>feat<U+0085>End to end, with
feat<U+00A0>checked out besidefeat:family.py remove.feat<U+00A0>and endedNEXT git -C <holder> push -u origin refs/heads/feat:refs/heads/feat. Pasted, that pushedfeatat main's commit, not the commit the act made.NEXT git -C <holder> push -u origin 'refs/heads/feat<U+00A0>:refs/heads/feat<U+00A0>'. Pasted, origin'sfeat<U+00A0>is the act's commit, andfeatis not pushed.update-shape.py apply --branch feat --push.committed on feat: ...andpushed feat to origin. But the commit was onfeat<U+00A0>, and origin'sfeatwas the commit before the re-pin.REFUSED update-branch-exists: --branch feat already exists and is not the current branch ('feat\xa0' is checked out), rolled back, with nothing pushed. That is the posturecommit_on_branchalready documents for a--branchthat exists and is not checked out.update-shape.py's rollback, fromfeat<U+00A0>with apre-commithook that exits 1:ROLLED BACK: every byte ..., thenswitched HEAD back to feat, the neighbour.feat<U+00A0>, in the default locale and underLC_ALL=C PYTHONUTF8=0.bump-leg.pyhas read the name whole since bump-leg.py: the branch is read in full and pushed asrefs/heads/<b>:refs/heads/<b>, dirty names are spelled,committedlists what git holds, and the dry run says it runs no validator (#672) #746 (rstrip("\n")). On main and on the branch, its NEXT namesfeat<U+00A0>, and pasted, it pushes the bump. The new case here is the same read beside the tracking branchmain:strip()readmain<U+00A0>asmainand refusedbump-leg-on-tracking-branch.Line 2: a branch whose name is not UTF-8
HEAD on
caf<0xE9>:bump-leg.py.NEXT git -C <root> push -u origin 'refs/heads/caf<U+FFFD>:refs/heads/caf<U+FFFD>'. Pasted: exit 1,error: src refspec refs/heads/caf<U+FFFD> does not match any, and nothing was sent.REFUSED bump-leg-branch-not-utf8, nothing fetched, written or committed. The dry run refuses alike, as it refuses a detached root.family.py remove.caf<0xE9>. The NEXT line held whatever the stream made of the lone surrogate. Under this machine's POSIX locale (stdoutsurrogateescape) that was the raw byte 0xE9, which a UTF-8 terminal shows, and copies, as U+FFFD. UnderPYTHONIOENCODING=utf-8:strictit was the six characters\udce9, which pasted name another branch.REFUSED family-branch-not-utf8, foradd,bumpandremovealike, before anything is fetched or written.update-shape.py apply --branch shape/update-partial.pre-commithook that exits 1):ROLLED BACK IN PART, thenHEAD is not back on caf<U+FFFD>: git -C <root> checkout 'caf<U+FFFD>'. The branch the run created was left behind, with a by-hand line that namescaf<U+FFFD>too. HEAD could not go back, because no branch is calledcaf<U+FFFD>.REFUSED update-branch-not-utf8, before the first write, so there is nothing to roll back.git branch -m cafe, each tool runs again as on any branch (measured with bump-leg's dry run: exit 0).How
scripts/bump-leg.py,current_branch. The subprocess call dropstext=True,encodinganderrors. The newline andrefs/heads/come off the bytes, and the rest is decoded withsurrogateescape.branch.encode("utf-8")raising is the test for a name that is not UTF-8, and the refusal names the bytes, and the root asshown_pathnames it (see "Merge of main")._branch_to_bump_from, the one caller, asks it before the fetch in both runs.scripts/family.py,_holder_branch. The same, through the_git_bytescall it already made, and its refusal names the holder asshown_pathnames it (see "Merge of main"). Its.strip()is gone. Its remedy'sbranch -mline is quoted for a POSIX shell (shell_line(..., "posix")), as bump-leg's and update-shape's are, so README's sentence on the lines quoted for PowerShell stays true. This is the only family.py hunk.update-shape.py._symbolic_headasks git itself, as bytes, in place of_git_answer's text. It still answers None for no git, or for a git that refuses, as_git_answerdid, so nothing raises inside a rollback.refs/heads/is ASCII, so_branch_oftaking it off the decoded text takes off exactly its bytes.current_branchtakes the branch from_symbolic_head, so the tool has one read, and refuses a name that is not UTF-8.cmd_applycallscurrent_branch(root)in itsif args.branch:block, beside_refuse_what_head_does_not_commit, so the refusal comes before the first write. This is the one hunk outside a branch-read helper. Without it,commit_on_branchrefused only after the copies were written, and rolled them back (revert U3 below).Rollback._undo_branchhands git the branch HEAD started on through_as_git_argument, sosubprocesswrites the name's own UTF-8 bytes under any locale, as bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 does forbootstrap.py. UnderLC_ALL=C PYTHONUTF8=0, a rollback fromfeat<U+00A0>raisedUnicodeEncodeErrorpart way and left the copies written; one from<U+65E5><U+672C>did so on main too. The by-hand lines it prints keep the name as it is._holder_branchreads only to printed lines (_checked_branch's refusal,_branch_said,_print_next), and bump-leg.py hands itscurrent_branchonly to printed lines (_branch_to_bump_from's refusals, the dry run's hint, the NEXT line). Neither hands it to git, so neither needs_as_git_argument.scripts/repo_shape.pyis not changed.Tests
The cases, each in the file where its tool's fixture already lives:
tests/test_bump_leg_branch_and_push_line.pytest_a_branch_ending_in_a_no_break_space_is_pushed_as_itself[beside-a-branch]...[beside-the-tracking-branch]:main<U+00A0>besidemaintest_a_branch_named_with_a_byte_that_is_not_utf8_is_refused_by_name[bump],[dry-run]tests/test_family_tracking_branch.pytest_a_branch_ending_in_a_no_break_space_is_pushed_as_itself:remove, its NEXT run as printedtest_a_branch_named_with_a_byte_that_is_not_utf8_is_refused_by_name[add],[bump],[remove]tests/test_update_shape_apply_lines_and_headers.pytest_a_branch_ending_in_a_no_break_space_is_not_taken_for_its_neighbourtest_a_branch_named_with_a_byte_that_is_not_utf8_is_refused_firsttest_a_rollback_puts_head_back_on_the_branch_it_started_on[locale],[c-locale](r765's)b'caf\xe9', and thebranch -m <a new name>remedy. It also asserts that HEAD, the ref HEAD is attached to, every ref, the index, the status and every git directory under.git/modulesare as they were (held_by_git).tests/conftest.pygainsNOT_UTF_8_BRANCH(caf+ byte 0xE9),NOT_UTF_8_BRANCH_NAMED(how a refusal names it),git_answer_bytes,held_by_gitandon_a_branch_that_is_not_utf_8. The last skips whereskip_where_a_name_cannot_be_not_utf_8says the name cannot be made: Windows'os.fsencode, and a filesystem that answers EILSEQ.own_remoteis split out oftaggedin the update-shape file, so the new cases push to a bare remote of their own.Revert table. Each change was reverted alone in a scratch clone of the head before the merge of main, against the 12 cases above and
tests/test_readme_windows_quoting_sentence.py(9 tests):fc867207has them[beside-...]bump-leg cases pass, as #746 already fixed that readupdate-shape.py_symbolic_head:.strip()back on the decoded refupdate-shape.pycurrent_branch: no refusalupdate-shape.pycmd_apply: no earlycurrent_branch(root)ROLLED BACKupdate-shape.py_symbolic_head:errors="replace"forsurrogateescapeupdate-shape.py: the name as{branch!r}, not its bytesRollback.new_branch:.strip()back_undo_branch: no_as_git_argumentLC_ALL=C PYTHONUTF8=0family.py_holder_branch:.strip()backfamily.py_holder_branch: no refusaladd,bump,removefamily.py: the remedy quoted for the host (no"posix")test_readme_windows_quoting_sentence.py[scripts/family.py]family.py: the name as{branch!r}bump-leg.pycurrent_branch:errors="replace"forsurrogateescapebump-leg.pycurrent_branch: no refusalbump-leg.pycurrent_branch:.strip()back (the issue's mutant)bump-leg.py: the name as{branch!r}Taking exactly one newline off, where
rstrip("\n")took every trailing one, cannot be told apart by any test: git refuses a newline in a ref name.Runs. Head
d98df568, before the merge of main (the gate at the merge is under "Merge of main"),/usr/bin/python3 -m pytest, a stubghfirst on PATH,GH_TOKEN=bogus-w765and an emptyGH_CONFIG_DIR, in six shards per mode. The 62 files:tests/test_shape_doctor.py,tests/test_scaffold_leg_path_collisions.pyandtests/test_cli_reference.py;tests/test_family*.py(27 files), everytests/test_bump_leg*.py(8 files), andtests/test_non_utf8_git_reads.py,test_update_shape_partial_rollback.py,test_update_shape.py,test_update_shape_landing.py,test_update_shape_paste_lines.py,test_update_shape_next_quoting.py,test_next_refspec_branch_length.py,test_tracking_branch_ref.py,test_branch_left_bump_leg_setup.pyandtest_non_utf8_name_helpers.py;tests/test_readme_windows_quoting_sentence.py(the one the first gate missed),test_agents_md_codes.py,test_git_output_decoding.py,test_validator_child_encoding.py,test_update_shape_kept_helper.py,test_doctor_next_is_a_command.py,test_openreposhape_package.py,test_setup_project_py.py,test_ruleset_hint_tracking_branch.py,test_template_values_single_pass.py,test_pinned_copy_text.py,test_refusal_default_remediation.pyandtest_repo_hygiene.py.Results:
test_family_init_values.pycases for a template that quotes the value, three PowerShell lines that only a Windows host prints, and one test that needs a disk that ignores case.PATH=<stub gh>:/usr/local/bin:/usr/bin:/bin): 3,523 passed, 34 skipped, 1 xfailed, 3,558 collected. The 11 extra skips are the tests that need git-filter-repo.Every log names the head, dirty 0, and ends
EXIT=0.On main, too, before the merge. At
d98df568,tests/test_bootstrap_non_utf8_branch.py(#824) was not in this branch. On a local merge of that head with maind8b8eb33(clean:Auto-merging scripts/family.py), that file, main'stests/test_readme_windows_quoting_sentence.py, the PR's three test files,test_update_shape_partial_rollback.py,test_git_output_decoding.pyandtest_agents_md_codes.pygave293 passed, EXIT=0. The branch now holds main, and the gate under "Merge of main" runs each of those files.Windows
CI is Linux only (#597), and none of this was run on Windows. What follows is judged from reading, not measured.
symbolic-refprints its UTF-8 bytes, and the three reads keep it.escape_what_stdout_cannot_encodewrites them as\u2028and\x85, and so does a code page without U+00A0 for it (\xa0). A pasted escaped name names another branch: the push failssrc refspec ... does not match anyand sends nothing. POSIX's C locale escapes them the same way.ref: refs/heads/caf<0xE9>as bytes, sosymbolic-refprints the byte however the ref store keeps it, and the three tools refuse it as on Linux.xutftowcsn), a byte from 0xA0 to 0xFF that is not UTF-8 maps to U+00A0 to U+00FF, so the loose ref is the filecaf<U+00E9>.git branch -mthen renames the branch.CreateProcessWcarries UTF-16), so such a branch most likely arrives only in a repository copied from another system._as_git_argumenthands the name over as it is on Windows, where git takes UTF-16.os.fsencodemakes no byte of the surrogate, and the rollback case isWINDOWS_SKIP.Overlaps
bumpfetches every branch of the member'soriginwhatever the mount's refspec and drops theorigin/refs no ref oforiginbacks, 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 nooriginit has none instead of to fix the network (#686) #795, which editedscripts/family.py'sbumpfetch, landed as6f64eea. This PR's only family.py hunk is_holder_branch, so the two touch different lines, andd98df568merged with main112eeed2without a conflict. The conflicts the merge with maind6071c7dresolved came from family.py, scaffold-project.py and bump-leg.py:init's plan rows and seven refusals name a holder, a folder, a lock, a remote path or a root as it is, so an ESC, a line break or U+202E in a path reaches the terminal as #692's refusals no longer let it #766, in two test files (see "Merge of main").l3-676-pins-merged-awayandl3-683-partial-cloneas editingupdate-shape.py. This PR's update-shape.py hunks arecurrent_branch,_symbolic_head, the new_as_git_argument, one line inRollback._undo_branch, and one line, with its comment, incmd_apply'sif args.branch:block.tests/conftest.pygains three helpers and two constants afterskip_where_a_name_cannot_be_not_utf_8. No existing line changes.Not in this PR
scripts/repo_shape.py, which is a pinned shape copy. bootstrap.py and repo_shape.py: a leg on a branch whose name is not UTF-8 makes every latermake bootstrapend in a UnicodeDecodeError, and a character the filesystem encoding lacks still ends step (a) in a traceback #710's ruling (Option 2, landed in bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 as0dee22b) keepsgit_out's strict decode and itsstrip(), sofamily.py's_member_head, which reads a MEMBER's HEAD through it for a rollback, is unchanged; it goes to Tracking: adopt-project.py, converting an existing repository in place #457's checklist.bump-leg.py'sleg_placement, which reads the LEG's own HEAD for its rollback, still useserrors="replace"andstrip(), andrestore_legpasses that branch throughchecked_value, which takes ASCII only. It is not the root's branch, which is what the issue's lines name. It goes to Tracking: adopt-project.py, converting an existing repository in place #457's checklist.--helpis unchanged, sodocs/cli.mdis unchanged.Known limits
update-shape.pynow refuses runs that main completed. On main,apply --branch <new>from HEAD oncaf<0xE9>committed and pushed the new branch, and one with nothing to do saidnothing to do, exit 0, since neither names the branch HEAD started on unless a rollback runs. Everyapply --branchrun on such a branch is refused now, exit 2, as the coordinator decided for all three tools: refuse a name the tool cannot spell, before anything is written.applywithout--branchnames no branch and runs as on main. The way out is onegit branch -m.family.py's dry runs refuse late. They ask_holder_branchlast, at theirbranchline, so a dry run on such a branch prints its plan and then refuses, exit 2 (bump --dry-runhas already fetched in the member, as it always does). Their real runs refuse before anything is fetched or written. Keeping the hunk inside the branch read is why the dry run does not report it as abranchline, as it does the tracking branch. No test holds the dry run's refusal.feat<U+2028>orfeat<U+0085>names it exactly, inside single quotes, so the character reaches the terminal as it is, and a viewer that breaks lines there shows the line broken. That was already true of bump-leg's line since bump-leg.py: the branch is read in full and pushed asrefs/heads/<b>:refs/heads/<b>, dirty names are spelled,committedlists what git holds, and the dry run says it runs no validator (#672) #746.Closes #765
🤖 Generated with Claude Code
Summary by Sourcery
Read branch HEAD references as Git-written bytes and refuse branch names that cannot be represented as UTF-8 before modifying repository state.
New Features:
Bug Fixes:
Enhancements:
Tests: