Repository navigation
bump-leg.py: the branch is read in full and pushed as refs/heads/<b>:refs/heads/<b>, dirty names are spelled, committed lists what git holds, and the dry run says it runs no validator (#672) - #746
Conversation
… carry a name or branch that is not UTF-8, `os.fsencode` first and the disk's EILSEQ second, and two pins hold `bump-leg.py`'s UTF-8 decode and the leg finding's role, so a test no longer skips on the disk alone or passes under a decode as ASCII or a finding named by path (#663) #461's five test lines, lane 2's L2-52 and lane 4's R423 N1 and N2 and R455 F1 and F3. Test only: no tool and no template changes, and `validate-manifest.py`, a pinned copy, is read and not edited. - `tests/conftest.py` holds `skip_where_a_name_cannot_be_not_utf_8`, which `tests/test_adopt_pins.py` and `tests/test_pin_source_unreadable.py` each had a copy of. It asks `skip_where_fsencode_gives_a_surrogate_no_byte` first (Windows' `os.fsencode` makes three bytes of U+DCFF, so no name that stands for a byte is carried there, though NTFS would take the file), then the disk (macOS answers EILSEQ); any other error is the test's own. - `make_file` and the `--branch a\udcffb` test call it, so they skip on Windows too, where the disk takes the name and the child then refuses the branch. `test_a_branch_the_system_carries_has_no_fault` asks the same function instead of its own copy of the question. - `test_a_name_that_is_utf8_under_quote_path_false_is_still_refused` asserts that the line naming `caf<e9>.yml` holds no literal `\x`, in any locale, so `bump-leg.py` decoding git's bytes as ASCII fails it. - `_leg_branch_findings` is asked of a leg whose role is not its path, so naming the leg by `path` fails it. - `tests/test_non_utf8_name_helpers.py` holds the helpers to this, with Windows' `os.fsencode` and macOS's EILSEQ emulated: CI is Linux only, and no Windows or macOS run was made. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…held by an emulation, so dropping the shared helper from it fails a test on Linux too (#663) `test_a_branch_the_system_carries_has_no_fault` now asks `skip_where_fsencode_gives_a_surrogate_no_byte` where it had its own copy of the question. The new test calls it under Windows' `os.fsencode` (emulated): a branch holding a byte-carrying surrogate is skipped, plain text and text with an accent run. Removing the call fails the two skipped rows. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…`refs/heads/<b>:refs/heads/<b>`, `bump-leg-root-dirty` spells each name, `committed` lists what git holds, and the dry run says it runs no validator, so a root on `+x` or beside a tag `main` is no longer told to force-push or push to the default branch (#672) Five lines of #672, each measured on 35133f2 and gone on this head: - The branch: `current_branch` read `git symbolic-ref --short HEAD`, which prints `heads/<b>` beside a tag of the same name. With a tag `main` the tracking-branch refusal was not given, the bump committed on `main` and printed `push -u origin heads/main`; a HEAD on `refs/tags/x` committed through the tag and moved it. It reads the full ref now, and only a ref under `refs/heads/` is a branch. - The push: the NEXT line ended `push -u origin <branch>`, a refspec, so a root on `+x` was told `push -u origin +x`, which force-pushed a local `x` over origin's and never pushed `+x`; on `+main`, pasted, it rewound origin's `main`. It prints `refs/heads/<b>:refs/heads/<b>`, the form lane 4's #689 (`family.py`) and #674 (`update-shape.py`) print, which pushes the branch unforced, beside a tag of its name too, and `-u` still records `origin/<b>`. - `bump-leg-root-dirty` printed C1 controls and bidi overrides raw under `core.quotePath=false`. Each line is `repo_shape.spelled` now, split on git's newlines only, so U+0085 and U+2028 stay on their line, named. - `committed <sha>:` listed the paths staged; it lists the commit's paths from `git diff-tree --ignore-submodules=none`. Where the gitlink already records the new commit, the `commit` line and the message name the pin's own `commit:` as the old one, not `X -> X`, and say the gitlink does not move. A failure to read the paths after the commit is a sentence, never a refusal over a bump that landed. - `--dry-run` runs no validator; its closing paragraph says so, and that the real run can still end `bump-leg-validators-red`, rolled back. Also: the `BRANCH_LEFT` comment names its four copies (rv647's nit 3). The ride-along (`run_validator`'s environment) is left: no verdict differs, and the caller's `PYTHONPATH` gives the more precise finding. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-5, 2026-10-16T18:38Z) @codex review |
Reviewer's GuideUpdates bump-leg.py to validate full branch refs, generate safe explicit push refspecs, safely spell unusual Git paths, report the actual committed paths and movement semantics, and explain skipped validators during dry runs; extensive regression tests cover each behavior. Sequence diagram for the bump-leg commit and push flowsequenceDiagram
participant User
participant BumpLeg as bump-leg.py
participant Git
participant Validators
User->>BumpLeg: cmd_bump(args)
BumpLeg->>Git: symbolic-ref --quiet HEAD
Git-->>BumpLeg: refs/heads/<branch>
BumpLeg->>BumpLeg: current_branch()
alt dry run
BumpLeg-->>User: Report preview and skipped validators
Note over BumpLeg,Validators: Validators are not run
else real run
BumpLeg->>Git: Commit rewritten leg
BumpLeg->>Validators: validate-pins.py and validate-manifest.py
alt validators pass
BumpLeg->>Git: diff-tree --name-only -r -z --ignore-submodules=none <sha>
Git-->>BumpLeg: Actual committed paths
BumpLeg-->>User: Explicit refs/heads/<b>:refs/heads/<b> push command
else validators fail
BumpLeg-->>User: bump-leg-validators-red and rollback
end
end
Sequence diagram for committed-path reportingsequenceDiagram
participant BumpLeg as bump-leg.py
participant Git
BumpLeg->>Git: Commit bump
Git-->>BumpLeg: commit sha
BumpLeg->>Git: diff-tree --no-commit-id --name-only -r -z --ignore-submodules=none sha
Git-->>BumpLeg: Paths held by commit
BumpLeg->>BumpLeg: spelled(path)
BumpLeg-->>BumpLeg: Print committed paths
alt path listing fails
BumpLeg-->>BumpLeg: Print git show --name-only recovery hint
end
Flow diagram for safe branch push refspec generationflowchart LR
HEAD[HEAD ref] --> Check{Starts with refs/heads/?}
Check -->|No| Detached[Refused as detached]
Check -->|Yes| Branch[Extract branch name]
Branch --> Refspec[refs/heads/<b>:refs/heads/<b>]
Refspec --> Push[git push -u origin refspec]
Push --> Upstream[Records origin/<b> upstream]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
… fails where a skip escapes it, `windows_fsencode` passes bytes through as `os.fsencode` does, and the no-`\x` check skips where the file name encoding is not UTF-8, so a leaked skip, an emulation that breaks on bytes and a Latin-1 host no longer pass silently or fail on a tool that is right (#663) The review of #723 (0b1d1dd) found no MUST and three things to take. SHOULD-1: `skip_message` had no test of its own. A skip that escapes it ends the test SKIPPED, and no run fails on a skip, so the mutant where one escapes gave `10 passed, 3 skipped` on the helpers file and seven emulation tests silently skipped. `tests/test_non_utf8_name_helpers.py` now holds it with three tests: it returns the reason a call skips with, it returns None where the call does not skip, and it lets any other exception go on. The first catches the escape and fails it with `pytest.fail`, because a bare assert on an escaped skip would end that test SKIPPED too. NIT-2: `windows_fsencode` took `str` only. `os.fsencode` passes bytes through on every system, so the emulation now does, and two tests hold it (the surrogate and text, and the bytes, compared with `os.fsencode` itself). NIT-1: on a Latin-1 file name encoding `make_file` writes `caf\xe9.yml` as the single byte 0xE9, the tool prints `caf\xe9.yml`, and the new `"\\x" not in shown` assertion would fail although the tool is right. It is now `assert_shown_without_hex_escapes`, which skips where `os.fsencode(UTF8) != UTF8.encode("utf-8")` and is called after the test's other assertions, so a skip loses none of them. Two tests hold it under an emulated Latin-1 `os.fsencode` (it skips) and Windows' (it asserts, and fails a line that holds a `\x`). Tests only; no tool and no template changes. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…ty list keeps git's leading ` M`, the fallback names a `git show` that lists the gitlink, and a gitlink moved by hand is not said to move, so rv672's S2 and N1-N3 no longer happen (#672) rv672's review of 326de72, one fix round: - S2: `current_branch` takes only git's newline off `symbolic-ref`'s answer. `strip()` also took a trailing U+00A0, U+2028 or U+0085, so a root on `feat<U+00A0>` beside a branch `feat` committed on the first and printed a push of the second. - N1: `_ensure_root_clean` takes only the last newline off. `strip(" \n")` took the first line's leading space, so its ` M` read as `M` (staged) and stood a column left of the lines under it. - N2: the `committed` fallback names `git show --name-only --ignore-submodules=none <sha>`, which lists the gitlink under `diff.ignoreSubmodules=all` as the line itself does. - N3: where the gitlink already recorded the commit, the message's last sentence says the pin and the workflow references are brought to the gitlink in ONE commit, and no longer that the gitlink moves in it. The ordinary message is byte for byte what it was. Tests: S1 parametrizes the HEAD-outside-`refs/heads/` test over `refs/tags/release-1` and `refs/remotes/origin/main` (rv672's R1 survived on `refs/tags/x`, whose name is as long as `refs/heads/`); N4 adds a pin whose `commit:` is `not-a-sha` (rv672's R2); S2 and N1 each add a test, and the dirty-list expectations in two files now read ` M ...`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es every reason THIS checkout cannot answer, and the notes are current to main bf1f099 with the 25 pull requests since de92614 (#563, #683) - W683 and RV-f's N4 on #742: README's exit-3 list (Keeping a project's shape current, :1208-1220) and its handbook twin (`doctor`) named only a pin whose commit this checkout does not carry. They now say, as shape-doctor.py's EXIT CODES does: a pin's commit it lacks, a partial clone missing what is compared (#683), a read that fails (`update-upstream-unreadable`), a crashed validator of its own (#278, #410), or its own naming policy not loading (#678). +1 line, with tests/readme-cap.d/683.txt; README.md is 1474, its cap (1422 + 2 + 5 + 12 + 22 + 10 + 1). The two `Keeping a project's shape current` markers move: `doctor` text and marker, `current` marker only. - The notes, "What changed since v1.0.1", one entry per pull request from `git log --first-parent de92614..bf1f099`, each from its squash commit and the issues GitHub says it closes: - adopt-project.py: #699, #719, #749, #727, #717, #728 (with #621's hint), #743 + #720 + #718, #722, #721; - update-shape.py, bump-leg.py and a project's own scripts: #702 (with the pinned validators), #634; - scaffold, setup, family and the shared readers: #725, #671, #744; - shape-doctor.py: #742, #701, #737, #751, #736, #734, #700 + #705, #740; - install, CI and the docs: the exit-3 sentence above. - Counts (99 pull requests since bfed143; 109 of 135 milestone issues closed at 10:28Z), the "current to main" sha and the open list (26) are at main bf1f099, still placeholders for tag time. Left for round B: lane 5's eight lines (#630 issuecomment-6227129879), whose pull requests #726, #747, #682, #724, #746, #745 and #738 are open drafts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-bump-leg-branch-and-push-line #723 lands before #746, and the two conflicted in one assertion of tests/test_non_utf8_git_reads.py::test_a_name_that_is_utf8_under_quote_path_false_is_still_refused. This branch moved its prefix from four spaces and `M` to five (#672 keeps the leading space of git's ` M`). #723 rewrote the same `any(...)` into `[shown] = [...]`, `assert shown.endswith(".yml")` and a closing `assert_shown_without_hex_escapes(shown, output)`, so a decode of git's bytes as ASCII fails it. The resolution takes #723's form with this branch's five-space prefix. Each side alone fails here: #723's four-space line finds no line under #672's output (`ValueError: not enough values to unpack`), and this branch's `any(...)` drops the hex-escape check, so `bump-leg.py` decoding git's bytes as ASCII passes it. Every other hunk merged as git made it. Every test of each side stands: test_non_utf8_git_reads.py has #746's 14 and #723's 19 test functions as 19 (#723's file is a superset of names), test_bootstrap_branch_bytes.py 18, test_non_utf8_name_helpers.py 12, test_adopt_pins.py 44 and test_pin_source_unreadable.py 13. The three synthetic lines #723's helper tests feed `assert_shown_without_hex_escapes` keep their four spaces: they are the helper's input, not the tool's output. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
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 5 hours and 47 minutes by commenting |
…_utf8_git_reads.py imports both sides' conftest names, so #723 and #746 land under #758 with `appended_to_command_scope` still in place (#758) #723 (`bad05d21`) and #746 (`fc867207`) landed on main after `eaa72d9e`. The merge conflicts in one place: the `from conftest import` block of `tests/test_non_utf8_git_reads.py`. #758 added `appended_to_command_scope` there, and #723 added `skip_message`, `skip_where_fsencode_gives_a_surrogate_no_byte` and `windows_fsencode`. The resolution keeps both sides' names, sorted as the file sorts them: from conftest import (ORG, PROJECT, REPO, appended_to_command_scope, cloned_just_pushed, git, run_script, skip_message, skip_where_fsencode_gives_a_surrogate_no_byte, windows_fsencode) No other file conflicts. The scan in `tests/test_no_auto_maintenance.py` flags no file on the merged tree, and no file outside that test's own docstring uses `PASTE_ENV` or `ALLOW_FILE_PROTOCOL`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#813 and #723, v1.0.0's quoted-header and plain-path-clone limits closed and the counts current to main bad05d2 (#563) Each entry is the one RV630 corrected in its pre-review of the B2 stage, put at its anchor, and checked against its squash on main: the `git merge-tree --write-tree` of main before the squash with the head the entry was written from equals the squash's tree for all seven. - #810 (#674, `d17564d9`): a new bullet, "`apply`'s lines, its branch and its headers.", after "Headers, branches and messages.". It closes v1.0.0's quoted `files:`/`shape:` header (v1.0.0.md:439) and `apply`'s half of the paths listed as staged (:433); `bump-leg.py`'s half waits for #746. - #731 (#692, `d66a9054`): appended to "Folders, asked before they are made.". - #813 (#689, `f6d17bc5`): appended to "`--tracking-branch`.". - #809 (#688, `42aed788`): a new bullet, "What `family.py init` records and commits as.", after "Names that are not UTF-8.". - #816 (part of #684, `fa04d2cd`): a new bullet, "More holder states, named.", after "family.py refuses before it writes.". #816 closes no issue; #684 keeps its reftable case and is on v1.1.1. - #748 (#662, `431c8cf8`), then #723 (#663, `bad05d21`): in the CI bullet, after #713's sentence. With #554 and #578, #748 closes v1.0.0's limit on fixtures that clone a just-pushed remote by plain path (:530). The counts, in the header comment, the counts paragraph, "What changed" and the open list: main `bad05d2`; 120 pull requests since `bfed143`; at 18:02Z, 3 of the `v1.1.0` milestone's 133 issues open and 130 closed. The open list is #563 (#630), #672 (#746) and #686 (#795); #684 and #695 (#738) are on v1.1.1. #746 and #795 have not landed, so neither has an entry yet. Nothing since 2000488 touches pyproject.toml, src/openreposhape/, install.sh or .github/workflows/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained #746 (#672, `fc867207`) since bad05d2. No conflict, and it touches no README.md, handbook, AGENTS.md, docs/ or README cap ledger file, so every handbook marker reads as before. README.md is 1474 lines, its cap; AGENTS.md 494, its cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it closed as one clause, the list's and the known limits' markers made final, the triage lines named by their issues, and the counts current to main fc86720 (#563) - #746 (#672, `fc867207`): a new bullet, "`bump-leg.py`'s branch and its lines.", after "`bump-leg.py --dry-run` gives the real run's answer.". `git merge-tree --write-tree bad05d2 a5139a3`, the head #746 was gated and landed on, equals the squash's tree `bb920ac1`. The head the entry was written from, `4a014a79`, sat on #723's head before its squash and does not merge clean with main; `scripts/bump-leg.py` is one blob (`226e7b0d`) at `4a014a79`, `a5d473ee`, `a5139a32` and the squash, and the squash's own lines in each of its five files equal that head's, so the entry stands as written. - v1.0.0's limit on the paths listed as staged (v1.0.0.md:433) is one clause, in #746's entry: with #810 it closes. #810's half-clause is cut. `bump-leg.py --dry-run` over a pin its validators refuse (:440) stays a limit, which the dry run now says. - "What changed" runs to main `fc86720`, and says #795 is added if it lands next; Known limits' re-read marker and "as read so far" are cut. The counts marker stays as the release-body tool reads it. - Known limits name each triage by its issue: "the checklist triage of the L4 lines on #457 and #459" and "of the L1 lines on #457", where they named lanes 4 and 1 and D9, and each id reads "#457's L4-05", "#459's L4-24" and so on. - Counts in the four places: main `fc86720`; 121 pull requests since `bfed143`; at 19:08Z, 131 of the `v1.1.0` milestone's 133 issues closed, the open two #563 (#630) and #686 (#795). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…f64eea, RV630's lead and two named limits under Known limits, and the counts current to main 6f64eea (#563) - #795 (#686, `6f64eea2`): a new bullet, "`bump --to`, reachable or not.", before "family.py refuses before it writes.", the pending file's text as written. `git merge-tree --write-tree fc86720 <head>` equals the squash's tree `0f2d2ddd` for `17bbbaba` and for the final head `cf143c69`. For the head the text was written from, `cf5e64b3`, the merged tree differs from the squash only in `tests/test_family_bump_reachability_words.py`, so `scripts/family.py` is as the text says. - "What changed" runs to main `6f64eea`, with no word of a later pull request. - Known limits, RV630's words: the lead says the list is not all of v1.0.0's open limits, and names two this release worked on and left open, the `--into` sentence (#695) and `bump-leg.py --dry-run` over a pin its validators refuse (#746, #672). - Counts in the four places: main `6f64eea`; 122 pull requests since `bfed143`; at 20:15Z, 132 of the `v1.1.0` milestone's 133 issues closed, the one open #563 (#630). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ush-line), landed as fc86720 tests/test_dry_run_parity.py conflicted. The merge bases are 431c8cf and a5d473e, and neither holds #731's rows, so ort's virtual base lacks them: #731's `spelt` came back as a conflict, and its POSIX_NAMES, ESC_NAME and FORGED_NAME block came back unflagged, beside #766's move of all four to tests/conftest.py. The file is the same at ab55c29, 13f643d, a5139a3 and fc86720, so only #766 changed it, and it is taken as ours, which is ef6db40's byte for byte.
scripts/bump-leg.py (two hunks) and tests/test_bump_leg_branch_and_push_line.py (three, add/add) conflicted, each where #746's text meets #766's addition beside it. Main holds both files exactly as #746's final head a5139a3 does (blobs 226e7b0 and 11dc4bd at both), and this branch already merged a5139a3, so each is taken as ours. fc86720's tree is a5139a3's, so this merge leaves the tree as it was (f4bb551).
…he import list main's #746 extended with `spelled` merges without a conflict (#715) `716ad457` added `readable_branch` to the `from repo_shape import (...)` list, on the line #746 (`fc867207`) also rewrote, and `git merge-tree` with main `190133d5` stopped there. The list is as main's merge base has it again, and the name has an import of its own after it, as `update-shape.py` imports `_strip_comment`. The predicted merge with `190133d5` is now clean. Run: `tests/test_yaml_reader_spaces.py` and `tests/test_bump_leg_dry_run_tracking.py`, `-k "bump_leg or tracking"`: 44 passed. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd what they tell git to the `GIT_CONFIG_COUNT` entries the environment holds, so a pasted line keeps the session's maintenance settings and the Windows job's two (#758) (#818) Against main. #748 landed as `431c8cf8`; this branch merged #748's final head and main (see Fix round 1). After that merge, #723 (`bad05d21`) and #746 (`fc867207`) landed. The branch is refreshed over main `fc867207` as `4a553e17`, one merge commit. Its only conflict was `tests/test_non_utf8_git_reads.py`'s `from conftest import` block, resolved by keeping both sides' names, as rv818 asked. The merged tree differs from main in #818's own 27 files, and #818's count scan flags no file. Tests only: 25 files under `tests/`, no shipped code, no pinned file, no README, handbook, `AGENTS.md` or `docs/cli.md` change. Two new commits on `b6e15591`: the retarget merge `2908e46f`, then the fix commit `eaa72d9e`. Tests only, as before. ### The retarget: merge commit `2908e46f` #748 landed as `431c8cf8` from its final head `8368d1f8`, and GitHub moved this PR's base to main. The merge commit has three parents: `b6e15591`, `8368d1f8` and main `fa04d2cd`. #748's head goes in first, so git sees the history the squash dropped. - **#748's head did not merge clean**, as rv818 found. `tests/test_adopt_empty_symlinks.py` conflicts, because `8368d1f8` holds main `741679e7`, which carries #388. - I took w758's earlier resolution of that file, which rv818 checked. Main's side of the file is the same at `974d4827`, `8368d1f8` and `fa04d2cd`, so it still fits. - Both sides stay: #388's two targets (`with_each_target`, `SAID`, the `target` parameters) and #758's `symlinks_off()`, built at the call. - `test_the_link_given_a_target_adopts_end_to_end` calls `symlinks_off()` too. Git does not flag it, and left as it was the file fails at collection. - **Main then conflicted in three files** (main was `d17564d9` when I resolved them): `tests/test_clone_just_pushed.py`, `tests/test_doctor_legs_members_next.py` and `tests/test_family_bump_remove_rollback.py`. - Main's copy of each was `8368d1f8`'s byte for byte. - This branch's copy is `8368d1f8`'s plus #758's own hunks, so this branch's side is taken. - Main's next two landings, #809 and #816, merged with no conflict. - **rv818 MUST 1.** `tests/test_local_remote_dir_urls.py` came with #812 and imported `ALLOW_FILE_PROTOCOL` from `test_family`. - Without a change, the file fails at collection: `ImportError: cannot import name 'ALLOW_FILE_PROTOCOL' from 'test_family'`, and none of its 27 cases run. - It now imports `allow_file_protocol` and calls `pasted(lines, paste, allow_file_protocol())`. - `git grep` finds no other file on main that uses a name #758 removes (`SYMLINKS_OFF`, `SYMLINKS_ON`, `CONFIG_COUNT`, `CONFIG_VALUE`, `PASTE_ENV`, `ALLOW_FILE_PROTOCOL`). `NO_MAINTENANCE`, `QUOTE_PATH_OFF` and `tool_env` change shape, and each is used only in its own file. - **Checked:** - Against main, the merged tree differs in #758's 25 files and in `tests/test_local_remote_dir_urls.py`. - Each of the 25 differs by exactly #758's own added and removed lines, `tests/test_adopt_empty_symlinks.py` aside. - Collecting the whole suite at the head finds 12,320 tests and no error. ### The fix commit `eaa72d9e` - **SHOULD 2:** `configure` in `tests/test_adopt_reencoded_files.py`, the seventh append from #758's line 2, now sets what `appended_to_command_scope(os.environ, pairs)` returns. It gives the same entries in the same order, so no test can fail on the old form. - **SHOULD 3:** the scan also reads a name the module binds to `"GIT_CONFIG_COUNT"` (`CONFIG_COUNT = "GIT_CONFIG_COUNT"`, annotated or not) as the count. It does so in a dict key, a setter's first argument and a subscript. - rv818's repro: append the base's two lines to `tests/test_adopt_root_symlinks.py`. - The old scan passes. The new one names line 2137. - **SHOULD 4:** `counts_set_to_a_number` is split into one reader per node type: `in_a_dict`, `in_a_call` and `by_subscript`. The walk takes each node's reader from `READERS`. By hand, no function scores more than 5 on S3776. I could not run Sonar. - **NIT 5:** `conftest.py` is no longer skipped. It scans clean, at the head and on today's main. - **NIT 6:** - New "sees" cases: `os.putenv("GIT_CONFIG_COUNT", "1")` and a bare `setenv("GIT_CONFIG_COUNT", "1")`. - The walk is now `files_off_their_count(directory)`. A new test runs it over a folder of its own, and checks three things: - `conftest.py` and a helper module are read; - a `.txt` file is not; - a file `OWN_COUNTS` allows is named for fewer counts as well as for more. - Four of rv818's six surviving mutants now fail a case: `putenv` dropped, the bare-name setter dropped, `!=` changed to `>`, and `glob("test_*.py")`. - The fifth, `conftest.py` scanned, is now how the code works, and putting the skip back fails the new test. - The sixth, the count's value taking any string, still survives: it is a stricter scan. Git refuses a count that is not a number (`bogus count in GIT_CONFIG_COUNT`, exit 128), so a test that holds one fails on its own. - **NIT 7, in part:** - An empty count (`""`) is now a count of its own. Git 2.43 and 2.47.1 read it as no entries at all. Measured: `GIT_CONFIG_COUNT= GIT_CONFIG_KEY_0=a.b GIT_CONFIG_VALUE_0=c git config --get a.b` exits 1. - The docstring says the scan reads the count's value as a constant only. - Left out: - a value built by a call (`str(len(pairs))`) or held in a variable; - an int; - `dict()`, or `environ={}` by keyword, handed to the helper; - `setenv` with keyword arguments; - an annotated subscript. rv818 judged only `str(len(entries))` likely, and telling it from a count worked out needs what the call means, not how it is spelled. - **NIT 8:** the paste case deletes `GIT_CONFIG_PARAMETERS` first. Measured on the merge before the fix commit, with `GIT_CONFIG_PARAMETERS="'probe.param'='p'"`, the case failed (`Left contains one more item: 'probe.param=p'`). It passes now. - **NIT 9, left as it is:** main's three appends built by hand each work the count out right: `tests/test_family_head_lost_gitmodules.py:97`, `tests/test_family_bootstrap_gate_order.py:135` and `tests/test_family_add_spellings_remedies.py:137`. Each of those files is also changed by another open PR (#828 `fix/761-add-move-back-gitmodules`, #820 `fix/762-validate-family-remedies-texts`, `fix/763-holder-docs-agree`), so converting them here would only add conflicts. - **NIT 10:** the comment on `NO_MAINTENANCE` now says its `maintenance.auto` and `gc.auto` repeat the session's two, which reach these gits in the command scope. Only `gc.autoDetach` adds a setting. The two stay, so the constant alone still keeps maintenance off for these gits. - **NIT 11:** the count was in lane 5's #473 summary, not in this body. The seven sets held 2,401 distinct passes, not 2,609: the 208-test run repeats three files of the 256-test run. ### Tests at `eaa72d9e` All runs went through `run9.sh`, with the real `gh` unreachable. All passed, none skipped: - **The 26 test files the PR changes against main**, in one full-file run each: 2,206 passed (1,108 + 1,098). - These are #758's 24 test files, `tests/test_local_remote_dir_urls.py` and `tests/test_adopt_reencoded_files.py`. - The count matches the collection exactly. - **The 43 files that import any of those 26, with `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`:** 1,646 passed (825 + 821). - **The same merge made on main `d17564d9`** (a local commit, `c16a7e1a`, never pushed), before the fix commit: `tests/test_local_remote_dir_urls.py` and `tests/test_no_auto_maintenance.py`, 55 passed. ### Fails on the base The new tests were run against the base's scan and walk, as `b6e15591` has them, with `GIT_CONFIG_PARAMETERS` set: 6 failed, 30 passed. The six: - the paste case; - the `nothing`, `a-name-bound-to-it`, `an-annotated-name` and `a-name-by-subscript` cases; - the walk test. The three other new cases (`a-bare-setenv`, `putenv`, `a-name-bound-to-another-key`) are there to kill mutants, and they pass on any correct scan. MUST 1 fails on main's form at collection, as above. SHOULD 2 and NIT 10 change no behaviour. ### Mutants I made 26 mutants. 25 are caught, and one survives as rv818 said it would: - **22 on the scan and the paste case**, each run against `tests/test_no_auto_maintenance.py`: - the delenv dropped; - `putenv` dropped from `SETTERS`, or the bare-name setter; - the name branch of `is_the_count` dropped, or any name taken as the count; - `""` no longer a count; - the `Assign` names dropped, or the `AnnAssign` names; - no names collected; - each reader ignoring the names; - each of the three readers dropped from `READERS`; - `conftest.py` skipped; - `!=` changed to `>` or to `<`; - `glob("test_*.py")`, or `glob("*")`; - `by_subscript` taking any value; - the walk pointed at an empty folder. The last passes on a clean tree, like any change to an assertion that nothing is found. It is caught by rv818's SHOULD 3 repro, where the real walk fails and the mutant passes. - **The survivor:** the count's value taking any string. It makes the scan stricter (see NIT 6). - **`configure`:** with `appended_to_command_scope({}, pairs)`, the scan names the file. With the pairs dropped, three of its tests fail. - **MUST 1:** main's import put back fails collection with `ImportError`. ### Merge checks at `eaa72d9e` I ran `git merge-tree --write-tree` against `origin/main` (`fa04d2cd`) and the 53 other `fix/*` and `l3-*` heads on origin: - **Clean:** `origin/main` and 43 heads. - **#792 (`l3-785-non-ascii-paths`)** merges clean, but `tests/test_doctor_non_ascii_paths.py` imports `PASTE_ENV`. Whichever PR lands second changes `:50` (the import), the `:266` docstring and `:268` to `paste_env()`. - **#723 (`fix/663-non-utf8-name-tests`), #746 (`fix/672-bump-leg-branch-and-push-line`, which now holds #723) and #806 (`fix/757-non-utf8-test-dirs`)** conflict only in the `from conftest import` block of `tests/test_non_utf8_git_reads.py` (and of `tests/test_adopt_git_reads.py`, for #806). Both sides add names, so keep them all. - **Seven heads conflict with main itself, not with this PR:** - `l3-732-latin1-section` and `l3-733-ignored-sections` (`tests/test_doctor_legs_members_next.py`); - `fix/712-validate-pins-contracts`, `fix/766-spelled-paths` and `fix/798-adopt-legs-asked-first`; - `l3-677-pins-hand-step-wip` and `l3-678-naming-checkout-wip`. - I scanned each clean merged tree's changed test files for a name #758 removes, or a count the scan flags. Only #792 shows up. - **Main moved after the push.** #723 landed as `bad05d21`, and the two now conflict in one place: the `from conftest import` block of `tests/test_non_utf8_git_reads.py`. The fix is to keep both sides' names: `ORG, PROJECT, REPO, appended_to_command_scope, cloned_just_pushed, git, run_script, skip_message, skip_where_fsencode_gives_a_surrogate_no_byte, windows_fsencode`. Every other change merges in. On that merged tree, with the block resolved that way, the scan flags no file, and no file uses a name #758 removes. On `deb029c9` a probe test imported `PASTE_ENV` and pasted `git config --show-origin --list` through `paste()`: - `PASTE_ENV` held `GIT_CONFIG_COUNT=1` (`protocol.file.allow`), a stale `GIT_CONFIG_KEY_1=user.email` left from collection, and no `GIT_CONFIG_GLOBAL`. - `os.environ` at the call held a count of 4 and the session's global file. - The pasted git listed one command-line entry, `protocol.file.allow=always`, and `git config --get maintenance.auto` exited 1. So a pasted line ran with git's automatic maintenance on, in both scopes, as the issue said. `paste_env()` replaces it. It reads `os.environ` at each call (minus `PYTHONPATH`), adds the identity and `PYTHONDONTWRITEBYTECODE`, and appends `protocol.file.allow` with `appended_to_command_scope`. The six uses call it. The import-time copy also predated the session's `gh` guard on `PATH`, as `test_doctor_next_is_a_command.py`'s docstring says of its own environment; a pasted line now gets the `PATH` of the call. That docstring now says `paste_env()` reads at the call too. - **The seven.** Six are on this base, each now one call to `appended_to_command_scope`: `test_adopt_git_reads.py`, `test_adopt_nested_attributes.py`, `test_adopt_ignored_shape_files.py`, `test_adopt_root_symlinks.py`, `test_bootstrap_after_bump.py`, `test_family_bump_remove_rollback.py`. The seventh, `test_adopt_reencoded_files.py`, came with #743 and is converted in fix round 1. `test_clone_just_pushed.py:96` stays, as the issue says: it is the helper's own test. - **The other literals.** Each sets a count of its own and is laid over `os.environ`, where the session's two settings and the Windows job's two sit, so each replaced them (`NO_MAINTENANCE` put the two maintenance keys back and dropped the job's two). I read each; each is converted, none left: - `test_family.py`: `ALLOW_FILE_PROTOCOL` is now `allow_file_protocol()`; its two importers (`test_siblings_family_name.py`, `test_family_siblings_placement.py`) and the four uses in `test_family.py` call it. - `test_family_dry_run.py`: `NO_MAINTENANCE` is a tuple of entries, appended by `isolated()`. - `test_non_utf8_git_reads.py`: `QUOTE_PATH_OFF` is a tuple of entries, which `tool_env()` appends; `user_name()` appends too. - `test_adopt_empty_symlinks.py`: `SYMLINKS_OFF` is `symlinks_off()`. - `test_adopt_root_symlinks.py`: `SYMLINKS_OFF` and `SYMLINKS_ON` are gone; the four tests that set the first call `links_as_files` and the two that set the second call `told_git`, which already appended. - one-off environments: `test_scaffold_reuse_seeded_legs.py`, `test_adopt_split_branch.py`, `test_scaffold_free_text_values.py`, `test_adopt_plan_yaml_quoting.py`, `test_setup_project_py.py`, `test_setup_sh.py`, `test_doctor_next_is_a_command.py`. - The two tests that build a held environment on purpose to see it kept (`test_adopt_git_reads.py`, `test_clone_just_pushed.py`) now build it with `appended_to_command_scope({}, ...)`. Measured with a probe on a throwaway bare remote after a push (18 loose objects): the hand-built options and `clone_just_pushed`'s give the same clone, one pack and no loose object, with `upload-pack` and `pack-objects` in `GIT_TRACE`, and `origin` keeps the plain path. A plain local clone of the same remote keeps all 18 loose objects. For a clone that is not recursive the helper's `insteadOf` rewrite is inert, because no submodule is cloned; the helper adds one place for the options, not a different read. - `test_bump_leg_dry_run_truth.py:98`, the private bare copy of the session's spec remote: a session remote. `cloned_just_pushed(..., bare=True)`; the fixture now asserts the copy is bare. - `test_bump_leg_dry_run_truth.py:110`, `private_work`, a clone of that private remote, which tests push tags into: `cloned_just_pushed(..., recurse=False)`. - `test_doctor_legs_members_next.py:1828` and `:1929`, `Ghost`, a clone of the first member's checkout in the test's own holder copy: not a remote, and the hand-built `-c protocol.file.allow=always` was dead weight on a direct clone. Both call `clone_member`, one helper over `cloned_just_pushed(..., recurse=False)` that asserts the legs stay empty. - `test_family_bump_to_checks.py:235`, a clone of `a_private_remote`, a bare repository filled by a push: `cloned_just_pushed(remote, work, "main", recurse=False, env=env)`. - `test_doctor_pin_restore_merges.py:714` is **left as it is**: a `--filter=blob:none` clone of a file URI. The helper takes a path, and has no way to pass `--filter`. - `test_family_dry_run.py`: the comment on `NO_MAINTENANCE` says what the entries are for, the gits `isolated()` hands an environment to, whose global file `excluding()` replaces, and that the session fixture covers the module's own setup commits, which pass no `env=`. - `tests/conftest.py`: the two i645 replacements, "repacks once its estimate, taken from `objects/17` alone, is above 256 loose objects, so two loose objects there are enough", and the `objects changed` sentence. **#713's body holds the matching sentence and needs the same two replacements; I cannot edit a body.** `tests/test_no_auto_maintenance.py` has 17 new cases (28 in the file, 11 before): - `test_a_pasted_line_keeps_the_entries_the_environment_holds_at_the_call`: the environment holds two entries as the Windows job does, then the two settings as the session fixture appends them, set after the doctor tests were imported. `git config --show-origin --list` through `paste()` lists the four, in order, then `protocol.file.allow`, and reads both settings from the session's global file. - `test_no_test_sets_a_git_config_count_of_its_own`: reads every `tests/*.py` but `conftest.py` with `ast` and fails a `GIT_CONFIG_COUNT` set to a number (dict, keyword, `setenv`/`setdefault`/`putenv`, subscript) and an `appended_to_command_scope` call handed a dict written out. Three files are allowed the ones that build a held environment on purpose, with a count each in `OWN_COUNTS`. - 15 cases that hold the scan itself: seven spellings it must see, eight it must leave (`env.get("GIT_CONFIG_COUNT", "0")` is a read). Runs at the head `b6e15591` through `run9.sh` (the real `gh` unreachable), all passed, none skipped: - `test_no_auto_maintenance.py`, `test_doctor_legs_members_next.py`, `test_doctor_next_is_a_command.py`: 208 passed. - `test_clone_just_pushed.py` and `test_doctor_validator_rows.py` (it imports the doctor file), with those three: 256 passed, run one commit earlier (the later commit only moved two blank lines and reworded one failure message). - The sweep files: `test_adopt_git_reads.py`, `test_adopt_nested_attributes.py`, `test_adopt_root_symlinks.py`, `test_adopt_ignored_shape_files.py`, `test_bootstrap_after_bump.py`, `test_family_bump_remove_rollback.py`, `test_adopt_empty_symlinks.py`, `test_adopt_split_branch.py`: 445 passed. - `test_non_utf8_git_reads.py`, `test_family.py`, `test_siblings_family_name.py`, `test_family_siblings_placement.py`, `test_scaffold_reuse_seeded_legs.py`, `test_scaffold_free_text_values.py`, `test_adopt_plan_yaml_quoting.py`: 999 passed. - `test_family_dry_run.py`, the five files that import its `isolated` (`test_family_bump_to_a_tag.py`, `test_family_bump_to_checks.py`, `test_family_bump_to_local_only.py`, `test_family_refuse_up_front.py`, `test_family_remove_gitmodules.py`) and `test_bump_leg_dry_run_truth.py`: 216 passed. - `test_setup_project_py.py`, `test_setup_sh.py` and their importers (`test_adopt_shape_pin_and_work_dir.py`, `test_dry_run_asks_remotes.py`, `test_clone_url_bytes.py`, `test_setup_by_hand_commands.py`): 313 passed. - `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_doctor_pin_restore_merges.py`, `test_git_helper_identity.py`: 172 passed. `render-cli-reference.py --check` is ok. **On the base (`deb029c9`)**, with the new `test_no_auto_maintenance.py` copied in: 2 of the 17 fail, the paste case and the scan (it finds 12 files). The other 15 hold the scan itself and pass on any tree. Each of the new hunks was broken in a throwaway and run against its tests: - `paste_env`: a literal count of 1, a snapshot taken at import, and `appended_to_command_scope({}, ...)`: each fails the paste case, and the first and third the scan too. - The 18 sites that call `appended_to_command_scope(os.environ, ...)` (or `env`), each with `{}` in place of the environment: each fails the scan. Sites: the six of line 2, `allow_file_protocol`, `isolated`, `tool_env`, `user_name` (non-UTF-8), `symlinks_off`, `git_reads_github_from`, `github_at`, `user_name` (free text), `test_adopt_plan_yaml_quoting.py`, the two setup tests, `test_doctor_next_is_a_command.py`. - The scan: eight changes (ignore dicts, keywords, `setenv` calls, subscripts, `appended_to_command_scope({}`; flag `os.environ`; flag a read; allow fewer in `OWN_COUNTS`) each fail a scan case. - The clone sites: dropping `bare=True` from the private remote, `recurse=False` from `clone_member`, and `"main"` from the checks clone each fail. Two **survive as equivalents**: `recurse=False` in `private_work` (the spec leg has no `.gitmodules`, so a recursive clone of it is the same clone) and `env=env` in the checks clone (a clone reads no config the isolated environment changes). - The two comments cannot be mutated. `git merge-tree --write-tree` of this head against `origin/main` (`974d4827`), #748 (`deb029c9`), #713 (`979bb394`) and every `fix/*` and `l3-*` head on origin (46 refs): - **Clean:** #748, #713, and 24 of the 46 heads: 21 `fix/*` heads (every lane-5 PR but #723 and #756, and most of lane 4's) and `l3-677`, `l3-678`, `l3-rehearsal-v1.1.0`. - **`origin/main`: one file, `tests/test_adopt_empty_symlinks.py`** (four hunks), against #388's landing (`5c990708`). The parent #748 merges clean, so this arrives with the retarget. Resolve by keeping main's lines and my `symlinks_off()`. Two more spots need the same change and git does not flag them: - `tests/test_adopt_empty_symlinks.py::test_the_link_given_a_target_adopts_end_to_end` uses `SYMLINKS_OFF`, which this change removes: give it the `off` parametrization. - `tests/test_adopt_reencoded_files.py::configure`, the seventh hand-built append, goes to `appended_to_command_scope(os.environ, pairs)`. - `tests/test_family_head_lost_gitmodules.py::on_disk` (on main) is one more hand-built append: `{**env, **appended_to_command_scope(env, [("core.ignorecase", ignorecase)])}`. The scan does not read for hand-built appends, so it is not required, but it is the same thing. I made that merge in a scratch tree (main at `bf1f099b`), resolved it as above, and ran the files this change touches that main also changed, with `test_adopt_empty_symlinks.py`, `test_adopt_reencoded_files.py`, `test_family_head_lost_gitmodules.py`, `test_no_auto_maintenance.py` and the files that import `isolated` or the doctor helpers: 653 and 245 passed, in two runs. The scan finds no other file of main's with a count set by hand. - **#723 (`fix/663-non-utf8-name-tests`):** `tests/test_non_utf8_git_reads.py`, the `from conftest import` block, where both add names. - **`l3-732`, `l3-733`:** `tests/test_doctor_legs_members_next.py` (the `from conftest import` block) and `tests/test_adopt_empty_symlinks.py`. - **19 more** (`fix/664`, `fix/729` (#756) and 17 `l3-*` heads): `tests/test_adopt_empty_symlinks.py` only, because they carry main's #388 as main does. - **Not a textual conflict, but it breaks on merge:** `l3-785-non-ascii-paths` imports `PASTE_ENV` (`tests/test_doctor_non_ascii_paths.py:47` and `:223`); it must call `paste_env()`. #812 (`fix/693-local-remote-dir`) landed first, with `tests/test_local_remote_dir_urls.py` importing `ALLOW_FILE_PROTOCOL` (`:51`, `:349`). This PR's retarget merge makes it call `allow_file_protocol()` (rv818 MUST 1). - A pasted `git submodule update --init` still clones a local path: `paste()` takes no remotes, so no `insteadOf` rewrite reaches it (rv662's "build it from `read_through_git`"). It runs under the session's maintenance settings now, which is what the issue asks. - Three more appends built by hand beside `appended_to_command_scope`, in files this base does not hold: `tests/test_family_head_lost_gitmodules.py:97` (main), `tests/test_family_bootstrap_gate_order.py:135` (#682), `tests/test_family_add_spellings_remedies.py:137` (#767). The scan reads for counts set to a number, not for appends worked out by hand. Review: lane 2's independent review (issuecomment-6236874291) and its re-verification of eaa72d9, READY (issuecomment-6238781272); its new SHOULD is taken in #848, and 4a553e1 merges main over eaa72d9. CI (Brett Heap's ruling 27): H0 is the head 4a553e1 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base fc86720; and the local gate passed on its predicted squash tree 9d6c099 over main d8b8eb3: the adopt_empty_symlinks, adopt_git_reads, adopt_ignored_shape_files, adopt_nested_attributes, adopt_plan_yaml_quoting, adopt_reencoded_files, adopt_root_symlinks, adopt_split_branch, bootstrap_after_bump, bump_leg_dry_run_truth, clone_just_pushed, doctor_legs_members_next, doctor_next_is_a_command, family, family_bump_remove_rollback, family_bump_to_checks, family_dry_run, family_siblings_placement, local_remote_dir_urls, no_auto_maintenance, non_utf8_git_reads, scaffold_free_text_values, scaffold_reuse_seeded_legs, setup_project_py, setup_sh, siblings_family_name, repo_hygiene, agents_md_codes, cli_reference sets, 2359 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green". Closes #758 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…even refusals and bump-leg's root name each path as `shown_path` names 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 (#766) (#814) #692 (#731) spelled the folder refusals with `shown_source`, and #672 (#746) spelled each dirty file's name; the paths beside them still printed as they were. Measured with a stub `gh`: a `--work-dir` or `--into` holding a line break and then `WARNING forged-line: pwned` printed that line as a line of `init`'s plan, on a dry run and on a run refused after it; each refusal below printed it as a line of its own; and an ESC reached the terminal raw. Each of these now names its path as `repo_shape.shown_path` does: as it is, and as Python's `repr` spells it where a character in it is not printable. A path `str.isprintable` accepts is named byte for byte, so no existing test row changed. - `scripts/family.py`: the plan's `folder` and `landing` rows; `family-holder-detached` and `family-on-tracking-branch` (the holder); `family-landing-in-standard` (the holder, the checkout, and where `--into ..` lands); `family-holder-already-here` (`--into` and the holder in it); the detail of `family-index-locked` (the lock); and `family-remote-exists` for a local remote. - `scaffold-project.py`: `scaffold-remote-exists` for a local remote. - `scripts/bump-leg.py`: `bump-leg-root-dirty`, `bump-leg-root-detached`, `bump-leg-on-tracking-branch` and the dry run's `the real run needs a branch` line (the root). A GitHub `<org>/<name>` is named as it is. The commands a remedy prints for a person to paste (`switch -c`, `add`, `rm -f`, and the `switch -c` line of `add`/`bump`/`remove --dry-run`) are quoted for a shell as they were, and still hold such a name raw inside the quotes. The docstrings of `_checked_branch` and `_checked_index_unlocked` say so; the follow-ups are on #459 and #458. The new rows hold a path with an ESC and one with a line break then `WARNING forged-line: pwned`, POSIX only, skipped where the disk refuses the name. The rows for `family-holder-already-here` and the bump-leg branch refusals also hold a printable name, named unquoted, byte for byte. `tests/conftest.py` keeps the names and helpers they share, #731's among them. `family-remote-exists` and `scaffold-remote-exists` cannot be reached from the command line with such a name (`*-unwritable-value` refuses it first, already spelled), so their rows ask the question in process. 34 new tests: 30 fail on the code before the change (the printable rows pass by design), and each of 21 one-line mutants fails at least one. Landed after #731 and #746, on which it was stacked. Closes #766 Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lper whether a name that is not UTF-8 can be made and skip on its two answers only, and three tests skip where an ASCII file name encoding cannot carry their text (#757) (#806) Against main. #723 landed as `bad05d2`, and this branch is restacked onto main as `76ae6419`: it merges #723's final head `4cdb427c`, then main, taking this branch's side in `tests/test_non_utf8_git_reads.py`, the one file #723 also changed. Over main it carries exactly its own change: `git diff 6ba8c5d 6b5e907` and `git diff origin/main 76ae641` add and remove the same lines (7 files, +408/-45). Tests only. No tool, template, pinned file, README, handbook, `AGENTS.md` or `docs/cli.md` changes (`render-cli-reference.py --check`: `[ok] docs/cli.md is what the generator produces`). Seven test files change, one of them new. **No Windows run and no macOS run was made.** CI is Linux only since #597. What Windows and macOS answer is emulated (see the tests below), and the emulation holds the skip decisions, not a real disk. **Line 1: the callers ask the helper, and every other disk error is the test's own.** Four tests and `directory_named` each made the directory themselves and turned any `OSError` into `pytest.skip`, beside `skip_where_a_name_cannot_be_not_utf_8` from #723. That helper skips on the two answers that mean "this system cannot carry the name" (Windows' `os.fsencode` first, macOS's EILSEQ second) and raises the rest. So a full disk or a `tmp_path` this user cannot write in read as SKIPPED in those five and as a failure in the helper's own tests, and no run fails on a skip. - `tests/test_stdout_encoding.py`, `tests/test_adopt_git_reads.py`, `tests/test_adopt_template_values.py` and the directory test of `tests/test_adopt_plan_escapes.py` now call the helper first. The directory they make after the probe (`b"b\xffd"`, `caf` and byte 0xE9) is no longer guarded. - Each test keeps its own second skip: `git will not work under <name>` in the three adopt tests, and `this filesystem rewrote a name that is not UTF-8` in the stdout test. - The file twin, `test_a_leg_extracts_a_path_that_is_not_utf8` in `tests/test_adopt_plan_escapes.py`, asked the same question of a FILE (`caf` and byte 0xE9 plus `.txt`, `write_bytes`, `unlink`). It calls the helper too and no longer writes a probe file of its own. I chose the helper over `make_file`'s skip because `make_file` also skips on any `OSError` of the write (see the candidates), and because the helper's probe is a name that is not UTF-8 under the same directory, which macOS refuses for a file and a directory alike. The file itself is made later, by git, in `filter-repo`'s clone. - `directory_named` in `tests/test_clone_url_bytes.py` is split. `skip_where_this_system_cannot_name(tmp_path, raw)` asks the helper for a name that is not UTF-8 (`raw.decode("utf-8")` fails) and keeps `directory_named`'s own Windows skip for a control character (a byte below 0x20), which Windows refuses in any file name. `directory_named` then makes the directory with an unguarded `os.mkdir`. **The Windows half of #459's line (R433 O5, issuecomment 6214823552), decided with line 1.** The old `directory_named` skipped every row on Windows, the `bidi-override` row (the name U+202E plus `evil`, as UTF-8) included, though NTFS holds U+202E. Now the bidi row is not skipped on Windows: the name is valid UTF-8 and holds no control character. **This is reasoned and emulated, not run.** If a Windows run finds the row's other assertions (a refusal text, `str(bad.resolve())`, `os.listdir`) fail there for a reason that is not a defect, the fix is one more skip in `skip_where_this_system_cannot_name`. **Line 2: the `make_file` test under an ASCII file name encoding.** `test_make_file_writes_a_name_that_is_utf8_where_fsencode_makes_no_byte` asserted `skip_message(make_file, ...) is None`, and `make_file` cannot write `café.yml` where the encoding is ASCII, so the assert failed. The test now asks `skip_where_the_environment_cannot_carry(UTF8)` first, before it swaps in the Windows `os.fsencode`, so it skips for the encoding and for nothing else. The assert is unchanged, so a write refused with EACCES or ENOSPC still fails the test. **Beyond the issue's measured set.** The issue measured `1 failed, 6 passed, 2 skipped` under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0` on the `make_file` tests. Its Verify asks that the WHOLE file `tests/test_non_utf8_git_reads.py` pass under that environment, and on `6ba8c5d2` it gives `6 failed, 40 passed, 6 skipped`. The other five failures hold no defect either: `subprocess` puts each environment value through `os.fsencode`, which raises `UnicodeEncodeError` for `git config user.name` of an accented name, a U+200C name or a U+202E name, in the test and before the tool runs. They are `test_a_user_name_that_is_text_still_initialises[accent-dry-run]`, `[accent-real]`, `[zwnj-dry-run]`, `[zwnj-real]` and `test_every_other_name_no_file_can_hold_is_refused_the_same_way[u202e]`. Both tests now call `skip_where_the_environment_cannot_carry(value)`, which skips where `os.fsencode(value)` raises `UnicodeEncodeError` (the `make_file` test above calls it too, with the file name), and says so as "this system's file name encoding (...) cannot carry '<value>'". ASCII names, and a lone surrogate that `os.fsencode` gives back as its byte, are not skipped. On Windows and under a UTF-8 file name encoding nothing skips that did not before. Lane 1's review of `41849725` asked for three fixes and a nit. All four are in one commit, in `tests/test_non_utf8_git_reads.py` and `tests/test_non_utf8_name_callers.py`. 1. **The `make_file` test skipped on a write the disk refused.** `make_file` skips on any `OSError` of its write with a reason that contains "filesystem", so the first version of the test, which skipped on that reason, ended SKIPPED when `write_bytes` raised EACCES or ENOSPC for the plain UTF-8 name, and the reason it gave was false. The test now asks `skip_where_the_environment_cannot_carry(UTF8)` first and keeps its strict assert. The helper's reason no longer ends "in an environment", and the callers file reads "file name encoding". The ASCII row patches `os.fsencode`, and two rows make `Path.write_bytes` raise EACCES and ENOSPC and expect the test to FAIL. The broader narrowing of `make_file` stays a candidate below. 2. **Five own-directory rows failed on a disk that refuses a name that is not UTF-8** (macOS's APFS, a ZFS with `utf8only`): they let the probe through to the real disk, and the helper skipped. `a_disk_that_takes_the_probe` asks the real disk first and only those rows use it. Measured with a plugin that makes `os.mkdir` answer EILSEQ: `31 passed, 5 skipped` (without the fixture, `5 failed, 31 passed`). 3. **S5778 (two places).** `fail_where_the_caller_ends` holds the `pytest.fail`, so each `pytest.raises` block holds one call, as does the new disk-error row. 4. **The Windows rows' names.** The byte and control-character rows now call `directory_named` itself (chosen over renaming), so its order, asking before it makes anything, is held. The bidi row asks the decision function and is named for it. A new file, so it stays clear of the open branches that edit the five. Each caller is called as a plain function with the disk answering as it would on a system CI does not have: | group | what the disk does | the caller must | tests | |---|---|---|---| | macOS | the probe `probe` + U+DCFF is refused with EILSEQ | skip, on the helper's reason | 6 | | a folder nobody can write in | the probe is refused with EACCES | fail with `PermissionError`, never skip | 6 | | the test's own directory | the probe passes, the test's own directory is refused with EACCES. These rows are skipped where the real disk refuses the probe (EILSEQ): a fixture asks it first | fail with `PermissionError`, where it skipped | 5 + 1 (the fixture asked of a disk made to answer EILSEQ) | | Windows (`os.name` is `nt`, `os.fsencode` gives a surrogate three bytes) | nothing is made | `directory_named` skips for byte 0xE9 (reason names `os.fsencode`) and for a line feed and an escape (names the control character), and does not skip for the bidi override | 4 | | a system that is not Windows | the disk takes it | `directory_named` makes a directory named with a line feed or an escape | 2 | | ASCII file name encoding | `os.fsencode` is ASCII with `surrogateescape` | the `make_file` test and the two environment tests skip, on "file name encoding"; the helper skips no text that is carried | 1 + 3 + 4 + 2 | | a write the disk refuses | `Path.write_bytes` raises EACCES or ENOSPC for the plain UTF-8 name | the `make_file` test still FAILS on its assert, and does not end SKIPPED | 2 | The six callers are the four tests, `directory_named` and the file twin (the last has no directory of its own, so it is not in the own-directory group). The Windows rows for a byte and a control character call `directory_named` itself, so that it is held to asking before it makes anything; the bidi row asks `skip_where_this_system_cannot_name`, because `directory_named` goes on to make a `Path`, which under an emulated `os.name` of `nt` is a `WindowsPath` that Linux cannot make. The "carried" rows (ASCII text, a lone surrogate that is a byte, a line feed, an escape) hold that the environment skip is not wider than it should be. All with a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR` and `/usr/bin/python3`, at head `6b5e9074`. Per file: `test_stdout_encoding.py` 48, `test_adopt_git_reads.py` 21, `test_adopt_template_values.py` 78, `test_adopt_plan_escapes.py` 59, `test_clone_url_bytes.py` 30, `test_non_utf8_git_reads.py` 52, `test_non_utf8_name_helpers.py` 18, `test_non_utf8_name_callers.py` 36, and the four files that import `test_stdout_encoding.py` (`test_bootstrap_branch_bytes.py` 61, `test_bootstrap_spelling.py` 205, `test_siblings_family_name.py` 150, `test_validator_child_encoding.py` 55). - **The twelve files above:** `811 passed, 2 skipped in 295.42s`. Both skips are the same two as on the base and have nothing to do with this change: `test_stdout_encoding.py:285` ("the default this is about is Windows' own") and `test_adopt_template_values.py:224` (`git config user.name` here is not read from `GIT_CONFIG_GLOBAL`). - **The Verify's C locale**, `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`, `tests/test_non_utf8_git_reads.py` and the new file: `74 passed, 14 skipped in 20.01s`. The 14 are the 6 that skipped on the base, the 6 this change turns from failures into skips (the `make_file` test, two accent rows, two U+200C rows and the U+202E row), and the 2 disk-error rows of the new file, which need an encoding that can write the name. On the base the first file gives `6 failed, 40 passed, 6 skipped`. - **A disk that answers EILSEQ to a name that is not UTF-8** (a test plugin that makes `os.mkdir` answer so, as macOS's APFS does), the new file: `31 passed, 5 skipped`. The 5 are the own-directory rows, skipped by the fixture; without it they fail (mutant M9b). - **`tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`:** `146 passed in 31.39s`. The new file copied onto `6ba8c5d2` fails 33 of its 36 tests: `33 failed, 3 passed in 27.36s`. The decisive lines: the probe-refusal rows end `Failed: the caller ended without an error` (the base never asks the probe); the own-directory rows end `Failed: the caller ended SKIPPED: no directory here is named b'caf\xe9': PermissionError(13, 'Permission denied')` (stdout: `this filesystem holds no name that is not UTF-8: [Errno 13] Permission denied`; `directory_named`: `this disk cannot hold the name b'caf\xe9': [Errno 13] Permission denied`); the C-locale rows fail on `UnicodeEncodeError: 'ascii' codec can't encode character ...` and on the `make_file` assert. The 3 that pass on the base are the two disk-error rows (the base's `make_file` test is strict, so they guard against the first version of this change, which skipped: on `41849725` they and the ASCII row fail, `3 failed, 33 passed`) and the fixture row, which holds a fixture the new file defines. The seven existing files (the five, `test_non_utf8_git_reads.py` and `test_non_utf8_name_helpers.py`) pass on the base too (`304 passed, 2 skipped`). Each hunk reverted alone, in a throwaway clone at `6b5e9074`, new file only (36 tests). Every one is caught, by the test it is meant for, except where it says otherwise. | mutant | result | caught by | |---|---|---| | M1 / M2 / M3 / M4: the helper call removed from the stdout, git-reads, template-values and plan-escapes directory test | `2 failed, 34 passed` each | the EILSEQ row and the EACCES-on-the-probe row of that caller | | M1b / M2b / M3b / M4b: the same four with their own `mkdir` guarded by `except OSError: pytest.skip` again | `1 failed, 35 passed` each | the own-directory row of that caller | | M5: the file twin's helper call removed | `2 failed, 34 passed` | the EILSEQ and EACCES rows of `adopt-plan-escapes-file` | | M5b: the file twin's old broad write-and-unlink probe put back | `2 failed, 34 passed` | the same two | | M6a: the helper call removed from `skip_where_this_system_cannot_name` | `3 failed, 33 passed` | EILSEQ, EACCES and the Windows byte row | | M6b: `directory_named`'s own `os.mkdir` guarded again | `1 failed, 35 passed` | the own-directory row of `clone-url-bytes` | | M6c: the old blanket `os.name == "nt"` skip back | `1 failed, 35 passed` | `test_where_windows_holds_a_bidi_override_the_name_check_does_not_skip` | | M6d: the control-character test made `False` | `2 failed, 34 passed` | both Windows control-character rows | | M6e: `directory_named` asks after it makes the directory | `3 failed, 33 passed` | the Windows byte row and both control-character rows (new in the fix round: they call `directory_named` itself) | | M7: the `skip_where_the_environment_cannot_carry(UTF8)` line removed from the `make_file` test | `1 failed, 35 passed` | `test_make_files_utf8_name_test_skips_where_the_encoding_is_ascii` | | M7b: the broad `"filesystem" in reason` skip back in the `make_file` test, the encoding question kept | `2 failed, 34 passed` | `test_a_write_the_disk_refuses_still_fails_the_make_file_test[EACCES]` and `[ENOSPC]` (new) | | M8a: `skip_where_the_environment_cannot_carry` returns instead of skipping | `6 failed, 30 passed` | the `make_file` ASCII row, the three "cannot carry" rows and the two-test rows | | M8b / M8c: its call removed from the text test, or from the every-other-name test | `1 failed, 35 passed` each | the matching row of the two-test group | | M8d: its reason no longer says "file name encoding" | `6 failed, 30 passed` | the same six, on the new `"file name encoding" in reason` asserts | | M9a: the probe fixture `a_disk_that_takes_the_probe` asks nothing | `1 failed, 35 passed` | `test_the_fixture_that_guards_those_rows_asks_the_real_disk` (new) | | M9b: the own-directory rows use the system-only fixture again | `36 passed` on this disk; `5 failed, 31 passed` under a plugin that makes `os.mkdir` answer EILSEQ to a name that is not UTF-8 | **no committed test**: Linux's disk never answers EILSEQ. The probe run is the evidence, the five own-directory rows | | M10: `fail_where_the_caller_ends` never fails, with the stdout helper call also removed | `2 failed, 34 passed` | the same two rows as M1 (`DID NOT RAISE PermissionError` for the EACCES row). The helper is a refactor of what each row did inline; it has no behaviour of its own that Linux can change | `git merge-tree --write-tree HEAD <ref>` at `6b5e9074`, against origin/main (`bf1f099b`), against #723 (`6ba8c5d2`, my parent), against `fix/693-local-remote-dir` (`831d76c6`, the one open branch that edits `tests/test_clone_url_bytes.py`) and against every other wave-7 branch (local refs): all clean except two, which are inherited. At the first push (`41849725`) every `fix/*` and `l3-*` head on origin (24 + 22) was checked too, with the same result. - **`fix/672-bump-leg-branch-and-push-line` (#746, `a5d473ee`) conflicts in `tests/test_non_utf8_git_reads.py`, one region (lines 316-324 of the merged file). It is inherited, not mine:** `merge-tree 6ba8c5d a5d473e` (#723 alone against #746) conflicts in the same file and the same region, and my hunks are at lines 123-127, 497-513, 647 and 668. Whichever of #723 and #746 lands second needs the one conflict resolved by hand, and this branch follows #723. - **`fix/758-git-command-scope` (local `ceca042f`) conflicts in the same file, in the import block (lines 54-62 of the merged file).** Also inherited: `merge-tree 6ba8c5d ceca042f` conflicts in the same lines. - `fix/663-...` (#723), `fix/645-...` (#713) and `fix/662-...` (#748) touch `tests/conftest.py`, which this branch does not edit. `l3-785-non-ascii-paths` (`a1e2c9a8`) touches `tests/test_stdout_encoding.py` and merges clean. - **`make_file`'s own skip** (`tests/test_non_utf8_git_reads.py:100`) turns any `OSError` or `UnicodeError` of the write into a skip. It is the same defect as line 1, in the helper that #723 added, and the issue names it only as the model for the file twin and for line 2. See the candidates. - **A real Windows or macOS run.** Not possible here. - **Two more tests make their own "a name that is not UTF-8, or skip" on any `OSError`:** `tests/test_cli_reference.py:632` (`test_a_checkout_whose_path_is_not_utf8_is_a_checkout`, `parent.mkdir()`, then `pytest.skip`) and `tests/test_doctor_validator_verdicts.py:317` (`here.mkdir(parents=True)`, then `pytest.skip`), at `6ba8c5d2`. Same fix as line 1: `skip_where_a_name_cannot_be_not_utf_8(tmp_path)`, then an unguarded `mkdir`. Neither is in #757's lines. - **`make_file` (`tests/test_non_utf8_git_reads.py:100`) skips on any `OSError` of `path.write_bytes`,** where the helper it calls first raises all but EILSEQ. Only the encoding error (a `UnicodeError` under an ASCII file name encoding) and EILSEQ are the system's answer; a full disk should fail the test. - **`tests/test_adopt_template_values.py:314`** (`test_execute_refuses_a_local_source_directory_holding_a_bidi_control`) makes a directory `U+202E` plus `caf` and skips on any `OSError` ("no directory here is named ..."). The name is text, not a byte that is not UTF-8, so the helper is not its question, but the broad skip hides a full disk the same way. Review: lane 1's re-verification of 6b5e907 is PR-READY (issuecomment-6233426675); 76ae641 restacks it onto main after #723 with the same changed lines. `a1498632` merges main `e62b5e17` after #818: its one conflict, `tests/test_adopt_git_reads.py`'s `from conftest import` line, takes both sides' names, and its -U0 changed lines against main equal the reviewed delta but for that line. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a149863 passed tests, tests-git-bash and SonarCloud; the head 22238b4 is a clean update-branch merge of main d6475f9, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 9f94a18 over main d6475f9: the adopt_git_reads, adopt_plan_escapes, adopt_template_values, clone_url_bytes, non_utf8_git_reads, non_utf8_name_callers, stdout_encoding, repo_hygiene, agents_md_codes, cli_reference sets, 486 passed, 2 skipped. 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 #757 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of `.gitmodules` back, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closing `mv` is guarded and leaves a checkout to delete by hand, and `--sparse` follows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) (#828) Written on #767's head `bbb961f8`, as `deb83e2e`. #767 has since landed as `98f14976`, a squash holding the tree of its final head `bd6d2afe`. The branch then merged `bd6d2afe` (clean) and main `40e678e6`, taking its own whole file for #767's four files; main changed none of the five files after the squash. The head is `d4e8c65a`, and its diff against main is this PR's five files. Its file contents equal w761's predicted restack `e607c304`, which passed 63 and 207 tests; that scratch tree had lost `scripts/family.py`'s executable bit, which the head keeps as main has it (100755). **Goal (#761): every command that the move back of a member's gitlink (#646) and the rename back of `members/` (#685) print can be typed as printed, in the state each describes, and leaves the holder passing its gate with each member where its row says.** The issue's four lines and rv685's three (issuecomment-6227887960) are all in `_members_spellings` and `_spellings_refusal` in `scripts/family.py`. Each state was first typed as printed through `add`, on the base and with each candidate direction, on git 2.43 on Linux. A disk that folds case is asked in process (`disk_as`), as #646 and #685 did; CI is Linux only. Lane 2's independent review, rv828, read `6cf02edf` and said NOT READY. This round makes two commits: - **`ef38aad0` merges main `bad05d21`.** It is clean as text (tree `75500249`, the tree the review tested). - **`ce0f2a5f` is the fix.** Main has since moved to `fc867207` (#746, `bump-leg.py` only). It merges cleanly, and it touches none of this PR's files. ### MUST 1: the merge with #684 broke two tests, and one state failed again when typed **What failed.** #684 (`fa04d2cd`) added `_members_file_left_in_head`. `_checked_mount_path` asks it before `_members_spellings`. A `members` file that HEAD and the index hold, with nothing on disk, is now #684's `member-path-unaddable`, and this PR's set-aside of that file is never reached. On the merged tree, the four test files gave 2 failed and 173 passed (`m0-merged.log`). The two failures were `test_an_entry_at_members_itself_is_set_aside_first`, for the file deleted from disk and the file a sparse checkout keeps off disk. For the sparse file, #684's printed `git rm --cached -- members` exits 1 on the merged code: `matched paths that exist outside of your sparse-checkout definition`. It fails the same way where the sparse checkout marks the file and where its rules leave it out (`p1-684-sparse-merged-base.log`). So the `members` half of Codex's P2 1 failed again once main was merged. **The fix.** `_members_file_left_in_head` now prints `git rm --cached --sparse -- members`, which needs git 2.34 or later, wherever a sparse checkout marks the file or its rules leave it out. This is the review's own choice. Its first line then says "as the sparse checkout leaves it out" in place of "its deletion not staged". Everywhere else #684's text is unchanged, byte for byte (`tests/test_family_holder_states.py`: 42 passed). Why `git rm --cached --sparse` and not the `git mv --sparse` that `_set_aside` gives an entry marked off disk: #684 allows a directory made by hand at `members`. `git rm --cached` touches no file, whatever is there. **The tests.** - New: `test_a_members_file_head_holds_is_taken_out_then_renamed_back`, three states: deleted from disk, kept off disk by a sparse checkout, and left out by the sparse rules. Each one types every step as printed: #684's removal and its commit, then the rename back that the next `add` prints. Then both members are where their rows say, the gate passes in every mode, and the same `add` goes through. - The rules state uses non-cone rules `/*`, `!/members` and `/members/`, which leave out the file `members` alone. `git sparse-checkout check-rules` confirms it: out, while `members/IRRS` stays in. This state carries `needs_check_rules`. - Changed: `test_an_entry_at_members_itself_is_set_aside_first` keeps only the cases that still reach the set-aside: a link, a file staged new and deleted from disk, and a file staged new and kept off disk by a sparse checkout. ### SHOULD 2: a clone of its own, or a link, found on disk (taken) New: `test_what_is_on_disk_and_no_checkout_keeps_its_mv`. It covers an untracked clone at `Members/X` (its `.git` a directory) and a link `Members/X -> ../members/IRRS`. Each gets #574's `mv -- Members/X <new name>`. Typed as printed, the `mv` moves it out whole, and the same `add` goes through. The review's surviving mutants R9 and R10 are now caught. ### SHOULD 3: an unmerged file at or under the row's path (stays) **Not fixed.** Setting such a file aside is not cheap: - `git mv` refuses a conflicted entry. - An unmerged file on disk at the row's path would still stand where the update checks the member out. - So it needs a remedy of its own, and possibly a refusal that comes first. The review's mutant R2b still survives (`mut/R2b.log`, 78 passed). This is a checklist candidate below. I could not read #840 to say whether it is one of #840's six. ### SHOULD 4: S3776 (taken) The parts this PR grew moved out of the two functions. The text is unchanged: every typed and compared test passes. - **`_spellings_refusal`** lost: - the set-aside part, now `_set_aside_part`; - the move back, now `_move_back_parts` and `_move_back_line`; - its closing sentences, now `_closing_sentences`; - the `each` closure, now `_each`. - **`_members_spellings`** lost: - the row bookkeeping, now `_rows_held`; - the cached sparse questions, now `_SparseAsked`; - the set-aside call, now `_in_the_way`. These counts come from my own AST estimate (`f828r2/cogc.py`), not from Sonar: | Function | main `bad05d21` | `6cf02edf` | `ce0f2a5f` | |---|---|---|---| | `_spellings_refusal` | 52 | 88 | 51 | | `_members_spellings` | 48 | 71 | 49 | Each new helper scores 13 or less: `_members_file_left_in_head` 13, `_set_aside` 12, `_move_back_line` 11, `_set_aside_part` 10, `_rows_held` 9, `_move_back_parts` 6, `_in_the_way` 5, `_closing_sentences` 0. ### NIT 5: surviving mutants R3 and R20 (taken) New: `test_only_a_file_or_link_in_a_moves_way_is_set_aside`, run in process. A file at `members` with no gitlink moved back is not set aside (R20). A gitlink at `members` is never set aside, since taking it out of the index would un-register what it records (R3). Both mutants are now caught. The unmerged file at `members` belongs to SHOULD 3's candidate. ### NIT 6: duplication (taken) The three staged set-aside tests are now one table, `STAGED_ASIDE`, under the first test's name, `test_what_is_set_aside_is_committed_with_the_move`. Its states are on disk, kept off disk by a sparse checkout, and deleted from disk. ### NIT 7: the shell dialect (followed) The guarded `mv` is now to be typed "in a POSIX shell (Git Bash on Windows)". This is the review's suggestion, and the same words `family.py` already uses for its other POSIX-only line (`rm -rf`). The text stays POSIX-only, per #532. No PowerShell form is printed, since the sentence before it tells a folding Windows disk to type nothing. ### Runs Every run went through `run9.sh`, with basetemps `v110/bt/f828r2-*` and logs in `v116/f828r2/`. The git is 2.43 unless the table says otherwise. | Log | Tree | Files | Result | |---|---|---|---| | `m0-merged.log` | the merge `ef38aad0` | the four test files this PR changes | 2 failed, 173 passed (MUST 1) | | `t1.log` | `ce0f2a5f`'s contents | the four test files this PR changes, and `test_family_holder_states.py` | 224 passed | | `g247.log` | `ce0f2a5f`, git 2.47.1 | `test_family_add_move_back_sections.py`, `test_family_add_member_variant.py`, `test_family_holder_states.py` | 120 passed | | `w1.log` | `ce0f2a5f`'s contents | the files that call the changed functions (`test_family_add_excludes.py`, `test_family_add_refusals.py`, `test_family_dry_run.py`, `test_family_head_lost_gitmodules.py`, `test_family_member_path.py`, `test_family_refuse_up_front.py`, `test_readme_windows_quoting_sentence.py`, `test_shape_doctor.py`, `test_validate_family_remedies_typed.py`, `test_validate_family_unnamed_mounts.py`) and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 959 passed, 3 skipped, 1 xfailed | | `w2a.log` and `w2b.log` | `ce0f2a5f` | the other 42 files that run `family.py` | 1173 passed, 20 skipped; 1364 passed, 1 skipped | The skips are environmental: - PowerShell lines run only on a Windows host. - One test needs a disk that ignores case. - Templates that quote a value skip some cases. CI's git 2.55 was not run here. ### Fails on the merged base These are the commit's two changed test files, run against `ef38aad0`'s `family.py` (`base-final-merged.log`): 53 failed, 25 passed. - **Behaviour.** The two sparse states of the new #684 test fail when typed: `git rm --cached -- members` exits 1. - **Text.** The other 51 failures are expected text: the closing sentence now says Git Bash, and #684's sparse line changed. - **Passing on the merged base:** five of the new cases. They pin behaviour that already held there: - #684's own deleted-from-disk state; - the clone and the link; - the two nothing-aside states. ### Mutants These ran through `mutate2.py` in a scratch clone of `ce0f2a5f`'s contents (`mut/summary.txt`, one log each). **19 of 20 are caught. R2b survives, as SHOULD 3 says.** | Mutant | What it undoes | Result | |---|---|---| | N1 | #684's `--sparse` not asked of the mark | caught | | N2 | #684's `--sparse` not asked of the rules | caught | | N3c | #684's removal without `--sparse`, the test expecting it | caught: `git rm --cached -- members` refused, `exist outside of your sparse-checkout definition` | | N4 | #684's first line unchanged for the sparse file | caught, 2 failed | | N5 | the closing sentence without "(Git Bash on Windows)" | caught, 4 failed | | R9 | a clone of its own taken for a checkout | caught: "Delete by hand" printed | | R10 | a link to a checkout taken for a checkout | caught | | R3 | a gitlink at `members` set aside | caught | | R20 | the set-aside asked where no gitlink is moved back | caught | | R2b | an unmerged file counted as in the way | **survives** (78 passed); SHOULD 3 | | M1, M7, M8, M10, M11c, M12c, M13c, M15, M17 | round 1's mutants, at the code's new places | each caught | | M18 | the move's commit blind to what is set aside (`_move_back_parts`) | caught | ### Merge checks `git merge-tree --write-tree` of `ce0f2a5f`: - **Clean:** main `fc867207`, `fix/684-e4-reftable-operation` `a3683127` and `fix/686-bump-reachability` `17bbbaba`. - **`fix/766-spelled-paths` `ef6db40d` (#814) conflicts** only in `tests/test_dry_run_parity.py` and `tests/test_non_utf8_git_reads.py`. This PR does not touch either file. The same conflict appears between #814 and main, as the review found. `scripts/family.py` merges cleanly. ### Checklist candidates - **An unmerged file at or under a row's path** (SHOULD 3). A member's gitlink at a variant then gets #574's remedy, which takes the gitlink away from its row. The same goes for an unmerged file at `members`. - **`validate-family.py`'s `_wrong_case_move`** (#747, `6626a3c5`) still prints the guard without the "nothing there" test, so its `mv` exits 1 where nothing is at the variant. The candidate is carried from round 1. Codex reviewed `d4e8c65a` (review 5558731894) and left two P2 findings. **Both hold.** Each was reproduced on `d4e8c65a` by typing the printed remedy, and each is fixed. A third defect in the same function turned up while testing the fix, and it is fixed too. Main is not merged; the branch's diff against main is still this PR's five files. ### P2 1: a sparse checkout decided whose the gitlink was (`scripts/family.py:3409`) **What failed.** `_members_spellings` dropped a row from `blocked` before building `owners` if a sparse checkout marked any of the row's files (`skip-worktree`) or its rules left one out. The member's own gitlink at the variant then stopped counting as the member's own, and got #574's remedy instead. The state: `git mv members/IRRS Members/IRRS`, committed, plus a committed `members/IRRS/notes`. Typed as printed on `d4e8c65a` in four states (marked on disk, marked off disk, left out by the rules on disk, left out off disk; `r1-p2-1-base.log`): - printed: `git mv -- Members/IRRS <new name>`, and commit the rename; - after typing it, `.gitmodules` said `submodule.members/IRRS.path renamed-1`, and HEAD held `members/IRRS` as a tree with no gitlink. The member was un-registered. The same filter applied to a file at `members` itself. After `git mv members Members`, with that file kept off disk by a sparse checkout, the file was not set aside. The move back's `git update-index` then failed with `error: 'members/IRRS' appears as both a file and as a directory`, after the `git rm` had already taken the variants out (`r1b-members-marked-base.log`). **The fix.** Both filters are gone, so the sparse checkout no longer decides whose the gitlink is. `_set_aside` now names each entry in the way by the same rule the index's own remedies use: | The entry in the way | Printed | Plain git, measured with 2.43 and 2.47.1 | |---|---|---| | marked, on disk | `git mv -- <entry> <new name>` | plain `git mv` takes it | | marked, off disk | `git mv --sparse -- <entry> <new name>`, which needs git 2.38 or later | plain `git mv` and `git rm --cached` refuse it, exit 1 | | left out by the rules, its bit cleared, on disk | `git mv --sparse -- <entry> <new name>` | plain `git mv` and `git rm --cached` refuse it, exit 1 | | left out by the rules, its bit cleared, off disk | `git rm -r --cached --sparse -- <entry>`, which needs git 2.34 or later | plain `git rm --cached` refuses it, exit 1; `git mv`, with or without `--sparse`, says `bad source`, exit 128 | Each refusal says `exist outside of your sparse-checkout definition`. The probe logs `probe1-243.log` and `probe1-247.log` match line for line. A new remedy, `ASIDE_SPARSE`, prints the `git mv --sparse` line. `ASIDE_OUT` takes `--sparse` where the rules leave its entry out, as `UNSTAGE` already did. ### P2 2: the closing `mv` failed where nothing was at the variant (`scripts/family.py:3931`) **What failed.** The guard was `[ X -ef Y ] || [ -e X/.git ] || mv -- X ../X-flat`. Where nothing is at `X`, both tests are false, and `mv` exits 1 with `mv: cannot stat 'Members/IRRS': No such file or directory`, so a pasted remedy stopped before the update. `typed_as_printed` hid this, because it skipped any `mv` whose source was absent. With only that helper changed to type every `mv` as printed, `d4e8c65a` failed four tests on that line (`r2-helper-only-base.log`): - `kept off disk by a sparse checkout` (Codex's `kept_off_disk`); - `nothing on disk in that spelling`; - `with nothing left to commit`; - `unmerged, with nothing in progress`. **The fix.** The guard now begins with a test for "nothing there": `[ ! -e X ] && [ ! -L X ] || [ X -ef Y ] || [ -e X/.git ] || mv -- X ../X-flat` Nothing there moves nothing and exits 0. A link there that points nowhere, which `-e` treats as nothing, is still moved, as the old guard moved it. Measured in dash with nothing there: the old guard exits 1 and the new one exits 0. With a link that points nowhere, the new guard exits 0 and moves the link. The sentence after the command now reads "which moves nothing where nothing is there, where the two names are one directory or where it holds a .git entry, a checkout". `typed_as_printed` now types every `mv`, guarded or not, exactly as printed, and each must exit 0. 13 typed lists in the three test files now include the guarded `mv` that the helper used to skip. ### Found on the way: a commit printed with nothing to commit Mutant M6 below first survived, and the reason was a defect already in `d4e8c65a`. `_set_aside` said a removal leaves a change to commit wherever HEAD holds anything at the entry's path. Take a staged `git mv members/IRRS Members/IRRS`, then a new file staged at `members/IRRS` and deleted from disk. HEAD holds the member's gitlink at that path, and the move puts the same gitlink back, so nothing is left to commit. The remedy still printed ", and commit that", and `git commit` failed with nothing to commit (`p2-base.log`, on `d4e8c65a`). Now HEAD's gitlink at a row the move puts back no longer counts. **This is outside Codex's two findings.** It is one expression in the function this round already changes. ### Tests `tests/test_family_add_move_back_sections.py` now has 34 cases. The new ones: - `test_a_blocker_a_sparse_checkout_marks_or_leaves_out_is_set_aside_too`, four states (P2 1). The two "left out" states use non-cone rules that leave out `members/IRRS/notes` alone, and carry `needs_check_rules`. The "marked, off disk" state skips below git 2.38. - `test_an_entry_at_members_itself_is_set_aside_first[a file a sparse checkout keeps off disk]` (P2 1, the file at `members`). - `test_what_a_sparse_checkout_keeps_off_disk_is_committed_with_the_move`: a staged new file, marked and off disk, is renamed with `git mv --sparse`, and the commit is printed. - `test_a_removal_where_head_records_the_gitlink_leaves_no_commit`: the defect found on the way. - `test_a_link_pointing_nowhere_at_the_variant_is_still_moved_out`, for the guard's `-L` test. It uses the layout where HEAD alone holds the variant. With the variant's gitlink still in the index, git 2.47.1's own `git status` refuses the link (`expected submodule path 'Members/IRRS' not to be a symbolic link`). The first version of this test failed that way on 2.47.1 (`g247.log`). Changed: - In `tests/test_family_add_member_variant.py`: `typed_as_printed`, `guarded()`, the closing sentence in `moved_back()`, and the typed lists. - In `tests/test_family_add_move_back_sections.py`: `set_aside()`, which now gives the `--sparse` forms. - In `tests/test_family_add_spellings_remedies.py`: one typed list. ### Runs Every run went through `run9.sh`, with the real `gh` unreachable and basetemps under `v110/bt/f828-*`. The logs are in `v116/f828/`. The git is 2.43 unless the table says otherwise. | Log | Tree | Files | Result | |---|---|---|---| | `r1.log` | the commit's contents, before the link test was reworked | the four test files this PR changes | 175 passed | | `r3.log` | `6cf02edf` | `test_family_add_move_back_sections.py` | 34 passed | | `g247.log` | as `r1.log`, git 2.47.1 | the sections file, `test_family_add_member_variant.py` | 70 passed, 1 failed (the link test's first layout, since reworked) | | `g247-link.log`, `g243-link.log` | `6cf02edf`'s contents | the reworked link test, git 2.47.1 and git 2.43 | 1 passed each | | `w1.log` | `6cf02edf`'s contents for every file it reads | the files that call the changed functions (`test_family_add_excludes.py`, `test_family_add_refusals.py`, `test_family_dry_run.py`, `test_family_head_lost_gitmodules.py`, `test_family_member_path.py`, `test_readme_windows_quoting_sentence.py`, `test_shape_doctor.py`, `test_validate_family_unnamed_mounts.py`) and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 862 passed, 3 skipped, 1 xfailed | | `w2a.log` | `6cf02edf` | 21 more files that run `family.py` | 1218 passed, 20 skipped | | `w2b.log` | `6cf02edf` | the other 21 files that run `family.py` | 1312 passed, 1 skipped | | `refresh-predicted.log` | `6cf02edf` merged with main `d66a9054` (a scratch tree, not pushed) | the four test files this PR changes | 175 passed | The skips are environmental: PowerShell lines on a Windows host only, a disk that ignores case, and templates that quote a value. ### Fails on d4e8c65 `base-final.log`: `d4e8c65a`'s `family.py` with this commit's tests gives 56 failed, 59 passed. All 8 new cases fail. The other 48 failures are tests whose expected text now includes the new guard; 4 of those also fail by behaviour with only the helper changed (`r2-helper-only-base.log`, above). ### Mutants `mutate.py`, in a scratch clone. Each mutant undoes one new hunk, runs the test named, and restores the file. Mutants marked "c" change the test's expected text to match, so the failure shows behaviour, not just text. All 20 are caught (`mut/summary.txt`, one log each). M1-M14, M16 and M17 ran on a tree that differs from `6cf02edf` only in docstrings. M15 and M12c ran on `6cf02edf`'s contents. | Mutant | What it undoes | Result | |---|---|---| | M1 | the sparse filter on `blocked` put back (P2 1) | caught, 4 failed: `git mv -- Members/IRRS <new name>` printed again | | M2 | the sparse filter on a file at `members` put back | caught, 1 failed | | M3, M3c | on disk, plain `git mv` though the rules leave it out | caught; M3c: `git mv` refused, `exist outside of your sparse-checkout definition` | | M4, M4c | off disk, taken out of the index though marked | caught, 2 failed; M4c: `git rm --cached` refused | | M5, M5c | no `--sparse` on the removal though the rules leave it out | caught; M5c: `git rm --cached` refused | | M6 | a rename aside off disk leaves no change to commit | caught by `test_what_a_sparse_checkout_keeps_off_disk_is_committed_with_the_move` | | M7 | the `git mv --sparse` part not printed | caught, 2 failed | | M8 | the removal's `--sparse` not printed | caught | | M9 | an entry set aside with `--sparse` listed among the variants | caught, 2 failed | | M10 | the set-aside part blind to `ASIDE_SPARSE` | caught, 2 failed | | M11c | the guard without `[ ! -e X ]` | caught: the empty directory is left at the variant, and the next `add` refuses it | | M12c | the guard without `[ ! -L X ]` | caught: the link is left at the variant | | M13c | the guard as `d4e8c65a` printed it | caught, 7 failed: `mv: cannot stat 'Members/IRRS'` | | M14 | the sentence without "where nothing is there" | caught, 4 failed | | M15 | `_set_aside` told nothing of the sparse checkout | caught, 3 failed | | M16 | `ASIDE_SPARSE` left out of `REMEDY_ORDER` | caught, 4 failed | | M17 | HEAD's gitlink at the row's path counted as a change to commit | caught by `test_a_removal_where_head_records_the_gitlink_leaves_no_commit` | ### Merge checks `git merge-tree --write-tree` of `6cf02edf`: **clean** against main `d66a9054` and against every open branch that changes `scripts/family.py`: - `fix/684-family-holder-states` `fdc90a6e` - `fix/686-bump-reachability` `cf5e64b3` - `fix/688-family-init-values` `6958dfbc` - `fix/689-next-push-refspec` `15c3b5ab` - `fix/766-spelled-paths` `ef6db40d` It is also clean against the branches updated during this round: `fix/674`, `712`, `762` and `768`, and `l3-733`. No other open branch changes the four test files. ### Left out - **`validate-family.py`'s `_wrong_case_move`** has the same guard and the same failure where nothing is at the variant: `mv` exits 1. It landed with #747 as `6626a3c5`. It is a pinned file, so the fix is for a follow-up, not this PR. - **The two "left out" tests use non-cone sparse rules** (`git sparse-checkout set --no-cone`). CI's git 2.55 was not run here; git 2.43 and 2.47.1 were. ### What was wrong `git mv members/IRRS Members/IRRS` moves the checkout, the gitlink and the section's `path`. The move back put the gitlink back at `members/IRRS` in the index alone, and left the section naming `Members/IRRS`. Typed as printed at bbb961f: - committed: `git submodule update --init --recursive` fails with `fatal: No url found for submodule path 'members/IRRS' in .gitmodules`. The gate exits 1 with `gitmodules-path-unnamed` in every mode, and `add` goes through. - staged: the same failure, and the gate refuses `member-uninitialized`, exit 2. - a fresh recursive clone of the committed state: the update checks nothing out at `members/IRRS`, and the gate exits 1. ### What changed - **Each section that gives a member another spelling of its row's path is pointed back.** `_sections_restored` names those sections on the member's move back (`Spelled.sections`). It reads them from the file that `_submodule_paths` reads, the work tree's, which is the one `git config -f` edits. The move's parenthesis gains, per section, `git config -f .gitmodules submodule.<name>.path <row path>`, then `git add -- .gitmodules`. - **The commit is printed only where one is left to make.** The move is a change to commit where HEAD's own `.gitmodules` gives the section another path (`_head_section_paths`, read with `git config --blob`). After a staged move, nothing differs from HEAD once the lines are typed, so no commit is printed; `git commit` would fail there with nothing to commit. - **`.gitmodules` is asked first wherever a section is pointed back**, not only beside a gitlink still in the index (`_checked_mount_path`). So `gitmodules-sparse` comes first where a sparse checkout marks the file, since `git config -f` would then write a new file holding one line. ### A defect found on the way In the staged state, a commit or an untracked file of its own in the checkout at `Members/IRRS` makes the move's `git rm -r --cached --ignore-unmatch -- Members/IRRS` refuse, exit 1: `the following file has staged content different from both the file and the HEAD`. It takes the gitlink with `-f`, which with `--cached` touches no file. The removal is now printed with `-f` where the index stages the variant at a commit HEAD does not record there and a checkout is on disk at it, with a clause saying what `-f` does. Everywhere else the line is the validator's word for word, as before. ### What was wrong After `git mv members/IRRS Members/IRRS`, a committed file at `members/IRRS`, or under it, made the gitlink at the variant look like a second gitlink, so it kept #574's remedy, `git mv -- Members/IRRS <new name>`. Typed as printed, that moves the member's gitlink and its section to `renamed-1`: the member is un-registered, the gate refuses, exit 2, and `add` goes through. In #646's own state (the gitlink moved in the index alone, the checkout still at `members/IRRS`), a file planted at `members/IRRS` got `git rm -r --cached -- Members/IRRS`, which takes the member's gitlink out. ### What changed Where the index holds only files and links at or under a row's path, each at stage 0, and none marked or left out by a sparse checkout, a gitlink at a variant is still the member's own. Each of those entries is set aside first (`ASIDE`, `ASIDE_OUT`), then the gitlink is moved back, in the same commit. An entry on disk as the index holds it is renamed with `git mv`; any other is taken out of the index, which touches no file. So the history never holds the member's gitlink beside the file, nor the file gone and the gitlink not back. The other order fails, measured. With a file at the row's path, the move back first replaced the file without a word, and the `git mv` of that path then took the member's gitlink away. With a file under it, the move back stopped at exit 128 (`appears as both a file and as a directory`). ### What was wrong After `git mv members Members`, a file or link at `members` was never named: planted, committed and deleted from disk, or a committed link `members -> Members`. Each `git update-index` of the move back failed with `'members/IRRS' appears as both a file and as a directory`, exit 128, after the `git rm` had taken the variants out, and the commit printed after it removed both members' gitlinks. ### What changed The same set-aside part names the entry at `members` wherever a gitlink is moved back. A link is renamed with `git mv`, as `_members_is_a_file` moves `members`; a file deleted from disk is taken out of the index. ### What was wrong The closing `mv -- Members/IRRS ../Members-IRRS` moved out of the holder a checkout that `git mv` or `git submodule add` made. Its `.git` file names its store by a relative path (`../../.git/modules/members/IRRS`), which the move leaves pointing nowhere: #681's N-1, which #747 fixed in the validator. ### What changed - **Each closing `mv` is #747's guarded form**, typed in a POSIX shell: ``[ Members/IRRS -ef members/IRRS ] || [ -e Members/IRRS/.git ] || mv -- Members/IRRS ../Members-IRRS``. It moves nothing where the disk takes the two names for one directory (macOS, Windows, WSL's /mnt/c), and it never moves a checkout. The text is the same on every disk, and the old pair of sentences ("on a case-sensitive disk ...", "on macOS and Windows ...") is gone. - **The text says where a checkout's commits are kept**: in the holder's `.git/modules/`, whatever becomes of the checkout. It names `git -C <variant> status` to read what is not committed, and says to delete such a checkout by hand once nothing only it holds is needed. A clone of its own (`.git` a directory) is moved out whole, by hand, and `git -C <variant> log --oneline HEAD --branches --not --remotes` lists what it has not pushed. - **The update comes last.** Measured: a checkout at the variant that `git mv` made shares the member's store, and `git submodule update --init --recursive`, checking the member out at the row's path, points that store's `core.worktree` there. `git -C Members/IRRS status` then printed the row's clean checkout, not the variant's file that was never committed. Read before the update, it names that file. This is the one order that differs from the validator's `_wrong_case_move`. - **A checkout that `add` finds on disk and untracked later is never moved.** `_moved_or_kept` tells it apart (a directory, not a link, holding a `.git` FILE), and a new remedy, `CHECKOUT`, says to delete it by hand once nothing only it holds is needed. A clone of its own keeps the `mv`, which moves it whole. - `MOVE_BACK`, for a `members/` renamed by hand, is unchanged. Measured, `mv -- Members members` keeps each checkout working, since each relative `.git` file then points where it did. ### What was wrong `git mv members Members`, staged, then `git rm --cached Members/IRSS`. HEAD records `members/IRSS` and the index holds it nowhere, so nothing named it. The rename back was printed. Typed, it left IRSS's section at `Members/IRSS`, `add` committed that `.gitmodules`, and the gate refused `member-commit-unresolvable`, exit 2. With the rename committed instead, the move back from HEAD was printed, and it left both sections at `Members/` with (e)'s gap. ### What changed A row is found `IN_GITMODULES` ("in .gitmodules, its gitlink's removal staged") where HEAD records its gitlink at the row's path, the index holds nothing at or under it, and a section gives another spelling of it. The line that moves the other gitlinks back puts it back too, at the commit HEAD records, and its section is pointed back. The rename back is not printed beside it (`_renamed_with_git`), since it would leave that section naming `Members/`. The staged state takes `-f`, since the checkouts are on disk, and no commit; the committed state takes the commit. ### What was wrong `git mv members Members`, committed, and a committed `MEMBERS/notes`. The index held two spellings, so `_renamed_with_git` gave up and the move back was printed, with (e)'s section gap: the update failed, and the gate exited 1. ### What changed The spelling to rename back is the one that holds the members' own gitlinks, and there must be exactly one. What the index holds under a third spelling keeps its own remedy, which runs first in `REMEDY_ORDER`. On Linux that is `git mv -- MEMBERS/notes <new name>`. On a folding disk, where the three spellings are one directory and `MEMBERS` is never on disk in its own spelling, it is `git rm -r --cached -- MEMBERS/notes`, which touches no file, and the file goes back with the rest. The rename back replaces only the entries under its own spelling. ### What was wrong A cone-mode sparse checkout leaves `Members/` out, and an entry there has its skip-worktree bit cleared. `add` printed plain `git mv` for it on disk and plain `git rm --cached` off disk. git 2.43 asks the sparse rules, not the bit. Measured with plain git: - plain `git mv` refuses such an entry on disk, file or directory (`exist outside of your sparse-checkout definition`, exit 1), and `git mv --sparse` takes it; - plain `git rm --cached` refuses one off disk the same way, and `--sparse` takes it; - the move back's `git rm --cached --ignore-unmatch` passes over such a gitlink with exit 0, as it does one marked; - `git mv` of a directory holding one refuses, exit 1. ### What changed Each `--sparse` is printed where the rules leave the entry out, as well as where the bit marks it. The rules are asked once, and only where they matter (`_sparse_rules_exclude`, which asks `git sparse-checkout check-rules`). So an entry on disk gets `git mv --sparse`, one off disk gets `git rm -r --cached --sparse` with the git it needs, the move back's removal gets `--sparse`, and no rename back is printed where an entry under the spelling is left out. A git that cannot answer `check-rules` leaves the bit alone to decide, as before. The `RENAME_SPARSE` wording now says "what the sparse checkout leaves out", in place of "keeps off disk". **New file `tests/test_family_add_move_back_sections.py`, 26 cases.** Each typing test types every printed command as printed with `typed_as_printed`. That helper now runs each guarded `mv` through `sh -c`, types each `git config` line, and does each hand step the text names at its place. Each test then checks that every member is registered where its row says, that the gate passes in every mode, and that the same `add` goes through with the gate passing after. - (e): committed and staged, with a commit and an uncommitted file in the checkout, each kept (the commit in the store, the file read by `git -C Members/IRRS status` before the checkout is deleted); a fresh recursive clone; both disks in process; and `gitmodules-sparse` named first. - (g): a file at the row's path and one under it, renamed aside; and #646's state with the checkout at the row's path, the file taken out of the index. - (h): a committed file `members` deleted from disk, and a committed link `members -> Members`. - Line 4: the hand step skipped, which shows `git -C Members/IRRS status` printing nothing after the update though the uncommitted file is there. The next `add` names the checkout to delete by hand, with no `mv`. - rv685's candidate 1, staged and committed; the third spelling, typed on Linux and asked in process on both disks; the sparse rules, for a file on disk, a file off disk and a member's own gitlink on disk, for a gitlink renamed with `--sparse`, and for the rename back withheld. These sparse tests need `check-rules` and skip on a git without it. - What each rule turns on: a section that HEAD moved makes the commit; what is set aside makes the commit; both removals staged with no `.gitmodules` on disk; and a checkout under an index entry stays the index's to name. **Changed tests:** - `tests/test_family_add_member_variant.py`: `moved_back` builds the new text, with sections, staged removals, `-f`, and the five closing sentences. It gains `guarded`, `point_back` and `typed_in_sh`. Its layouts type the guarded `mv`, the hand step and the update last. The table of what is the member's own now expects the move back after the set-aside part for (g)'s two states. - `tests/test_family_add_spellings_remedies.py`: the folding test's Linux branch expects both sections, and "another spelling in the index too" leaves the not-renamed-back table, since that state is renamed back now. - `tests/test_family_members_case_variants.py`: the `RENAME_SPARSE` wording, and the two new fields in a full-tuple comparison. Every run went through `run9.sh` (the real `gh` unreachable, the git identity blanked), with basetemps under `v110/bt/w761-*`, on git 2.43 and Python 3 on Linux. | Run | Tree | Files | Result | |---|---|---|---| | `batchA.log` | before the last three small edits | 34 more of the files that import or run `family.py` (`test_family.py`, `test_family_refuse_up_front.py`, `test_non_utf8_git_reads.py` and the rest) | 1582 passed, 20 skipped | | `batchB.log` | the same | `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_shape_doctor.py`, `test_setup_project_py.py`, `test_bump_leg.py`, the four `update_shape` files and 8 more | 1716 passed, 4 skipped | | `r2.log` | the same | the issue's Verify files: `test_family_add_member_variant.py`, `test_family_add_spellings_remedies.py`, `test_family_members_case_variants.py` | 141 passed | | `r3.log` | the same | `test_family_add_refusals.py`, `test_family_head_lost_gitmodules.py` | 103 passed | | `f1b.log` | this commit, `PYTHONHASHSEED=0` | the new file, `test_family_add_member_variant.py` | 63 passed | | `f2b.log` | this commit | `test_family_add_spellings_remedies.py`, `test_family_members_case_variants.py`, `test_repo_hygiene.py` | 181 passed | | `s1.log` | this commit, `PYTHONHASHSEED=1` | the new file | 26 passed | | `s2.log` | this commit, `PYTHONHASHSEED=2` | the new file's four order-sensitive cases | 4 passed | | `base2.log` | `bbb961f8`'s `family.py`, this commit's tests | the new file | 25 failed, 1 passed | | `rs1.log` | the predicted restack onto main (tree `e607c304`) | the new file, `test_family_add_member_variant.py` | 63 passed | | `rs2.log` | the same | `test_family_add_spellings_remedies.py`, `test_family_members_case_variants.py`, `test_family_add_refusals.py`, `test_family_head_lost_gitmodules.py` | 207 passed | - **Fail on base:** 25 of the new file's 26 cases fail on `bbb961f8`'s code. The one that passes, `test_a_checkout_under_an_index_entry_is_the_indexs_to_name`, guards the disk filter's new `CHECKOUT` branch: it keeps what the base already did (M22 shows it catches the branch dropped). - **The last three small edits**, after the two batches: one closing sentence reads "a checkout there that git submodule add or git mv made", a comment says a git without `check-rules` leaves the bit alone to decide, and the order fix. The order fix: `rows` is a set, so where two members' removals were staged, the order of the `IN_GITMODULES` entries, and of the text naming them, followed the set's hash (one run printed `Members/IRSS` before `Members/IRRS`); `staged_out` now walks the rows sorted. The runs at this commit cover the files that compare that text. - The three sparse tests carry `needs_check_rules` from `test_family_refuse_up_front.py`, so a git without `git sparse-checkout check-rules` skips them rather than failing. - `render-cli-reference.py --check`: ok. No line of the README, the handbook, `AGENTS.md` or `docs/cli.md` names the changed text, so none changed. 26 mutants, one per new hunk, each run `-x` on the test named. Round 1 (`mutate.py`, `mut/mutants.txt`) ran M1-M25 on the tree before the last three small edits. M7, M8, M20 and M21 were run again at this commit (`mutate2.py`, `mut2/mutants.txt`), since the order fix below touched M7's line and M20's test was the one that showed the order bug; M26 undoes that fix. Round 2 ran with `PYTHONHASHSEED=0`, the seed whose order the bug printed. All 26 are caught, each by its own assertion (read in its log). | Probe | What it undoes | Result | The test that fails | |---|---|---|---| | M1 | no section lines | caught, 1 failed (round 1) | `test_a_mount_moved_with_git_mv_is_moved_back_with_its_section` | | M2 | HEAD's section no commit | caught, 1 failed (round 1) | `test_a_section_head_moved_is_a_change_to_commit` | | M3 | no blocked rows | caught, 1 failed (round 1) | `test_a_file_at_or_under_the_rows_path_is_set_aside_first` | | M4 | no members entry set aside | caught, 1 failed (round 1) | `test_an_entry_at_members_itself_is_set_aside_first` | | M5 | aside always out of the index | caught, 1 failed (round 1) | `test_a_file_at_or_under_the_rows_path_is_set_aside_first` | | M6 | aside forces no commit | caught, 1 failed (round 1) | `test_what_is_set_aside_is_committed_with_the_move` | | M7 | no staged removal found | caught, 1 failed (this commit) | `test_a_members_removal_staged_beside_the_rename_is_moved_back` | | M8 | rename back past a staged removal | caught, 1 failed (this commit) | `test_a_members_removal_staged_beside_the_rename_is_moved_back` | | M9 | one spelling of all held again | caught, 1 failed (round 1) | `test_a_third_spelling_keeps_its_own_remedy_beside_the_rename_back` | | M10 | replace every index entry | caught, 1 failed (round 1) | `test_a_third_spelling_keeps_its_own_remedy_beside_the_rename_back` | | M11 | plain git mv though left out | caught, 1 failed (round 1) | `test_an_entry_the_sparse_rules_leave_out_takes_sparse_whatever_its_bit` | | M12 | no --sparse on git rm --cached | caught, 1 failed, 1 passed (round 1) | `test_an_entry_the_sparse_rules_leave_out_takes_sparse_whatever_its_bit` | | M13 | move back's --sparse from the bit alone | caught, 1 failed, 2 passed (round 1) | `test_an_entry_the_sparse_rules_leave_out_takes_sparse_whatever_its_bit` | | M14 | rename back past a left-out entry | caught, 1 failed (round 1) | `test_the_rename_back_is_withheld_where_the_rules_leave_an_entry_out` | | M15 | a checkout moved like any other | caught, 1 failed (round 1) | `test_a_checkout_left_at_the_variant_is_named_to_delete_by_hand` | | M16 | closing mv unguarded | caught, 1 failed (round 1) | `test_each_layout_moves_the_gitlink_back_as_printed` | | M17 | the update first again | caught, 1 failed (round 1) | `test_a_mount_moved_with_git_mv_is_moved_back_with_its_section` | | M18 | no -f | caught, 1 failed, 1 passed (round 1) | `test_a_mount_moved_with_git_mv_is_moved_back_with_its_section` | | M19 | no gitmodules-sparse first | caught, 1 failed (round 1) | `test_a_sparse_gitmodules_is_named_first_where_a_section_is_pointed_back` | | M20 | .gitmodules asked only beside an index gitlink | caught, 1 failed (this commit) | `test_both_removals_staged_with_no_gitmodules_on_disk` | | M21 | staged removal's checkout named twice | caught, 1 failed (this commit) | `test_a_members_removal_staged_beside_the_rename_is_moved_back` | | M22 | the disk filter for a checkout | caught, 1 failed (round 1) | `test_a_checkout_under_an_index_entry_is_the_indexs_to_name` | | M23 | CHECKOUT part dropped | caught, 1 failed (round 1) | `test_a_checkout_left_at_the_variant_is_named_to_delete_by_hand` | | M24 | aside part dropped | caught, 1 failed (round 1) | `test_a_file_where_the_disk_holds_the_checkout_is_taken_out_first` | | M25 | aside left in the first line | caught, 1 failed (round 1) | `test_a_file_where_the_disk_holds_the_checkout_is_taken_out_first` | | M26 | staged rows in the set's order | caught, 1 failed (this commit) | `test_both_removals_staged_with_no_gitmodules_on_disk` | `git merge-tree --write-tree`, after a fetch at 16:02Z on 2026-10-17. - **This commit, `deb83e2e`:** clean against #767's final head `bd6d2afe`, #670's `6d7e4ff0`, and the open branches that change these files from #767's base: `fix/684-family-holder-states` `fdc90a6e`, `fix/686-bump-reachability` `cf5e64b3`, `fix/688-family-init-values` `6958dfbc`, `fix/689-next-push-refspec` `15c3b5ab`, `fix/692-raw-folder-paths` `ab55c29f` and `fix/766-spelled-paths` `ef6db40d`. Against main `40e678e6`, and the branches already restacked onto it, it conflicts in the four files #767 changed: the stacked-PR conflict the restack above resolves. - **The predicted restack, `263d60e8`:** clean against `bd6d2afe`, `fix/693-local-remote-dir` `b5a6c6a1`, and every branch above. Against the wave-7 branches it is clean with `fix/757`, `763`, `768`, `777` and `796` to `801`. `fix/758`, `759`, `760`, `762` and `764` conflict with it only in files this PR does not touch (`tests/conftest.py`, `install.sh`, `validate-family.py`, the `bootstrap.py` copies and their tests), where main has moved since those branches were cut. - Every open branch that touches one of these five files is clean against the restack. - **The validator's own move back** (`templates/family-root/scripts/validate-family.py`, `_wrong_case_move`) still prints the update before the hand step, which meets the store trap above where a checkout at the variant shares the member's store. Its `git rm --cached` may refuse a staged variant beside its checkout's work, as `add`'s did; that was not measured for the validator. Both are for the validator's own issue, not this one. - **A section that gives a case variant of a row's path while the gitlink is at the row's path** (the state #646's old move back left, typed) is not named by `add`, which goes through; the validator refuses it. - **#574's `git mv` of a directory target** moves an untracked checkout under it too. Not measured. - **A case variant of a row's path under `members/` itself** (`members/irrs`) is not named by `add` (read from the source, not run). Review: lane 2's independent review and its re-verification of ce0f2a5, READY (issuecomment-6240244762). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 90649e5 passed tests, tests-git-bash and SonarCloud; the head 3546118 is a clean update-branch merge of main c5d0bde, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 635a3da over main c5d0bde: the family_add_member_variant, family_add_move_back_sections, family_add_spellings_remedies, family_members_case_variants, install_sh, openreposhape_command, repo_hygiene, agents_md_codes, cli_reference sets, 975 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 #761 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ile` skips on EILSEQ or a `UnicodeError` only and the U+202E directory has no guard, so a full disk or a read-only `tmp_path` fails the test, not SKIPPED (#834) (#845) Stacked on #806 (`fix/757-non-utf8-test-dirs`, `76ae6419`); retarget to main once it lands. Tests only: nothing a user runs changes, and no pinned file changes. One new commit on `9ba530a9`, one test file, one line moved. Main is not merged and neither is #806. **SHOULD 1 (SonarCloud python:S5778, MAJOR): done, the reviewer's way.** The `pytest.raises(pytest.fail.Exception)` block in `test_the_sweep_names_the_first_errno_that_fails_and_stops_there` held two calls that could raise, `Disk(first)` and `raised_for_every_errno(...)`. `disk = Disk(first)` is now built before the block, and the block holds `raised_for_every_errno(disk, hides_the_error, "a path")` alone. The assertions after the block are unchanged and read the same exception: `failed.value.msg` is still the `Failed` that `raised_for_every_errno` raises, and `asked` is still the list the caller was asked. The file's other `pytest.raises` block (in `the_error_of`) already held one call. ### Runs Through `run9.sh`, Python 3.12.3. Logs are in the scratchpad's `v118/w834/`. - The two files, `tests/test_non_utf8_oserror_sites.py` and `tests/test_non_utf8_name_callers.py`: 55 passed (`t-two.log`), as the reviewer measured. - The seven changed or importing files (`tests/test_non_utf8_git_reads.py`, `tests/test_adopt_template_values.py`, `tests/test_non_utf8_name_helpers.py`, `tests/test_non_utf8_name_callers.py`, `tests/test_non_utf8_oserror_sites.py`, `tests/test_adopt_git_reads.py`, `tests/test_family_init_asks_every_value.py`): 311 passed, 1 skipped (`t-B.log`), the same as round 2. The skip is the one in the PR body, from the run script's blank `user.name`; it does not happen in lane 4's runs. - `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`: 147 passed (`t-C.log`). ### Mutants The four label and fail-fast mutants of round 2 are all still caught, each by `test_the_sweep_names_the_first_errno_that_fails_and_stops_there` alone, with the new line in place (`tmutants-summary.log`). A fifth, from lane 4's table, is caught too. | Mutant | Tests that fail | |---|---| | the sweep does not pass the label | 1 | | `the_error_of` drops the label on the way | 1 | | `fail_where_the_caller_ends` ignores the label | 1 | | the sweep swallows each failure and goes on | 1 | | the label goes after the message (lane 4's) | 1 | The first one reads `- errno 1 (EPERM): the caller ended SKIPPED: hidden` against `+ the caller ended SKIPPED: hidden`. ### Merge checks `git merge-tree --write-tree` of `8f6a38e3` against main `9f235da2`, #806 (`76ae6419`) and `9ba530a9`: clean. #778 is as before: the one `from conftest import` line of `tests/test_doctor_validator_verdicts.py`, which no round of this PR touches after `b18b243e`. One new commit on `efb17273`, two test files. Main is not merged and neither is #806. **SHOULD 1 (SonarCloud python:S8714 on `raised_for_every_errno`): done, the reviewer's way.** The `except pytest.fail.Exception` caught a failure only to fail again with the errno's name in front. The label is now passed in: - `fail_where_the_caller_ends` (`tests/test_non_utf8_name_callers.py`) takes a keyword-only `said`, put in front of its message, with `""` as the default. - `the_error_of` threads `said` through, and the loop passes `said=f"errno {code} ({errno.errorcode[code]}): "`. The loop has no `try`. - Nothing else changes. A failing errno still reads `errno 1 (EPERM): the caller ended SKIPPED: ...`, and the sweep still stops at the first errno that fails, since the failure ends the loop. The three other callers of `fail_where_the_caller_ends`, all in `tests/test_non_utf8_name_callers.py`, pass no `said` and are not touched: the only hunk in that file is the function's own. A failing errno still names itself, in a run's output. This is the mutant that re-raises only ENOSPC, EROFS and EACCES in `make_file`, on this commit: ``` E Failed: errno 1 (EPERM): the caller ended SKIPPED: this filesystem refuses a file name that is not UTF-8 (EILSEQ): [Errno 1] Operation not permitted: ... ``` **One new test** holds the label and the fail-fast, `test_the_sweep_names_the_first_errno_that_fails_and_stops_there`: a caller that skips where it should raise must fail with exactly `errno <first> (<NAME>): the caller ended SKIPPED: hidden`, and be asked once. It passes on `efb17273` as well, by design: that commit already gave the same message, and this round keeps it. So it cannot fail there. It fails on the mutants below that drop the label or the fail-fast. **NIT 2 stays, and is #854.** Under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`, `test_execute_refuses_a_local_source_directory_holding_a_bidi_control` fails with a `UnicodeEncodeError` from `parent.mkdir()`, on `efb17273` and on main alike. It is filed as #854. This round does not change it, and the new test of the sweep does not touch that directory. ### Runs Through `run9.sh`, Python 3.12.3. Logs are in the scratchpad's `v117/w834/`. - The seven files that import the changed ones or are changed (`tests/test_non_utf8_git_reads.py`, `tests/test_adopt_template_values.py`, `tests/test_non_utf8_name_helpers.py`, `tests/test_non_utf8_name_callers.py`, `tests/test_non_utf8_oserror_sites.py`, `tests/test_adopt_git_reads.py`, `tests/test_family_init_asks_every_value.py`): 311 passed, 1 skipped (`s-B.log`). The skip is the one in the PR body, on the base too. The count is the 310 of the last round plus the one new test. - `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`: 147 passed (`s-C.log`). - The four `non_utf8` files together under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`: 110 passed, 15 skipped, all the ASCII file name encoding (`s-D-clocale.log`). ### Mutants Ten, each applied to `9ba530a9`'s test files and caught (`smutants-summary.log`): | Mutant | Tests that fail | |---|---| | `make_file` skips on any `OSError` again | 4 | | `make_file`: denylist of ENOSPC, EROFS, EACCES | 2 | | `test_cli_reference.py`: a `try` that re-raises 13, 28 and 30 | 1 | | `test_doctor_validator_verdicts.py`: the same | 1 | | U+202E `mkdir`: the same | 1 | | `conftest.py` helper: the probe's guard is a denylist | 3 | | the sweep does not pass the label | 1 | | `the_error_of` drops the label on the way | 1 | | `fail_where_the_caller_ends` ignores the label | 1 | | the sweep swallows each failure and goes on to the next errno | 1 | The first six are the earlier mutants that the sweep kills. The last four are new, and a failing run of the first label mutant reads `- errno 1 (EPERM): the caller ended SKIPPED: hidden` against `+ the caller ended SKIPPED: hidden`. ### Merge checks `git merge-tree --write-tree` of `9ba530a9` against main `190133d5`, #806 (`76ae6419`) and `efb17273`: clean. Against #778 (`fe7b30f6`): the one `from conftest import` line of `tests/test_doctor_validator_verdicts.py`, as before, and this round does not touch that file. One new commit on `b18b243e`, three test files. Main is not merged and neither is #806. Each point of the review, and what was done: **SHOULD 1: `REFUSALS` held three errnos, so a guard that re-raised those three and skipped the rest passed. Done.** The emulated disk (`a_disk_that_refuses`) now answers every errno that `errno.errorcode` holds but EILSEQ (129 here) and each caller must raise it, as Python makes it (`PermissionError` for EACCES). The errnos are drawn from `errno.errorcode` and not listed, so one nobody thought of is asked too. The sweep stops at the first errno that fails and names it, so a caller that goes on past a refused name runs its test once and not 129 times. The three-row families are gone, since the sweeps cover them: the new file has 18 tests where it had 32. Both of the reviewer's surviving mutants now fail, with the same mutants and the same run: - `make_file` with `exc.errno in (EACCES, ENOSPC, EROFS)` in place of `!= EILSEQ`: 2 tests fail (was 0). - `test_cli_reference.py` with a `try` that re-raises 13, 28 and 30 and skips the rest: 1 test fails (was 0). - Three more of the same kind also fail: the doctor test's `mkdir`, the U+202E `mkdir`, and the helper in `conftest.py` (a denylist there fails 3 tests, so the sweeps also hold #663's helper to it). **SHOULD 2: the U+202E docstring claimed more than `refused_then_moved` did. Made true, not narrowed.** The reviewer measured the gap with a `git` that fails `commit` as a full disk does. I measured it the same way, on `b18b243e` and on this commit: - `b18b243e`: `1 skipped`, "git will not work under '\u202ecaf' here: error: unable to write file .git/objects/xx: No space left on device". - `efb17273`: `1 failed`, `AssertionError: error: unable to write file ...`, raised at `tests/conftest.py:883`. Why true and not narrowed: the second skip exists for a name that is not UTF-8, which git may not work under (#757 kept it for that). U+202E is valid text, and git works under it wherever it works at all (reasoned for NTFS and APFS, not run). So the skip there hides a disk that filled after the `mkdir`, which is this issue's whole point, and the fix is one keyword. `refused_then_moved` takes `skip_where_git_fails` (default true, so the byte-name test is as #757 left it), and the U+202E test passes false. Three new tests hold it: - `test_a_failure_of_git_under_the_bidi_directory_fails_the_test`: a `make_source_repo` that raises the `AssertionError` must reach the test as a failure. It fails on `b18b243e`. - `test_a_failure_of_git_under_the_byte_directory_still_skips_the_test`: the control, and the known limit. The byte-name test still skips there, and a full disk after its `mkdir` still reads as SKIPPED. - `test_a_disk_that_refuses_the_bidi_directory_fails_the_test` is now a sweep over every errno. The docstrings of both tests and of `refused_then_moved` say which skip stays and why. **NIT 3: done.** `make_file` gives the two skips their own reasons: "this system's file name encoding cannot spell ..." for a `UnicodeError`, and "this filesystem refuses a file name that is not UTF-8 (EILSEQ)" for EILSEQ. The controls assert each one, and that the encoding reason does not say "filesystem". Both fail on `b18b243e`, and a mutant that restores either old wording fails. **NIT 4: stays.** The emulated disk is still written in four places. Moving `a_disk_that_refuses`, `fail_where_the_caller_ends` and the two fixtures into `tests/conftest.py` adds hunks to a file that #814 and #818 also edit, and Sonar reports 0.0% duplication on new code. It is a follow-up, once those land. **NIT 5: done.** The own-directory test is now `test_the_cli_reference_and_doctor_tests_fail_where_the_disk_refuses_their_own_directory`, which names its sites. A `-k` selection no longer picks up #757's test of the old name. **NIT 6: done.** The module docstring keeps `skip_where_a_name_cannot_be_not_utf_8` on one line. ### Tests The new file passes 18 of 18 on this commit. On `b18b243e`, with only the new file put in, 3 fail and 15 pass. The 3 are the two NIT 3 reasons and the U+202E git failure. The other 15 are mutant-killers for SHOULD 1, and they pass on code that is correct. Runs on Linux (Python 3.12.3) through `run9.sh`: - `tests/test_cli_reference.py`, `tests/test_doctor_validator_verdicts.py`, `tests/test_bootstrap_leg_moves.py`: 79 passed. - The changed files and the files that import them, `tests/test_non_utf8_git_reads.py`, `tests/test_adopt_template_values.py`, `tests/test_non_utf8_name_helpers.py`, `tests/test_non_utf8_name_callers.py`, `tests/test_non_utf8_oserror_sites.py`, `tests/test_adopt_git_reads.py` and `tests/test_family_init_asks_every_value.py`: 310 passed, 1 skipped. The skip is the one named in the PR body (`test_plan_names_the_elector_it_took_from_git_config`, on the base too). - `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`: 147 passed. - The four `non_utf8` files together: 124 passed. Under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`: 109 passed, 15 skipped, and every skip is the ASCII file name encoding. **Mutants.** 21 in all, one or more per hunk, each applied to the fixed tree and caught. The first eleven are the earlier table, rerun on the new tests (each fails 1 to 4 tests). The ten new ones: | Mutant | Tests that fail | |---|---| | `make_file`: denylist of ENOSPC, EROFS, EACCES (reviewer's) | 2 | | `test_cli_reference.py`: a `try` that re-raises 13, 28, 30 (reviewer's) | 1 | | `test_doctor_validator_verdicts.py`: the same | 1 | | U+202E `mkdir`: the same | 1 | | `conftest.py` helper: the probe's guard is a denylist | 3 | | `make_file`: the `UnicodeError` reason says "filesystem" again | 1 | | `make_file`: the EILSEQ reason does not name EILSEQ | 1 | | `refused_then_moved`: skips on git's failure whatever the caller asks | 1 | | U+202E test: does not ask for git's failure to raise | 1 | | byte-name test: asks for git's failure to raise | 1 | ### Merge checks `git merge-tree --write-tree` of `efb17273` against main `6f64eea2`, #806 (`76ae6419`) and `b18b243e`: clean. Against the 57 open `fix/*` and `l3-*` branches: 50 clean. The seven that conflict are six of the seven that conflicted at `b18b243e` (`fix/712-validate-pins-contracts` moved and is clean now) plus `fix/835-config-count-appends`, which conflicts in `tests/test_adopt_git_reads.py` as #818 does against #806. Every one conflicts the same against #806 or main, with one exception, below. The exception is #778, which conflicts with this branch only, on the one `from conftest import` line of `tests/test_doctor_validator_verdicts.py`, as at `b18b243e`: this round does not touch that file. This round changes no conflict region against #814 (one region in `tests/test_non_utf8_git_reads.py`, before and after). ### Not changed, and a candidate - **A candidate, not filed:** under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`, `test_execute_refuses_a_local_source_directory_holding_a_bidi_control` fails on `b18b243e` and on #806 (`UnicodeEncodeError` from `parent.mkdir()`: the ASCII encoding cannot spell U+202E). #757 skips three tests of `tests/test_non_utf8_git_reads.py` that way with `skip_where_the_environment_cannot_carry`. The new git-failure test asks it for U+202E and skips there, so this round adds no failure to a C locale. The test of the directory itself is not changed, because the review did not raise it. - The two checklist candidates in the PR body stand, narrowed: `refused_then_moved` no longer belongs to the first one for U+202E, only for the byte-name test. Five tests make a directory or a file named with a byte that is not UTF-8, or with U+202E. Each turned ANY `OSError` into `pytest.skip`, beside the one helper #663 added, `skip_where_a_name_cannot_be_not_utf_8`. So a full disk, a read-only `tmp_path` or a folder nobody can write in read as SKIPPED, and no run fails on a skip. CI never has one of those, so nothing failed. #806 moved five other tests onto the helper and left these. Each line of #834, with the direction chosen: 1. **`tests/test_cli_reference.py`, `test_a_checkout_whose_path_is_not_utf8_is_a_checkout`.** It asks the helper on `tmp_path`, then makes its directory with no `try`. 2. **`tests/test_doctor_validator_verdicts.py`, `test_a_name_that_is_not_utf8_is_never_an_exception_quoted_as_a_finding`.** Same: the helper first, then `mkdir(parents=True)` unguarded. The docstring said "a disk that refuses one (APFS) skips"; it now says which skip and that every other error fails. 3. **`tests/test_bootstrap_leg_moves.py`, `test_a_branch_whose_name_is_not_utf8_is_read_back_on_every_run`.** The helper replaces the `write_bytes` probe that skipped on `OSError` and `ValueError`. The Windows `skipif` stays. 4. **`make_file` in `tests/test_non_utf8_git_reads.py`.** It keeps the `UnicodeError` skip (an ASCII file name encoding, #757's second line) and skips an `OSError` only where `errno` is `EILSEQ` (macOS). ENOSPC, EROFS and EACCES are raised, as the helper does for its `mkdir`. Every caller of `make_file` inherits the failure and not the skip. #757's `test_a_write_the_disk_refuses_still_fails_the_make_file_test` (in `tests/test_non_utf8_name_callers.py`) expected the `AssertionError` of the test's own "not a skip" assert; it now expects the `OSError` itself, with its errno. 5. **`tests/test_adopt_template_values.py`, the U+202E directory.** The `try` is dropped, so `mkdir` raises. The issue offered the other way, narrowing to EILSEQ and naming the system that refuses U+202E. None is known: U+202E is valid text. A `mkdir` of a name that starts with U+202E succeeds on this machine's overlay filesystem (measured), and NTFS and APFS are reasoned to hold it (read, not run on those two). So a narrowing would guard a refusal nobody has seen. `tests/test_non_utf8_oserror_sites.py` is new: 32 tests. It calls each site as a plain function, with the disk answering as it would on a system CI does not run (CI is Linux only, #597): - EILSEQ on the probe (macOS) skips the three tests that ask the helper, on the helper's reason. - EACCES, ENOSPC and EROFS on the probe, on the test's own directory, on `make_file`'s write and on the U+202E directory raise. The test asserts the error and its errno, and fails if the call skipped or ended without one. - An `os.fsencode` that gives a surrogate three bytes (Windows, emulated with `windows_fsencode`) skips the three helper callers before the disk is touched, and nothing is made under `tmp_path`. - `make_file` still skips on EILSEQ and on a `UnicodeError` (two controls). The disk is emulated by `os.mkdir` and `Path.write_bytes`, the two calls the base and the fix use to make a name, so the same row fails on the base for the reason it should. It reuses the two fixtures, `PROBE` and `fail_where_the_caller_ends` of #806's `tests/test_non_utf8_name_callers.py`. No Windows or macOS run was made. Runs, on Linux (Python 3.12.3), under `run9.sh`: - `tests/test_non_utf8_oserror_sites.py`, `tests/test_non_utf8_name_callers.py`, `tests/test_non_utf8_name_helpers.py`: 86 passed. - `tests/test_cli_reference.py`, `tests/test_doctor_validator_verdicts.py`, `tests/test_bootstrap_leg_moves.py`: 79 passed. - Those two sets with `tests/test_non_utf8_git_reads.py`, `tests/test_adopt_template_values.py`, `tests/test_adopt_git_reads.py` and `tests/test_family_init_asks_every_value.py` (the files that import a changed module): 324 passed, 1 skipped. The skip is `test_plan_names_the_elector_it_took_from_git_config`, which skips on the base too: the run script's blank `user.name` overrides its `GIT_CONFIG_GLOBAL`. - `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`: 147 passed. `render-cli-reference.py --check` passes. - `tests/test_non_utf8_git_reads.py` under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`: 40 passed, 12 skipped; every skip is the ASCII file name encoding, as before. Normal locale: 52 passed. **Fails on the base.** On `76ae6419` with only the two test files put in, 32 of the 68 tests of `tests/test_non_utf8_oserror_sites.py` and `tests/test_non_utf8_name_callers.py` fail: 30 of the new 32 and the 2 changed ones. The other 36 pass: the 2 new controls, which hold what both keep, and the 34 that are unchanged. **Mutants.** One per hunk, each applied to the fixed tree and caught: | Mutant | Tests that fail | |---|---| | `test_cli_reference.py`: `except OSError` put back around the `mkdir` | 3 | | `test_cli_reference.py`: helper call removed | 5 | | `test_doctor_validator_verdicts.py`: `except OSError` put back | 3 | | `test_doctor_validator_verdicts.py`: helper call removed | 5 | | `test_bootstrap_leg_moves.py`: the old `write_bytes` probe back | 5 | | `test_bootstrap_leg_moves.py`: helper call removed | 5 | | `make_file`: skips on any `OSError` again | 8 | | `make_file`: EILSEQ no longer a skip | 1 | | `make_file`: `UnicodeError` no longer a skip | 1 | | `test_adopt_template_values.py`: `except OSError` put back around the U+202E `mkdir` | 3 | | `test_non_utf8_name_callers.py`: the old `AssertionError` expectation back | 2 | `git merge-tree --write-tree` of this head against origin/main (`fc867207`, which has #746), against `bad05d21`, against #806 and against all 52 open `fix/*` and `l3-*` branches: clean, except seven branches. - **#778 (`l3-707-way-in-policy`) conflicts in one place, and only with this commit.** Both PRs add a name to the `from conftest import ...` line of `tests/test_doctor_validator_verdicts.py` (#778 adds `git`, this one adds the helper). Keep both names. #778 is clean against main and against #806. - **#818 (`fix/758-git-command-scope`)** conflicts in `tests/test_adopt_git_reads.py`, which this commit does not touch. It conflicts the same against #806 alone, and is clean against main. - **#814 (`fix/766-spelled-paths`)** conflicts in `tests/test_non_utf8_git_reads.py` and `tests/test_dry_run_parity.py`. It conflicts the same against main and against #806, with one region in each file with and without this commit. - **`fix/712-validate-pins-contracts`, `fix/798-adopt-legs-asked-first`, `l3-677-pins-hand-step-wip` and `l3-678-naming-checkout-wip`** conflict in files this commit does not touch, and conflict the same against main and #806. The scan of `tests/` for an `except` that names `OSError` and is followed by `pytest.skip` lists 31 other places. As #834 says, most ask something else (a symlink, a hard link, `locale -a`, a NEL or a line feed in a folder name) and are not changed here. Two kinds are the same defect as this issue's and are not lines of it. Nothing is filed; they are for the checklist. - **A skip on an `AssertionError` of a git call, kept by #757 as a second skip.** `refused_then_moved` (`tests/test_adopt_template_values.py`), `test_a_utf8_clone_of_a_source_whose_path_is_not_utf8_adopts` (`tests/test_adopt_git_reads.py`) and `test_a_source_directory_that_is_not_utf8_is_shown_spelled` (`tests/test_adopt_plan_escapes.py`) skip with "git will not work under ..." when `make_source_repo` raises `AssertionError`. A disk that fills after the directory is made fails that call the same way, and the test reads as SKIPPED. For the U+202E test of line 5 this means the `mkdir` now fails on a full disk, and a disk that fills a moment later can still skip it. - **A skip on any `AssertionError`, `UnicodeError` or `OSError` where the branch name is not UTF-8.** `with_default_branch` and `moved_to_a_branch_that_is_not_utf8` (`tests/test_adopt_git_reads.py`) make the branch with `git`, and skip if it fails for any reason. Review: lane 4's RV845, READY on b18b243 (issuecomment-6240020694), on efb1727 (issuecomment-6240528662) and on 9ba530a (issuecomment-6241540556); 8f6a38e applies RV845's S5778 fix exactly as RV845 probed it. `fc3cb5e9` restacks it onto #806's squash `d6071c7d` with three merges and the same `-U0` lines. CI (Brett Heap's ruling 27): H0 8f6a38e passed tests, tests-git-bash and SonarCloud; the head fc3cb5e merges #806's final head, then #806's squash d6071c7 (the branch's own side for #806's files), then main, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree b572ef4 over main 576e170: the adopt_template_values, bootstrap_leg_moves, cli_reference, doctor_validator_verdicts, family_bootstrap_gate_order, family_unnamed_gitlink, non_utf8_git_reads, non_utf8_name_callers, non_utf8_oserror_sites, pin_validator, validate_family_unnamed_mounts, repo_hygiene, agents_md_codes, cli_reference sets, 524 passed, 1 skipped. 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 #834 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-5 (openRepoShape-5)
Claim: #672 (comment)
bump-leg.pyread the root's branch with--short, printed a push line that+could turn into a forced push, printed control characters from file names raw, listed paths the commit did not hold, and said nothing about the validators a dry run skips. Each of #672's five lines was measured on main35133f29with a scaffolded project and local bare remotes, and each is gone on this head. One file of code changes:scripts/bump-leg.py.Fix round: #723 merged in (
4a014a79)This branch now holds #723 (#663,
fix/663-non-utf8-name-tests, head6ba8c5d2) and is stacked on it. #723 lands first. Until then this PR's diff against main also shows #723's six test files. Once #723 lands, the PR is retargeted and restacked, and its own diff is #672's again.One new commit, a merge commit:
4a014a79merges6ba8c5d2intoa5d473ee. It changes no tool code.scripts/bump-leg.pyis byte for byte the same as ata5d473ee.The conflict
The two PRs both changed one assertion in
tests/test_non_utf8_git_reads.py::test_a_name_that_is_utf8_under_quote_path_false_is_still_refused:refs/heads/<b>:refs/heads/<b>, dirty names are spelled,committedlists what git holds, and the dry run says it runs no validator (#672) #746 changed its prefix from four spaces andMto five spaces andM, because bump-leg.py: a root on+xis toldpush -u origin +x(forced), a branch named like a tag passesbump-leg-on-tracking-branch,bump-leg-root-dirtyprints C1 controls raw, andcommitted <sha>:lists paths the commit lacks #672 keeps the leading space of git'sM.make_fileand the--branch a\udcffbtest by emulation, and the UTF-8 promise and the leg finding's role pinned (#663) #723 rewrote the sameany(...)as[shown] = [...]andassert shown.endswith(".yml"), then added a closingassert_shown_without_hex_escapes(shown, output). That check fails ifbump-leg.pydecodes git's bytes as ASCII.The resolution keeps #723's form and uses #746's five-space prefix. Neither side alone works here:
2 passed2 failed:ValueError: not enough values to unpack (expected 1, got 0); #672's output has no such lineany(...)2 passed, but the hex-escape check is gonebump-leg.pydecoding as ASCII2 failed:assert '\\x' not in ' M .git...xc3\\xa9.yml'any(...), withbump-leg.pydecoding as ASCII2 passed: the mutant is missedEvery other hunk merged the way git merged it.
Every test of each side stands
Test functions per file, before and after the merge:
tests/test_non_utf8_git_reads.pytests/test_bootstrap_branch_bytes.pytests/test_non_utf8_name_helpers.pytests/test_adopt_pins.pytests/test_pin_source_unreadable.py#723's names include all of #746's, and every one is in the merge. The merge holds #723's other five files exactly as #723 has them. In
tests/test_non_utf8_git_reads.pyit differs from #723 only in #746's three hunks.#723's helper tests feed
assert_shown_without_hex_escapesthree made-up lines. Those lines still start with four spaces, because they are the helper's input, not the tool's output.Each side's half is needed (
tests/test_non_utf8_git_reads.pyhas 52 tests):make_fileand the--branch a\udcffbtest by emulation, and the UTF-8 promise and the leg finding's role pinned (#663) #723'sbump-leg.py, from before bump-leg.py: a root on+xis toldpush -u origin +x(forced), a branch named like a tag passesbump-leg-on-tracking-branch,bump-leg-root-dirtyprints C1 controls raw, andcommitted <sha>:lists paths the commit lacks #672:7 failed, 45 passed.make_fileand the--branch a\udcffbtest by emulation, and the UTF-8 promise and the leg finding's role pinned (#663) #723's version of the file run against this branch'sbump-leg.py:7 failed, 45 passed.In both runs the 7 failures are the tests that read the dirty list:
refused_dirtytests, in both modes;test_the_refusal_names_every_dirty_file_whatever_its_bytes.Tests on the merge
Run on the tree
4f4fed60, which is the tree of4a014a79:make_fileand the--branch a\udcffbtest by emulation, and the UTF-8 promise and the leg finding's role pinned (#663) #723's files, the helpers file and the trio:tests/test_non_utf8_git_reads.py,test_non_utf8_name_helpers.py,test_adopt_pins.py,test_bootstrap_branch_bytes.py,test_pin_source_unreadable.py,test_repo_hygiene.py,test_agents_md_codes.pyandtest_cli_reference.py. Result:504 passed.test_bump_leg: everytests/test_bump_leg*.py(eight files), plustest_branch_left_bump_leg_setup.py,test_family_switch_branch_exists.py,test_update_shape_paste_lines.py,test_validate_pins_unreadable_workflows_dir.pyandtest_bootstrap_after_bump.py. Result:364 passed, 1 skipped. The skip needs a disk that ignores case.Merge checks
git merge-tree --write-treeof the merge:24a977c0: clean.fix/*andl3-*branch: clean, exceptfix/758-git-command-scope(b6e15591), which conflicts intests/test_non_utf8_git_reads.py. That conflict comes from tests: one skip helper for names and branches that are not UTF-8, Windows guards onmake_fileand the--branch a\udcffbtest by emulation, and the UTF-8 promise and the leg finding's role pinned (#663) #723:6ba8c5d2conflicts withb6e15591on its own, anda5d473eedoes not.What changed, and why
current_branchrangit symbolic-ref --short HEAD. Beside a tag of the same name that printsheads/<b>.mainbeside the tracking branchmain,bump-leg-on-tracking-branchwas not given. The bump committed onmainand printedpush -u origin heads/main.refs/tags/xwas read as the branchx, and the bump committed through the tag and moved it.refs/heads/is taken off. A ref outsiderefs/heads/is no branch, so that root is refusedbump-leg-root-detached.push -u origin <branch>. A bare branch name is a refspec, and a leading+means force.+xwas toldpush -u origin +x. Pasted, it printed+ e124684...c8932ad x -> x (forced update)and never pushed+x.+main, pasted, it printed+ a6b763e...c8932ad main -> main (forced update): origin's default branch was rewound.push -u origin refs/heads/<b>:refs/heads/<b>. Measured:+xand+mainpush as new branches and leave origin'sxandmainwhere they were.v1beside a tagv1pushes too, where git's own barev1fails withsrc refspec v1 matches more than one.-ustill recordsorigin/<b>.bump-leg-root-dirtyspells each name. Undercore.quotePath=falsegit prints a file name as its raw bytes. On main a tracked, editedcaf<U+009B><U+202E>evil.ymlwent to stderr as the raw bytes302 233 342 200 256.repo_shape.spelled, so this one shows ascaf\x9b\u202eevil.yml.\nonly.splitlines()also broke a name at U+0085 and U+2028, andstrip()dropped one from the end of the last name.committedlists what the commit holds, and noX -> X.committed ...: spec, contracts/spec-pin.yamlwhile the commit held only the pin. The report and the commit message both said52b767b3490a -> 52b767b3490a.git diff-tree --no-commit-id --name-only -r -z --ignore-submodules=none <sha>. Each name is passed throughspelled, like the dirty list.commitline and the message name the pin's owncommit:as the old commit. Agitlinkline, and a sentence in the message, say the gitlink does not move.wasis still what the rollback restores.git show --name-only --ignore-submodules=none <sha>(since the fix round) and the run still exits 0. Before, agit-failedrefusal would have exited 2 and implied the bump never landed.scripts/validate-pins.pyorscripts/validate-manifest.py, and that the real run can still endbump-leg-validators-red, rolled back. Behaviour is unchanged, as the issue recommends. Thecmd_bumpdocstring said the dry run refuses everything the real run refuses; it now names this one exception.Also: the
BRANCH_LEFTcomment said "the three tools follow one rule". It now names the four copies:family.py, the holder'svalidate-family.py,setup-project.pyand this one (rv647's nit 3).Decisions and their evidence
refs/heads/<b>:refs/heads/<b>, notrefs/heads/<b>alone. The issue allows either. Lane 4's branches print the both-sides form:fix/689-next-push-refspec(fa513848) forfamily.py's NEXT, andfix/674-update-shape-apply(d0699029) through_full_refspec. The brief asks this PR to match their form, so all three tools print one form. Both forms were measured to push+x,+mainandv1correctly and to set the upstream.diff-treerather thangit showfor thecommittedlist. Measured on git 2.43:git show --name-onlydrops the gitlink underdiff.ignoreSubmodules=all.diff-treeis plumbing and ignores that setting.diff-treealso drops the gitlink undersubmodule.spec.ignore=all.--ignore-submodules=noneoverrides it.scripts/repo_shape.pydeleted and committed:PYTHONPATH=<standard>/scripts, the gate printsFINDING shape-copy-missing: ... scripts/repo_shape.py is pinned but absent.ModuleNotFoundError.-Swith noPYTHONPATHwould only swap the precise finding for a traceback.git -C <path> push -u origin <branch>, right after theswitch -c <branch>the person types. They stay true for the names people choose, no test ties them to the tool's printed line, and the README stays out of the README queue.docs/release-notes/v1.0.0.mdlines 433 and 440 list two of these lines as v1.0.0 known limits. They describe v1.0.0 and are left alone; the v1.1.0 notes are lane 3's.--helptext changed, sodocs/cli.mdis unchanged, andtests/test_cli_reference.pypasses.Tests
New file
tests/test_bump_leg_branch_and_push_line.py, 20 tests in the first round and 24 after the fix round (see Fix round):mainbeside the tracking branch; a HEAD on a ref outsiderefs/heads/(refs/tags/release-1andrefs/remotes/origin/mainsince the fix round).+xand+mainwith origin's other branch moved on, each run as the printed argv;v1beside a tagv1, with its upstream checked.--dry-run, plus a default-quoting control.committedline: checked plain and undersubmodule.spec.ignore=allanddiff.ignoreSubmodules=all; the gitlink moved by hand, in both the dry and real runs; the line when git cannot list the paths.validate-pins.pyalready refuses.Tests that held the old form were updated:
tests/test_bump_leg.py::test_the_message_and_the_next_step_are_a_pull_requesttests/test_bump_leg_paste_lines.py:test_the_closing_next_is_one_command(3 params),test_an_ordinary_root_prints_the_line_it_always_didandtest_the_closing_next_carries_the_branch_whole(3 params). Its paste tests still run the line throughshand check the remote.Results:
35133f29: the new file gives19 failed, 1 passed. The one pass is the default-quoting control, which is meant to pass there.326de720: everytests/test_bump_leg*.py, plustests/test_branch_left_bump_leg_setup.pyandtests/test_non_utf8_git_reads.py. Result:277 passed, 1 skipped.bump-leg.py:tests/test_bootstrap_after_bump.py,test_repo_hygiene.py,test_agents_md_codes.py,test_cli_reference.py,test_doctor_gitlink_repairs.py,test_doctor_pin_restore_merges.py,test_doctor_unfinished_merge.py,test_family_switch_branch_exists.py,test_pin_rewrite_layout.py,test_stdout_encoding.py,test_template_text_placeholders.py,test_update_shape_paste_lines.py,test_validate_pins_lockstep_lines.py,test_validate_pins_unreadable_workflows_dir.pyandtest_validator_child_encoding.py. Result:697 passed, 1 skipped.326de720:217 passed.Mutation probes
One mutant of
bump-leg.pyper hunk. Each was run against the new file, and the two push mutants also against the paste-line tests. All 12 fail at least one test:--shortrestoredv1testsspelledkept butsplitlines()restoredwasgitlinklinewas--ignore-submodules=nonerefs/heads/<b>in NEXTThe comment and docstring hunks change no behaviour and have no mutant.
Merge checks
git merge-tree --write-treeof326de720is clean against:de926142, and2fac9790;fix/674-update-shape-apply(d0699029) andfix/689-next-push-refspec(fa513848);fix/665-four-guards(151a0861), which also editstests/test_bump_leg_paste_lines.py.Main up to
2fac9790changesrepo_shape.git_out's refusal text (#610), but notspelledorpin_values_in, which this PR imports.Left out
Fix round (rv672's review of
326de720)One new commit,
a5d473ee, on top of326de720.refs/heads/is now parametrized overrefs/tags/release-1andrefs/remotes/origin/main. Withrefs/tags/xit passed by coincidence even without therefs/heads/guard, becauserefs/tags/xis as long asrefs/heads/and the leftover name was empty. rv672's mutant R1 (the guard removed) now fails both cases.current_branchtakes only git's newline offsymbolic-ref's answer (rstrip("\n")).strip()also took a trailing U+00A0, U+2028 or U+0085 off the branch name. So a root onfeat<U+00A0>beside a branchfeatcommitted on the first and printed a push of the second. A new test putsfeat<U+00A0>besidefeatand runs the printed line: the bump commit reaches origin asfeat<U+00A0>, andfeatis not pushed._ensure_root_cleantakes off only the last newline. The first line keeps git's leading space, soMno longer reads asM(staged) one column left of the lines under it. The dirty-list expectations in the new file and intests/test_non_utf8_git_reads.pynow readM ..., and a new test with two dirty files checks that both lines line up.committedfallback namesgit show --name-only --ignore-submodules=none <sha>.scripts/validate-pins.pyrefuses when they disagree. The ordinary message is byte for byte what it was (checked by rendering both).commit:set tonot-a-sha. Thecommitline then names the gitlink's commit, never that text. rv672's mutant R2 (noCOMMIT_REcheck) now fails it.Fix-round tests, on the tree
a5d473eecommits:24 passed in 19.33s.tests/test_bump_leg*.py, plustests/test_branch_left_bump_leg_setup.pyandtests/test_non_utf8_git_reads.py. Result:281 passed, 1 skipped in 252.20s. The skip is the case-ignoring-disk test.Fix-round mutants: all 18 of the final head fail at least one test. That is the 12 above, re-made on this head, plus R1, R2 and one each for S2, N1, N2 and N3:
refs/heads/guard_moved_fromuncheckedstrip())strip(" \n"))git merge-tree --write-treeofa5d473eeis clean against:5c990708, and2d988200after it moved (rv672's verify round: clean, and the bump-leg set gives 331 passed, 1 skipped on the merge);fix/674-update-shape-apply(d0699029);fix/689-next-push-refspec(fa513848);fix/665-four-guards(151a0861).Review. rv672 re-verified the fix round: APPROVE WITH NITS at
a5d473ee, the nits being these body lines, now fixed. R1 fails both new cases and R2 fails thenot-a-shatest; the bump-leg set gives 331 passed, 1 skipped on the head and on its merge with2d988200.Closes #672
🤖 Generated with Claude Code
Summary by Sourcery
Harden bump-leg branch handling, push instructions, reporting, and dry-run messaging against ambiguous refs, unsafe names, and misleading commit details.
Bug Fixes:
Enhancements:
Tests: