Skip to content

bootstrap.py: a leg whose HEAD is on a branch that differs from the tracking branch only in case (Main for main) is left where it is, so on a disk that folds case branch -f main no longer moves the ref HEAD stands on (#896) - #948

Merged
brettheap merged 4 commits into
mainfrom
fix/896-casefold-branch-f
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #896 (comment)

Head 4541529 (fix round 1: part 1 48222b5e, then the merge 60df34fc of origin/main 450279ca (#944), then part 2), on the first head 80353dd1, on origin/main 50178305 (the v1.1.1 release commit). origin/main is now 450279ca: git merge-tree --write-tree of this head against it is clean, tree aabc059b. One file of templates/ changes: templates/assembly-root/scripts/bootstrap.py. One new test file.

Fix round 1, part 2 (rv896's SHOULD 1, one commit after the merge of main, 45415296). #944 (#932) landed as 450279ca, so _paste_word is on main. _say_twin's command is now git -C {_paste_word(path)} switch {_paste_word(branch)}: for a branch named with a byte that is not UTF-8 the old spelled(shlex.quote(...)) form printed switch 'a\udcffb', which /bin/sh reads as the eight characters it holds, so the pasted command named no branch. _say_twin's docstring and the docstring of test_the_way_out_is_quoted_for_a_shell_... now say _paste_word, where they said the line was printed as move_back_to_pin printed its own before #944. A non-UTF-8 twin test runs the printed switch through /bin/sh -c as printed, and five unit rows read each word back through /bin/sh.

Fix round 1, part 1 (rv896's NITs 1 to 4, one commit, 48222b5e). rv896 found this change READY (issuecomment-6252823490). Part 1 takes the four NITs that need nothing from #944: the make risk now says "the commits a branch spelled like main holds" (an unborn twin holds none); the missing-branch lines print the pin's sha, branch main does not exist (the pin is <sha12>); two _folded_twin rows, refs/notes/Main and refs/tags/xMain, kill the one surviving mutant; the module and _say_twin docstrings say what MAKING a branch hides. The switch line was left for part 2, below, which switches it to #944's _paste_word now that #944 has landed.

A pinned copy: every project's bootstrap.py takes an upstream-changed row, and re-pins it.

The rule

Where HEAD's ref and refs/heads/<branch> are equal under str.casefold() but differ in bytes, bootstrap leaves the leg. No ref moves and none is made. It says where the leg is, why it is left, and the way out.

What each site prints now

Before (all three said the leg was on Main, which keeps its commits (a branch with no commit yet, for the unborn twin below), and moved or made main):

T7 (main behind the pin)   branch main fast-forwarded <old> -> <pin>, the pin; ...
                           the leg was on Main, which keeps its commits; it is on main now
P9 (main at the pin)       on main at <pin> (branch tip == pin)
                           the leg was on Main, which keeps its commits; it is on main now
missing branch             branch main created at <pin> - at the pin
                           the leg was on Main, which keeps its commits; it is on main now

Now (spaces and quoting as printed; the leg is spec):

T7:
  [spec] spec: branch main tip <old> is behind the PIN <pin>, and the checkout is on Main, which differs from main only in case
          a disk that folds case (macOS, Windows) reads Main and main as one branch, so moving main could move the ref HEAD stands on and leave the index at the old commit; the leg is left as it is
          run `git -C spec switch main`, which moves no branch, and then run bootstrap again.
P9:
  [spec] spec: branch main is at the pin <pin>, and the checkout is on Main, which differs from main only in case
          a disk that folds case (macOS, Windows) reads Main and main as one branch, so the leg is already on main there, and bootstrap would say it left a branch that keeps its commits; the leg is left as it is
          run `git -C spec switch main`, which moves no branch, and then run bootstrap again.
missing branch:
  [spec] spec: branch main does not exist (the pin is <pin>), and the checkout is on Main, which differs from main only in case
          a disk that folds case (macOS, Windows) reads Main and main as one branch, so making main could hide the commits a branch spelled like main holds behind the new ref; the leg is left as it is
          not moving the checkout off it. Keep any commit made there on a branch whose name differs from main by more than case, check out the pin in the leg, and run bootstrap again.

T7 and P9 give git -C <leg> switch <branch>, the path and the branch each one _paste_word (#932), as move_back_to_pin's path and upstream are: a name that prints as it is stays shlex.quoted, and one with a byte that is not UTF-8 is a printf that prints the bytes. On a disk that folds case it re-spells HEAD and moves no file, and the next run takes T7's on-branch merge --ff-only path or P9's no-op checkout. Where the two are branches, it puts the leg on the real main, and Main keeps its commits. Every other state prints what it printed: HEAD on refs/heads/main itself, on feature, on mains, detached, on a ref outside refs/heads/.

The emulation, before and after

CI is Linux only, so a disk that folds case is emulated, as the issue's own probe did: a symlink .git/refs/heads/Main -> main, which git's files backend reads through, and HEAD on refs/heads/Main. T7 is the issue's state: main loose and behind the pin, the pin's commit adding NEW-CONTRACT.md and editing README.md.

At 50178305:

before: HEAD -> refs/heads/Main | Main = main = 536671ad3925 | pin = f27b6a92a433 | status ''
bootstrap: branch main fast-forwarded 536671ad3925 -> f27b6a92a433, the pin; origin/main is 536671ad3925, which the pin deliberately does NOT follow
           the leg was on Main, which keeps its commits; it is on main now
after:  HEAD -> refs/heads/main | HEAD = main = f27b6a92a433 | status 'D  NEW-CONTRACT.md;M  README.md'

At this head (a second scaffold, so other shas):

before: HEAD -> refs/heads/Main | Main = main = bf6b7be3d5fa | pin = 19051708a6c9 | status ''
bootstrap: the three T7 lines above
after:  HEAD -> refs/heads/Main | Main = main = bf6b7be3d5fa | status ''

P9 on the base printed on main at <pin> and the the leg was on Main line; at this head it prints the three P9 lines, and HEAD, main and the status are as before. The way out, git -C spec switch main run as printed, puts HEAD on refs/heads/main, moves no branch, and the next run prints fast-forwarded (T7) or on main at <pin> (branch tip == pin) (P9), with git status --short empty.

The missing-branch probe (create_at_pin)

At 50178305, git 2.43.0 and 2.47.1, the same result. checkout -q --no-overwrite-ignore -b main <pin>, from HEAD on Main, main not there:

  • A packed twin with commits (git pack-refs --all, so no loose Main, no main). Before: HEAD on Main, HEAD = Main = the twin's commit, main missing, status clean. The checkout exits 0 and writes the loose file main. On a disk that folds case that file is the file Main reads, so Main now names the pin, and the twin's commit is in no branch and no ref (git branch -a --contains, git for-each-ref --contains: nothing); only HEAD's reflog lists it. This is the harm. The emulation makes the link after the checkout: a link made before a packed Main is a DANGLING link, git reads Main as missing through it (it falls back to packed-refs only when lstat says ENOENT, and a folding disk would say that), HEAD reads as unborn, and the staged g.txt is left. That before-state is not a folding disk's, so the probe has no link before the checkout, and the committed packed test has none at all: a real Main with a commit, packed, is the state of a disk that keeps case, and the lines are the same on both.
  • An unborn twin (HEAD on Main with no commit, the dangling link Main -> main, which a folding disk reads as one missing ref). The checkout moves HEAD to main at the pin and the index is consistent. Nothing is hidden: the twin holds no commit. It is harmless. It is refused all the same: one rule, one helper, and no state of HEAD to branch on; head is not None in the missing-branch path would exempt it, if the coordinator prefers that.

The words follow the packed probe: switch main is no way out of a leg with no main on a disk that keeps case, so the line says to keep the commits on a branch whose name differs from main by more than case, check out the pin (whose sha the first line prints), and run bootstrap again. The risk says "the commits a branch spelled like main holds", which is true of a packed twin, of an unborn twin (it holds none, so nothing is hidden) and of a third spelling beside either (rv896's probe: an unborn Main beside a packed MAIN hides that commit the same way). The committed packed test runs the way out: git branch kept-from-Main, git checkout --detach <the printed sha>, bootstrap prints branch main created at <pin> - at the pin, kept-from-Main and Main both hold the commit.

Tests

tests/test_bootstrap_casefold_twin.py, 32 tests. The leg set-up is test_bootstrap_after_bump.py's fixtures, bumped and fast_forwarded, and test_bootstrap_leg_moves.py's typed and main_of; the helper bootstrap_module is test_bootstrap_branch_bytes.py's. Every test uses the module's own local bare remotes. Each symlink is os.symlink, and the test skips on Windows, and where it raises OSError (a disk that folds case refuses the link: Main is main), with the reason. A test that makes a real Main skips on a disk that folds case. Every assert on the output compares step (a)'s spec-leg lines exactly.

  1. T7 under the emulation: main unchanged, HEAD still refs/heads/Main, git status --short empty, the three lines exact.
  2. The way out runs as printed, then bootstrap again: fast-forwarded, HEAD refs/heads/main at the pin, status empty.
  3. P9 under the emulation, the lines exact, and its way out.
  4. The missing branch: an unborn twin under the emulation, and a packed twin with a commit, with the way out run.
  5. A disk that keeps case: a real loose Main with a commit of its own and HEAD on it, T7's state. The same lines. The way out puts the leg on the real main, bootstrap fast-forwards it, Main keeps its commit.
  6. Unchanged byte for byte: HEAD on main itself, on feature and on mains (3 tests).
  7. _folded_twin's table (15 rows): Main, MAIN, the twin the other way, the branch itself, Main against Main, detached, refs/remotes/origin/main, another branch, a longer name, refs/Heads/main, refs/notes/Main and refs/tags/xMain (each 11 characters before Main, as refs/heads/ is), a final sigma against a sigma (casefold folds it, lower() does not), and a name that is not UTF-8, itself and as a twin. Plus three tests of _say_twin itself: the command is shell-quoted as before for an ordinary pair, a name that is not UTF-8 or holds an ESC is spelled (output is ASCII, no raise), and (part 2) five rows read the two words of the command back through /bin/sh and compare them with the bytes of the names: an ordinary pair, a path with the byte, a branch with the byte, both with a leading dash, and a path with a percent.
  8. (Part 2) A tracking branch a<0xff>b and HEAD on its twin A<0xff>b (T7, --branch, POSIX, on a disk that takes the byte; skipped on Windows, and on macOS, whose disk refuses the byte): the lines name both spelled, the printed command is git -C spec switch "$(printf -- 'a\377b')", run through /bin/sh -c as printed it puts HEAD on refs/heads/a<0xff>b byte for byte, and the next run fast-forwards the branch.

On the base (50178305, the first 26 tests copied in): 23 failed, 3 passed. The three that pass are case 6, by design. Part 2's new test cannot run on the base (it imports test_bootstrap_pasteable_lines.py, which #944 added); on the merged tree before the part 2 change it failed, at the command: - git -C spec switch "$(printf -- 'a\377b')" expected, + git -C spec switch 'a\udcffb' printed. At this head: 32 passed, on git 2.43.0 and 2.47.1.

Mutants (each in a scratch tree, this test file; none survives)

mutant result
compare byte for byte again (_folded_twin never true) 11 failed
the check dropped from T7 / from P9 / from create_at_pin 3 / 1 / 2 failed
casefold() -> lower() 1 failed (the sigma row). Equivalent for ASCII; the sigma row tells them apart, so no ss pair was needed
the None guard dropped (None.startswith) make bootstrap's first run raises in the fixture: 9 errors and 1 failed
a twin with current None (returns true for it) the same 9 errors and 1 failed
switch -> checkout in the printed way out 4 failed (the exact-lines tests). Case 2, which only runs the printed command, still passes: git checkout main does the same here
the refs/heads/ guard dropped (whole refs compared) 1 failed (refs/Heads/main)
held != branch dropped (the branch itself a twin) 7 failed
the path / the branch not shell-quoted 1 failed each
the twin / the branch not spelled 1 failed each
the missing-branch path printing the switch command 3 failed
the missing-branch way out saying a branch for a branch whose name differs ... by more than case 3 failed
R2 (round 1): the refs/heads/ guard dropped, the 11-character slice kept 24 passed on the first 24 tests: it survived. 2 failed on the 26 (refs/notes/Main, refs/tags/xMain): killed.
round 1's re-runs on the 26: the check dropped from create_at_pin; casefold() -> lower() 2 failed; 1 failed
part 2: _paste_word dropped from the path / from the branch / the old spelled(shlex.quote(...)) form restored 3 / 3 / 5 failed of the 32 (the byte-path rows; the end-to-end test and the byte-branch rows; all of them but the ordinary pair). None survives

Runs

The host's load was 85 to 270, so the coordinator scoped them (one pytest process at a time, 15:53Z and 16:15Z).

  • git 2.43.0, tests/test_bootstrap_casefold_twin.py and every tests/test_bootstrap*.py (11 files, the issue's test_bootstrap_after_bump.py, test_bootstrap_leg_moves.py and test_bootstrap_non_utf8_branch.py among them), test_family_add_spelling_leftovers.py, test_pinned_copy_text.py and the trio (test_repo_hygiene.py, test_agents_md_codes.py, test_cli_reference.py): 751 passed, 3 skipped (the three skip test_bootstrap_ref_store.py:102: 2.43 makes no reftable repository) in 14:47. Then test_bom_reads.py and test_validator_child_encoding.py: 188 passed.
  • git 2.47.1, the new file, test_bootstrap_leg_moves.py, test_bootstrap_after_bump.py and the trio: 248 passed in 3:40.
  • Round 1, part 2, at the merged tree (the load was about 50, two processes at a time): git 2.43.0, the new file, test_bootstrap_pasteable_lines.py, test_bootstrap_non_utf8_branch.py, test_bootstrap_leg_moves.py, test_bootstrap_after_bump.py and the trio: 329 passed in 7:04. git 2.47.1, the new file and test_bootstrap_pasteable_lines.py: 72 passed in 2:09. Both ran before the five unit rows were added (a change to the test file alone); the new file was then run again on both: 32 passed each.
  • Round 1, at the part 1 tree (the load was about 40, two processes at a time): git 2.43.0, the new file, test_bootstrap_leg_moves.py, test_bootstrap_after_bump.py and the trio: 250 passed in 3:44. git 2.47.1, the new file: 26 passed in 20 s.
  • The predicted merge with origin/main 64464d31 (a commit of tree d7d8ca11, never pushed): the new file, the trio and test_release_workflow.py: 231 passed (git 2.43.0).
  • A first run of 115 files in 3 shards (git 2.43.0) was stopped at 35%, 28% and 45% when the load reached 270: about 3,100 tests, none failed. It is not counted.

Left to CI's tests job, which runs the whole suite on the head: every file not named above. Of the files git grep -l -E 'assembly-root/scripts/bootstrap|make bootstrap|bootstrap\.py' -- tests names, these 66 were not run: test_adopt_attributes_eol_block.py, test_adopt_clone_template.py, test_adopt_e2e.py, test_adopt_ignored_shape_files.py, test_adopt_mount_ignored.py, test_adopt_nested_attributes.py, test_adopt_pins.py, test_adopt_plan_yaml_quoting.py, test_adopt_reencoded_files.py, test_adopt_relative_submodule_url.py, test_adopt_root_manifest.py, test_adopt_root_symlinks.py, test_adopt_shape_pin_and_work_dir.py, test_adopt_submodules.py, test_adopt_verify.py, test_bump_leg.py, test_bump_leg_dry_run_truth.py, test_bump_leg_rollback.py, test_bump_leg_unreadable.py, test_doctor_legs_members_next.py, test_doctor_machine_row.py, test_doctor_next_is_a_command.py, test_doctor_paste_through_git.py, test_doctor_pin_restore_merges.py, test_family.py, test_family_bootstrap_gate_order.py, test_family_bump_remove_rollback.py, test_family_gitmodules_values.py, test_family_head_lost_gitmodules.py, test_family_holder_bootstrap_texts.py, test_family_makefile_siblings.py, test_family_members_not_a_list.py, test_family_siblings_placement.py, test_leg_path_yaml_scalars.py, test_legs_readers_not_a_list.py, test_load_yaml_refusals.py, test_naming_policy.py, test_non_utf8_name_helpers.py, test_park_resume_targets.py, test_push_branch_refspec.py, test_scaffold_e2e.py, test_scaffold_ignored_shape_files.py, test_scaffold_leg_path_collisions.py, test_scaffold_manifest_values.py, test_scaffold_mount_ignored.py, test_setup_by_hand_commands.py, test_setup_project_py.py, test_setup_sh.py, test_shape_doctor.py, test_siblings_family_name.py, test_siblings_inputs.py, test_template_text_placeholders.py, test_update_shape.py, test_update_shape_add.py, test_update_shape_apply_lines_and_headers.py, test_update_shape_kept_helper.py, test_update_shape_landing.py, test_update_shape_merged_away.py, test_update_shape_pr_base.py, test_validate_family_branch_step.py, test_validate_family_member_path_glob.py, test_validate_family_remedies_typed.py, test_validate_family_typed_states.py, test_validate_pins_lockstep_lines.py, test_validate_workflow_gitmodules_urls.py, test_windows_paths.py. On git 2.47.1 the 2.43 list's other files were not run either.

Merge checks

Docs and complexity

  • Docs. bootstrap.py's module docstring (step (a)) and the docstrings of the three path functions and _move_and_check_out say the rule. I grepped AGENTS.md, README.md, docs/, the assembly root's AGENTS-shape.md and README, and the family root's, for a sentence about step (a)'s paths. None is false: each says a leg is placed only where that loses no commit, and otherwise ... left where it is, saying why, or or says why not, and a twin leg is left, saying why. Nothing else was edited.
  • Cognitive complexity (S3776, 15 allowed), counted by hand and with an AST counter that follows Sonar's rules (each if, elif, else, for, except and ternary, plus nesting; one per run of one boolean operator): place_at_pin 5 -> 6, fast_forward_to_pin 14 -> 15, at the limit, create_at_pin 12 -> 13, _folded_twin 3, _say_twin 1, checkout_tracking_branch 13 (unchanged). If Sonar counts one more in fast_forward_to_pin, the cheapest cure is a helper for its note (two points). Round 1 changes only words, so none changes. The commit message of 80353dd says 14, 6 and 12; that was my hand count before the counter found the why or [] argument in fast_forward_to_pin and create_at_pin. The numbers here are right, and the second commit's message says so; the squash message should not repeat the first commit's sentence.
  • Width. Every new line is within 79 columns in both files. No duplicated block; no assert joins conditions with and.

Decisions and limits

  • One rule for all three paths, the unborn twin included (see the probe). rv896 ruled to keep refusing it and not to add head is not None: an unborn twin can stand beside a packed third spelling whose commit checkout -b hides, and the refusal costs one step (git -C spec checkout --detach <pin>, then bootstrap).
  • casefold() can over-match a real disk: ss and the sharp s fold under it, which a disk's own simple folding may not do. The leg is then left, with true words, where the disk would not need it.
  • The rule is by name, so a reftable leg is left too, where Main and main are two refs on any disk. The way out is true there as well.
  • NFC and NFD twins are out of scope (below).

Checklist candidates (nothing filed)

  1. An NFC/NFD twin does the same harm, and is not caught. APFS also reads a name's NFC and NFD forms as one file. Emulated as the issue's probe was (a symlink from the NFD name to the NFC branch caf + U+00E9, HEAD on the NFD name, --branch the NFC name, git 2.43.0), at 50178305 AND at this head: branch caf<e9> fast-forwarded <old> -> <pin>, the leg was on cafe<U+0301>, which keeps its commits; it is on caf<e9> now, status D NEW-CONTRACT.md and M README.md. casefold() does not normalize; unicodedata.normalize("NFC", ...) in _folded_twin before the compare is the one-line cure, with a test.
  2. bump-leg.py and family.py compare the root's or the holder's branch with tracking_branch byte for byte (branch == project.tracking, branch != family.tracking()): on a disk that folds case a root on Main is on main, and the pull-request-only refusal (bump-leg-on-tracking-branch, family-on-tracking-branch) is not given. Reasoned from the code; not probed.
  3. A HEAD on a ref whose directory is spelled otherwise (git symbolic-ref HEAD refs/Heads/main) is a twin on a disk that folds case, and is not one here: the rule leaves refs outside refs/heads/ alone, as the brief says. Reasoned; very rare.
  4. From rv896, for later: a follow-up for create_at_pin: the same lost-commit harm happens from a HEAD detached at the pin over a packed Main and no main, which the rule does not see (it asks only about HEAD's ref); before checkout -b, ask git for-each-ref refs/heads/ for any name that casefolds to the branch, packed or loose.

Closes #896

🤖 Generated with Claude Code

…racking branch only in case (`Main` for `main`) is left where it is, so on a disk that folds case `branch -f main` no longer moves the ref HEAD stands on (#896)

The assembly root's pinned bootstrap.py compared HEAD's ref with
`refs/heads/<branch>` byte for byte, in `fast_forward_to_pin` (T7),
`place_at_pin` (P9) and `create_at_pin`. A disk that folds case (macOS APFS,
Windows NTFS, by default) reads `refs/heads/Main` and `refs/heads/main` as one
loose file, so `git switch Main` in a leg whose branch is `main` puts HEAD on
the file `branch -f main <pin>` moves. T7 called that leg "on another branch",
moved `main` under HEAD, found HEAD already at the pin on `checkout main`,
printed `fast-forwarded` and `it is on main now`, and left the index and the
work tree at the old commit: the next commit reverted the pin's changes.

`_folded_twin(current, branch)` says whether HEAD's ref is a TWIN of the
branch: under `refs/heads/`, equal under `str.casefold()` and different in
bytes. A detached HEAD and a ref outside `refs/heads/` are never one. Each of
the three paths asks it first and, for a twin, `_say_twin` prints three lines
and no ref moves or is made: where the leg is, why (a disk that folds case
reads the two as one branch, and what that could do), and the way out. T7 and
P9 give `git -C <leg> switch <branch>`, printed as `move_back_to_pin` prints
its command; on a disk that folds case it re-spells HEAD and moves no file,
and the next run takes the on-branch `merge --ff-only` or the no-op checkout.
For a branch that is not there that command has nothing to switch to on a disk
that keeps case, so the line says to keep the commits on a branch whose name
differs by more than case, check out the pin and run bootstrap again: on a disk
that folds case, `checkout -b main` writes the file `Main` reads and hides the
commits `Main` holds, packed or not, behind it. The three path functions take
the leg's `path` for the command, and each grows by one `if` (cognitive
complexity 14, 6 and 12). Every other state prints what it printed.

tests/test_bootstrap_casefold_twin.py emulates a disk that folds case with a
symlink `.git/refs/heads/Main -> main` (CI is Linux only) and holds each path,
the way out run as printed, a disk that keeps case with a real `Main`, the
unchanged states, and the helper's names, a surrogate-escaped one included.

bootstrap.py is a pinned copy: every project's takes an `upstream-changed` row
and re-pins it.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-18T16:50Z) @codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-18T16:52:24.282410Z 80353dd Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

Bootstrap now detects when HEAD is on a branch whose name differs from the tracking branch only by case and leaves the leg untouched across all ref-moving or ref-creating paths, with explicit recovery guidance; a focused test suite validates filesystem emulation, packed and unborn refs, output safety, Unicode and quoting edge cases, and unchanged behavior elsewhere.

Sequence diagram for protecting a casefold twin during bootstrap

sequenceDiagram
    participant Bootstrap
    participant Leg
    participant Git
    participant User
    Bootstrap->>Leg: _head_of(sub)
    Bootstrap->>Bootstrap: _folded_twin(current, branch)
    alt HEAD is on casefold twin Main and target is main
        Bootstrap->>Bootstrap: _say_twin(label, path, branch, current, state, act)
        Bootstrap-->>User: Leave refs, index, and worktree unchanged
        Bootstrap-->>User: Print risk and recovery guidance
        User->>Git: git -C leg switch main
        Git-->>User: Re-spell HEAD without moving a branch
        User->>Bootstrap: Run bootstrap again
    else No casefold twin
        Bootstrap->>Git: Existing move, checkout, or branch creation
    end
Loading

Flow diagram for casefold twin protection in bootstrap

flowchart TD
    A["checkout_tracking_branch"] --> B{"Tracking branch state"}
    B -->|behind pin| C["fast_forward_to_pin"]
    B -->|at pin| D["place_at_pin"]
    B -->|missing| E["create_at_pin"]
    C --> F{"_folded_twin(current, branch)"}
    D --> F
    E --> F
    F -->|yes| G["_say_twin: leave leg unchanged"]
    F -->|no| H["Existing bootstrap path"]
    G --> I["Print risk and recovery guidance"]
Loading

File-Level Changes

Change Details Files
Adds case-insensitive branch-twin detection and prevents bootstrap from moving or creating a tracking ref that could be the ref currently held by HEAD.
  • Introduces _folded_twin for refs/heads/ names using casefold(), while excluding detached, non-head, identical, and surrogate-containing edge cases appropriately.
  • Checks for twins in the behind-pin, at-pin, and missing-branch paths before any ref or checkout operation.
  • Adds centralized _say_twin messaging that explains the filesystem risk, preserves exact branch/path spelling, shell-quotes the recovery command, and provides a distinct recovery path when the tracking branch is absent.
  • Passes the leg path through checkout handling so recovery commands identify the correct leg.
  • Updates module and function documentation to describe the case-folding safety rule.
templates/assembly-root/scripts/bootstrap.py
Adds comprehensive regression coverage for case-folding branch twins and preserves prior bootstrap behavior for unrelated HEAD states.
  • Tests T7, P9, and missing-branch behavior under symlink-based case-folding emulation, including unborn and packed twins.
  • Verifies no refs, HEAD, index, work tree, or pin are changed before the recommended recovery action, and validates the subsequent bootstrap run.
  • Covers real case-preserving filesystems, exact output text, shell quoting, escaped non-UTF-8/control-character names, Unicode casefold() behavior, and helper edge cases.
  • Confirms ordinary main, unrelated branches, detached HEADs, remote refs, and longer names retain previous behavior.
tests/test_bootstrap_casefold_twin.py

Assessment against linked issues

Issue Objective Addressed Explanation
#896 Prevent bootstrap from moving the ref HEAD stands on when its branch name differs from the tracking branch only in case, including when the tracking branch is behind the pin. ✅
#896 Leave the leg unchanged in that case and explain the risk and recovery command, git -C <leg> switch <branch>. ✅
#896 Test the case-folded-ref scenario on Linux using the symlink emulation described in the issue. ✅

Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 80353dd1b3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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".

@brettheap

Copy link
Copy Markdown
Contributor Author

Independent review of #948 (#896) at 80353dd1, for lane openRepoShape-5: READY

No finding is a MUST. On both the symlink emulation and a disk that keeps case, a full snapshot shows nothing moves. The lines are exactly the PR's. The printed way out, run through /bin/sh and followed by bootstrap again, ends on main at the pin with git status --short empty. Every state that is not a twin prints byte for byte what 50178305 prints. There are two SHOULDs: one is a landing condition with #944, the other a follow-up.

Findings

SHOULD 1 (landing). The switch line uses the old quoting.

SHOULD 2 (follow-up). The same lost-commit harm happens from a detached HEAD, which the rule does not see. _folded_twin asks only about HEAD's ref. I measured on git 2.43 with scratchpad/v118/rv896/probe-packed.sh:

  • (a) w896's probe, reproduced: HEAD on a packed Main that holds commit M, and no main. checkout -q --no-overwrite-ignore -b main <pin> exits 0 and writes a loose main. With the fold emulated afterwards, refs/heads/Main reads as the pin, for-each-ref --contains M and branch -a --contains M list nothing, and only HEAD's reflog keeps M. So checkout -b main <pin> does hide the twin's commits.
  • (b) HEAD detached at the pin, the same packed Main, no main: identical result. create_at_pin reaches checkout -b from this state (head == pin, current is None) with no twin check.
  • (c) An "unborn" Main beside a packed MAIN (a third spelling): the commit is hidden the same way (probe-packed-c.sh).
  • Scope: this is outside bootstrap.py: on a disk that folds case, a leg on Main whose tracking branch main is behind the pin is moved under its own HEAD, and bootstrap says it is on main now over an index that undoes the pin #896's Goal, which is about branch -f under HEAD, so it is a follow-up and not a blocker.
  • Direction: before checkout -b, ask git for-each-ref refs/heads/ for any name that casefolds to <branch>, packed or loose.

NIT 1 (check 3, ruling on the unborn twin): keep refusing it, and do not add head is not None.

  • Case (c) shows an unborn twin can stand beside a packed third spelling whose commit checkout -b hides. The exemption would drop that protection.
  • The refusal costs one step. Measured: git -C spec checkout --detach <pin>, then bootstrap, prints branch main created at <pin> — at the pin, with status empty.
  • Wording: the make risk ("could hide the commits Main holds", TWIN_RISKS, :734-746) assumes commits an unborn Main does not have. It could read "the commits a branch spelled like main holds".
  • The keep line (_say_keep(kept=...)) is true on both kinds of disk. I ran it on a packed and on a loose real Main: kept-from-Main and Main both still hold the commit afterwards.

NIT 2. The make lines say "check out the pin" but never print the pin's sha.

  • Where: bootstrap.py:1134-1137; the state is only branch main does not exist.
  • The detached refusal in create_at_pin names the pin; the "which is not a branch" refusal does not either.
  • Suggest branch main does not exist (the pin is <sha>).

NIT 3. One mutant survives all 100 tests in the three files.

  • R2: drop the refs/heads/ guard but keep the 11-character slice.
  • _folded_twin("refs/notes/Main", "main") and ("refs/tags/xMain", "main") then answer True, so the leg gets the twin lines instead of which is neither.
  • Cosmetic, since both refuse. A refs/notes/Main row would kill it.

NIT 4. Text accuracy.

  • Module docstring (:32-41): it says a branch "moved or made" as main could leave "the index at the old commit". For "made", the harm is hiding the twin's commits; the index is at the pin (probe a).
  • _say_twin docstring, "packed or not": a loose Main on a folding disk makes refs/heads/main resolve, so the make path never runs for it.
  • PR body, merge checks:
    • it says slice D is not pushed; it is now, at 0ef875c0, and merges clean;
    • slice D's earlier local head df1bb186 did conflict with this head: both add a block after _on_a_branch (merge-tree exit 1, CONFLICT in bootstrap.py). 0ef875c0 moved slice D's helpers to fix that, so the body's "15 lines below" note was wrong but is now moot.
  • Commit message: "14, 6 and 12" should be 15, 6 and 13, as w896 already said.

Checked and true

  1. The rule (check 1). I ran 20 rows through head's _folded_twin with no exception:

    • False: None; refs/remotes/origin/Main; the byte-equal ref; refs/heads/; refs/heads; a\udcffb against itself.
    • True: Main and MAIN for main; Feature/X for feature/x; final sigma ς against σ and against Σ; Straße/strasse; fix/fix; the Kelvin sign; A\udcffb against a\udcffb.
    • False, so missed: maın (dotless ı) and MAİN; NFD cafe + U+0301 against NFC café; \udcc3\udca9 against é.

    Ruling on casefold():

    • Over-matches: ß/ss, fi/fi and Kelvin; NTFS's 1:1 upcase table treats none of those as equal, and I could not verify APFS. Every twin is also an over-match on a disk that keeps case, and on a reftable leg.
    • Under-matches: NFC/NFD on APFS (w896's candidate). Also dotless ı: Python's "ı".upper() is I, so NTFS's upcase compare probably treats maın as main; reasoned, not run on NTFS.
    • Does it matter: an over-match costs a leg left with true words and one switch; an under-match keeps the old harm only for unusual names. Keep the rule by name.
  2. Each site (check 2). I wrote a 7-test probe (scratchpad/v118/rv896/test_rv896_probe.py). The snapshot covered:

    • the HEAD file, for-each-ref, the loose heads with their symlink targets and the packed-refs bytes;
    • ls-files -s, status --ignored -uall and the sha256 of every work-tree file.

    Cases: T7, P9 and the missing branch on the emulation, and on a disk that keeps case (P9 with a real Main is not in the PR's file; create both packed and loose). All passed:

    • each snapshot is identical before and after bootstrap;
    • the lines match exactly;
    • the way out through /bin/sh and a second bootstrap end on refs/heads/main at the pin with status empty;
    • Main keeps its commit on the disk that keeps case.

    Every sentence is true on both kinds of disk, because each claim about a disk that folds case is stated as a condition.

  3. Unchanged output (check 4):

    • I drove the three functions with patched readers through every non-twin state: 5 branch names × 8 HEAD refs × 4 commits × outcomes × remotes, 23,040 calls. All were byte-identical to 50178305, including HEAD on refs/heads/<branch>, feature, mains, detached, and refs outside refs/heads/.
    • test_bootstrap_leg_moves.py and test_bootstrap_after_bump.py pass at head.
  4. Runs (check 9):

    • git 2.43: the new file, test_bootstrap_leg_moves.py, test_bootstrap_after_bump.py and the trio: 248 passed in 148 s.
    • git 2.47.1: the new file: 24 passed in 14.6 s, no skips.
    • Base check: the new file against 50178305's bootstrap.py: 21 failed and 3 passed (the three unchanged states).
  5. Merges (git merge-tree --write-tree, check 10):

    On the predicted merge with slice D, the new file, my probe and test_assembly_root_unreadable_paths.py pass: 77 passed.

  6. Mutants (check 8): 12 run.

    Result Mutants
    Killed by the new file (10) w896's bytes, drop-t7, itself; my lower(); the twin check after T7's move; _say_twin naming the branch as the twin; the move and place risks swapped; casefold on one side only; the unborn twin exempted; the P9 check after the checkout
    Killed by the existing tests (1) a changed default for _say_keep's kept (9 failures)
    Survives (1) R2, NIT 3
  7. Sonar-style checks (check 7):

    • Cognitive complexity, by S3776's rules: fast_forward_to_pin is 15:

      • the twin if +1;
      • the and in other_branch +1;
      • the first if +1;
      • the elif +1, with its or and and sequences +2;
      • the else +1;
      • if not placed +1, the nested if moved +2 and its else +1;
      • why or [] +1;
      • the remote if +1 and its and +1;
      • if other_branch +1.

      S3776 flags only above 15, so it passes, at the limit. If it ever grows, moving the note block into a helper saves 2. The others: place_at_pin 6, create_at_pin 13 (12 if the conditional expression takes no nesting), _folded_twin 3, _say_twin 1.

    • Duplication: the longest repeated run in the new file is 5 lines (:263-267 equals :289-293), below Sonar's block size.

    • Asserts: none joins conditions with and.

    • Line length: added lines are at most 79 columns (test file) and 78 (bootstrap.py).

  8. Docs (check 6): grepped docs/, AGENTS.md, README.md and the assembly root's AGENTS-shape.md and README.md. No sentence about step (a) is made false. Each says "only where that loses no commit … or says why not", which stays true.

  9. Signatures: no other caller of the three changed signatures exists in scripts/, templates/ or tests/.

Could not verify

  • A real APFS or NTFS disk. In particular: whether NTFS treats dotless ı as i, APFS's exact fold, and the packed "before" state, which a symlink cannot emulate (w896's reasoning holds).
  • git 2.55 (CI's version).
  • Sonar's own analyser; the counts above are by hand.
  • The 66 files w896 left to CI.

Checklist candidates

  1. Change the switch line to _paste_word once bootstrap.py: the merge --ff-only, --continue and --abort lines it prints word a leg's path and an upstream as a shell reads them back, so a byte that is not UTF-8 is a printf and no longer escape text that names no ref (#932) #944 lands, with a non-UTF-8 twin row (SHOULD 1).
  2. Make create_at_pin check every ref under refs/heads/ that folds to the branch, not only HEAD's (SHOULD 2).
  3. Fold NFC/NFD in _folded_twin.
  4. Print the pin's sha in the make refusal.
  5. Add a refs/notes/Main row to the _folded_twin table.
  6. Make the same casefold check in bump-leg.py and family.py's tracking-branch guards (w896's candidate 2).

…ame the commits "a branch spelled like main" holds, and the docstrings say what making a branch hides, so no line over an unborn twin is false (#896)

Part 1 of the fix round on #948, from rv896's review (issuecomment-6252823490),
which found the change READY with four NITs that need nothing from #944.

NIT 1. `TWIN_RISKS["make"]` said making the branch could hide "the commits
Main holds"; an unborn twin holds none. It now says "the commits a branch
spelled like main holds", which is true of a packed twin, an unborn twin and a
third spelling beside either.

NIT 2. The missing-branch refusal told the person to check out the pin and
never printed it. Its first line now reads `branch main does not exist (the
pin is <sha12>), and the checkout is on Main, ...`. No refusal #896 did not
add is changed. The packed-twin test takes the sha from the printed line to
run the way out.

NIT 3. Two `_folded_twin` rows, `refs/notes/Main` and `refs/tags/xMain`
against `main`, both false. Each has 11 characters before `Main`, as
`refs/heads/` does, so a mutant that drops the `refs/heads/` guard and keeps
the 11-character slice answered true for both and survived the 24 tests; the
two rows kill it.

NIT 4. The module docstring says what each act could do: a branch MOVED as
`main` could be the ref HEAD stands on, with the index left at the old commit,
and one MADE as `main` could hide the commits the twin holds behind the new
ref. `_say_twin`'s docstring no longer says "packed or not": a loose `Main` on
a disk that folds case makes `refs/heads/main` resolve, so the missing-branch
path never runs for it.

The `switch` line and the docstrings that compare it with `move_back_to_pin`
are not touched: part 2 switches them to #944's `_paste_word` once #944 lands.
Cognitive complexity is unchanged: place_at_pin 6, fast_forward_to_pin 15,
create_at_pin 13, _folded_twin 3, _say_twin 1. (The first commit's message says
14, 6 and 12; those were a hand count that missed `why or []`.)

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

Copy link
Copy Markdown
Contributor Author

Fix round 1, part 1 on #948 (#896): rv896's NITs 1 to 4, one commit 48222b5e on 80353dd1, pushed

rv896 found #948 READY (issuecomment-6252823490). This commit takes the four NITs that need nothing from #944. The switch line (bootstrap.py:782-783) and the docstrings that compare it with move_back_to_pin are untouched: part 2 switches them to _paste_word after #944 lands. Two files change: templates/assembly-root/scripts/bootstrap.py (+14 -10) and tests/test_bootstrap_casefold_twin.py (+13 -5). Every other line is byte for byte.

NIT 1: the make risk's words

  • Was: making main could hide the commits Main holds behind the new ref. An unborn twin holds none.
  • Now: making main could hide the commits a branch spelled like main holds behind the new ref. It is true of a packed twin, an unborn twin (it holds none, so nothing is hidden) and a third spelling beside either, which is rv896's case (c): an unborn Main beside a packed MAIN.
  • Where: TWIN_RISKS["make"]. The test file's RISKS["make"] is the same words, exactly.

NIT 2: the pin's sha

NIT 3: survivor R2

  • Added: two _folded_twin rows, refs/notes/Main and refs/tags/xMain, each against main, both expected False. Each has 11 characters before Main, as refs/heads/ does.
  • R2 (the refs/heads/ guard dropped, the 11-character slice kept), run through the same mutation harness:
    • on the first head's 24 tests: 24 passed, it survives, as rv896 found;
    • on the 26 tests: 2 failed ([a notes ref], [a tag, 11 characters]): killed.
  • Two earlier mutants re-run on the 26: the check dropped from create_at_pin: 2 failed (the unborn and the packed test); casefold() to lower(): 1 failed (the sigma row).

NIT 4: true text

  • Module docstring (step (a)): it said a branch "moved or made" as main could leave "the index at the old commit". It now says a branch MOVED as main there could be the ref HEAD stands on, with the index left at the old commit, and one MADE as main could hide the commits the twin holds behind the new ref; and that the line for a branch that is not there gives the pin's sha.
  • _say_twin's docstring: "packed or not" is gone. It says checkout -b main would write the file Main reads and hide behind it what Main holds in packed-refs, and that a loose Main on a disk that folds case makes refs/heads/main resolve, so this path never runs for it.

Evidence

Left for part 2 (after #944 lands)

_paste_word(path) and _paste_word(branch) in the switch line; the two docstrings that say the line is printed as move_back_to_pin prints its command (_say_twin's and the test test_the_way_out_is_quoted_for_a_shell_as_the_move_back_line_is's); one non-UTF-8 twin row that runs the printed switch through /bin/sh.

brettheap added a commit that referenced this pull request Oct 18, 2026
… it prints word a leg's path and an upstream as a shell reads them back, so a byte that is not UTF-8 is a `printf` and no longer escape text that names no ref (#932) (#944)

The assembly root's pinned `bootstrap.py` printed `git -C <leg> merge --ff-only <upstream>` (`move_back_to_pin`) and an operation's `git -C <leg> <verb> --continue` and `--abort` (`_say_in_progress`) through `spelled(shlex.quote(...))`. `spelled` writes a byte that is not UTF-8 as #391's `\udcXX` text, and a control character, a U+2028 and a bidirectional control as the escape Python spells it, and `sh` reads that text as the characters it is. For an upstream `origin/a` + byte 0xff + `b`, the line named no ref.

`_paste_word(name)` words ONE path or ref, local to `bootstrap.py` (it imports nothing new from `repo_shape.py`: a pinned copy must not rely on a newer shape copy, #370, #610).

- **Where `spelled` has nothing to escape and this stdout can write every character, it is `spelled(shlex.quote(name))` as it was.** An ordinary name, a name with a space or an apostrophe and a printable name that is not ASCII (`origin/café`) are byte for byte what they were, so every line that was pasteable is unchanged.
- **Any other name is `"$(printf -- '<octal escapes>')"`**, the bytes `os.fsencode` gives the name, each byte outside `[A-Za-z0-9_./:@+=,-]` a three-digit octal escape. Why this form: dash has no `$'...'` before 0.5.13 (Ubuntu's 0.5.12 reads `$'a\377b'` as `$a\377b`), and the `printf` of dash, bash and zsh reads `\NNN`; `--` keeps a name that begins with `-` from being read as an option; `%` and the backslash and the apostrophe are escapes, so the format has no conversion and no quote to end; the double quotes keep the result one word. The word is ASCII, so it is the same on any stdout. It also covers a C1 control, a U+2028 and a bidirectional control in a name, and a name the stdout cannot write (`PYTHONIOENCODING=ascii` and `origin/café`).
- **Two names have no such word and keep `spelled`'s text, as they were:** one that ends in a line feed (`$(...)` strips it, so the word would name another path) and one whose bytes do not read back as the name (`os.fsdecode`). Git refuses a control character in a ref's name, so both can only be a leg's path, and a path with a final line feed is only a hand-made manifest's.

- `move_back_to_pin`: the leg's path and the upstream of the `merge --ff-only` line (`bootstrap.py:1041-1042`).
- `_say_in_progress`: the leg's path of the `--continue` and `--abort` commands (`bootstrap.py:857`).
- The docstrings of both and the module's (a) paragraph say how the words are made.
- Not touched: `checkout_tracking_branch`'s `git submodule update --init {spelled(path)}` (slice D's lines), and `fast_forward_to_pin`, `place_at_pin` and `create_at_pin` (#896's).

An upstream `origin/a` + byte 0xff + `b`, `main` past the pin with no commit of its own, the line pasted into `/bin/sh -c` in the root:

```
before: git -C spec merge --ff-only 'origin/a\udcffb'
        merge: origin/a\udcffb - not something we can merge     (exit 1)
after:  git -C spec merge --ff-only "$(printf -- 'origin/a\377b')"
        main is at the upstream's commit                         (exit 0)
```

`tests/test_bootstrap_pasteable_lines.py`, 45 tests. Each integration test runs the command from the printed line, as printed, through `/bin/sh -c` from the root (one of them also through dash, bash and zsh) and asks where the branch, or the merge, is afterwards, with the stdout lines asserted whole.

- the move-back line for an upstream with the byte 0xff, a C1 control, a U+2028 and a bidirectional control;
- the same for a leg mounted at a path with the byte 0xff, a leading `-` and a `%` (`.gitmodules` holds the byte, and `project.yaml` its `\udcff` escape);
- `_say_in_progress`'s `--continue` and `--abort`, each run, in a leg at a path with the byte 0xff;
- unchanged byte for byte: ASCII, an apostrophe, `origin/café`, a CJK name, a space, a no-break space, the empty name, `=x`;
- `PYTHONIOENCODING=ascii` with `origin/café`: the `printf` word;
- the helper's words for each byte class and a digit after an escape (exact, and read back through `/bin/sh`), the two names with no word, the legacy Windows code page, a stdout that is None, and no new import from `repo_shape`.

Two existing tests pinned the old line and now pin the new one: `test_bootstrap_after_bump.py::test_the_branch_and_its_upstream_are_spelled_in_the_move_back` and the byte row and the `ascii` test of `test_bootstrap_non_utf8_branch.py`. The prose in the same line (the upstream as a person reads it) is still spelled.

**Fail on base (`50178305`'s `bootstrap.py`):** 41 of the 45 fail (collected 45, no skip). The four that pass by design are the printable-upstream line's three rows (ASCII, an apostrophe, `café`: the line must be what it was) and the no-new-import test.

**Pass at head:** 45 passed (inside the runs below).

Load on this host made a full run impractical (a load of 85 to 270 on 32 cores), so the runs are scoped as the coordinator asked. One pytest process at a time, `run9s.sh` with `SHARDS=1`, the real `gh` unreachable.

| git | files | result |
|---|---|---|
| 2.43.0 (the host), at this head | every `tests/test_bootstrap*.py` (11 files, the new one included), `test_pinned_copy_text.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py` | 702 passed, 3 skipped (`test_bootstrap_ref_store.py`'s reftable rows: this git makes no reftable repository) |
| 2.47.1 (a local build), at this head | the new file, `test_bootstrap_non_utf8_branch.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py` | 223 passed |
| 2.43.0, a scratch merge of this head with `origin/main` `64464d31` | the new file, `test_bootstrap_non_utf8_branch.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_pinned_copy_text.py` | 313 passed |

Before the commit, on 2.43.0 with the tree uncommitted, the new file, eight other `test_bootstrap*` files, `test_leg_path_yaml_scalars.py` and `test_non_utf8_name_helpers.py` passed as well (the last two are the only ones of the list below that ran).

**Left to CI's `tests` job, which runs the whole suite on the head (59 files that mention `bootstrap.py`):** every `test_adopt_*` that does (13), `test_bom_reads`, the four `test_bump_leg*`, `test_doctor_*` (5), `test_family*` (8), `test_leg_path_yaml_scalars`, `test_legs_readers_not_a_list`, `test_load_yaml_refusals`, `test_non_utf8_name_helpers`, `test_park_resume_targets`, `test_push_branch_refspec`, `test_scaffold_*` (5), `test_setup_project_py`, `test_setup_sh`, `test_shape_doctor`, `test_siblings_family_name`, `test_template_text_placeholders`, `test_update_shape*` (7), `test_validate_family_branch_step`, `test_validate_pins_lockstep_lines`, `test_validate_workflow_gitmodules_urls`, `test_validator_child_encoding` and `test_windows_paths`. None of them reads a line this change moves: the lines are asserted in `test_bootstrap_after_bump.py`, `test_bootstrap_non_utf8_branch.py` and the new file, all run.

Each applied alone to this head's `bootstrap.py`, with the new file run (`-x`); every one is killed, none survives.

| mutant | killed by |
|---|---|
| the helper returns `spelled(shlex.quote(name))` always | `test_the_printf_word_prints_the_bytes_of_the_name` |
| the octal escape off by one (`byte - 1`) | `test_the_printf_word_prints_the_bytes_of_the_name` |
| the escape unpadded (`:o`, a digit after it is read into it) | the same test, the row with `%` |
| a printable name takes the `printf` branch | `test_a_name_with_nothing_to_escape_is_the_word_it_was` |
| `_prints_as_it_is` is `str.isprintable` | the same, the no-break-space row |
| `_prints_as_it_is` is always true | `test_the_printf_word_prints_the_bytes_of_the_name` |
| no `--` after `printf` | the same, the first row (the exact word) |
| no double quotes round the substitution | the same, the first row |
| `%` is a bare byte | the same, the `%` row |
| `/` is not a bare byte | `test_the_move_back_command_is_pasteable_whatever_the_upstream_is_named[byte-0xff]` |
| the final-line-feed guard dropped | `test_a_name_with_no_printf_word_keeps_the_text_it_had` |
| the `os.fsdecode` round-trip guard dropped | `test_a_name_the_codec_would_not_read_back_keeps_the_text_it_had` (it survived until that test was added) |
| the stdout's encoding not asked | `test_a_stdout_that_cannot_write_the_name_gets_the_printf_word` |
| the helper dropped from `_say_in_progress` | `test_the_commands_that_end_an_operation_are_pasteable_whatever_the_path[--continue]` |
| the helper dropped from `move_back_to_pin`'s path | `test_the_move_back_command_is_pasteable_whatever_the_legs_path_is_named[byte-0xff]` |
| the helper dropped from `move_back_to_pin`'s upstream | `test_the_move_back_command_is_pasteable_whatever_the_upstream_is_named[byte-0xff]` |

`git merge-tree --write-tree` of this head, clean (exit 0, one tree id and no conflict line) against `origin/main` (`64464d31`, which moved from `50178305` while this was written; it touches the family root's `bootstrap.py` and three tests), against `fix/430-d-assembly-root-guards` and against `fix/896-casefold-branch-f` (their local heads `df1bb186` and `80353dd1`; neither branch is on origin yet, so these are an early look to be repeated when they are pushed).

#896's head adds `_say_twin`, whose way out is `command = (f"git -C {spelled(shlex.quote(path))} switch " f"{spelled(shlex.quote(branch))}")`. A branch can hold the byte 0xff past `_argument_fault`, so it has this defect. Whichever of the two lands second should make it `command = f"git -C {_paste_word(path)} switch {_paste_word(branch)}"`. The helper is defined above `_say_in_progress`, so the order of the functions does not matter, and the two diffs touch different hunks.

A pinned copy: every project's `bootstrap.py` takes an `upstream-changed` row, and re-pins it.

Review: rv932, lane 5's independent reviewer, not the writer (w932): READY at `ec2017cf`, the head that lands (issuecomment-6252637720), with no MUST. Its SHOULD 2 (the `switch` line of #896) rides in #948's next round; SHOULD 1 (a leading `=`, which zsh expands), SHOULD 3 (the NOT INITIALIZED line) and the NITs go to a follow-up. CI (Brett Heap's ruling 27): H0 is the head ec2017c itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 5017830; and the local gate passed on its predicted squash tree 33ebc48 over main ce0245d: the bootstrap_after_bump, bootstrap_non_utf8_branch, bootstrap_pasteable_lines, repo_hygiene, agents_md_codes, cli_reference sets, 275 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #932

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h and the branch with `_paste_word`, so a branch that is not UTF-8 pastes into a shell as the bytes it is (#896)

Part 2 of the fix round on #948, from rv896's SHOULD 1, now that #944 (#932)
has landed as 450279c and `_paste_word` is on main. The first commit's merge
of origin/main is the commit before this one.

`_say_twin` built its command with `spelled(shlex.quote(...))`, the form #932
replaced. For a branch named with a byte that is not UTF-8 that printed
`git -C spec switch 'a\udcffb'`, and `/bin/sh` reads that word as the eight
characters it holds, so the pasted command named no branch. The command is now
`git -C {_paste_word(path)} switch {_paste_word(branch)}`, as #944's hand-over
gave it: a name that prints as it is stays `shlex.quote`d, and one with a byte
that is not UTF-8 is a `printf` that prints the bytes. `_say_twin`'s docstring
and `test_the_way_out_is_quoted_for_a_shell_...` say so, where they said the
command was printed as `move_back_to_pin` printed it before #944.

Tests: one end-to-end case with a tracking branch `a<0xff>b` and HEAD on its
twin `A<0xff>b` (T7, `--branch`): bootstrap leaves the leg, the printed
command is `git -C spec switch "$(printf -- 'a\377b')"`, run through
`/bin/sh -c` as printed it puts HEAD on `refs/heads/a<0xff>b` byte for byte,
and the next run fast-forwards it. Five unit rows read the two words of the
command back through `/bin/sh` and compare them with the bytes of the names
(an ordinary pair, a path with the byte, a branch with the byte, both with a
leading dash, a path with a percent). Dropping `_paste_word` from the path,
from the branch, or restoring the old form each fails 3, 3 and 5 of the 32.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

Fix round 1, part 2 on #948 (#896): rv896's SHOULD 1, the switch line through _paste_word, one commit 45415296 after the merge 60df34fc of main 450279ca, pushed

#944 (#932) landed as 450279ca, so _paste_word is on main. git merge --no-ff origin/main (identity by -c, no rebase, no force) was clean, as predicted: merge commit 60df34fc, parents 48222b5e and 450279ca, tree 664513ff, the tree git merge-tree --write-tree gave before the merge. The change is one commit on it, in templates/assembly-root/scripts/bootstrap.py (+16 -13) and tests/test_bootstrap_casefold_twin.py (+101 -9). Every other line is byte for byte.

The change

The tests

  • The non-UTF-8 twin, run through sh (test_the_way_out_of_a_twin_that_is_not_utf_8_is_one_a_shell_reads): T7 with the tracking branch a<0xff>b (--branch) and HEAD on its symlink twin A<0xff>b. It skips on Windows, and where the disk refuses the byte (needs_the_byte, as test_bootstrap_non_utf8_branch.py skips; macOS refuses it). Bootstrap leaves the leg: the first spec line names both spelled (branch a\udcffb tip <old> is behind the PIN <pin>, and the checkout is on A\udcffb, which differs from a\udcffb only in case), HEAD is refs/heads/A\xffb byte for byte, the branch has not moved and the status is empty. The printed command equals git -C spec switch "$(printf -- 'a\377b')". It is then run AS PRINTED through /bin/sh -c (pasted, from test_bootstrap_pasteable_lines.py), and HEAD is refs/heads/a\xffb byte for byte. The next bootstrap prints branch a\udcffb fast-forwarded <old> -> <pin>, the pin and leaves the status empty.
  • At the code before the change (the merged tree with _say_twin as 48222b5e had it) that test fails at the command: - git -C spec switch "$(printf -- 'a\377b')" expected, + git -C spec switch 'a\udcffb' printed (1 failed, 26 passed; log r2-pre-fix.log). 48222b5e itself cannot run the test: the test imports test_bootstrap_pasteable_lines.py, which bootstrap.py: the merge --ff-only, --continue and --abort lines it prints word a leg's path and an upstream as a shell reads them back, so a byte that is not UTF-8 is a printf and no longer escape text that names no ref (#932) #944 added.
  • Five unit rows (test_a_shell_reads_each_word_of_the_way_out_as_the_bytes_of_its_name): _say_twin is called, the command is taken from the printed line, and /bin/sh reads its two words (printf '%s\0%s' <path-word> <branch-word>); they must be the bytes of the path and of the branch. Rows: an ordinary pair, a path with the byte, a branch with the byte, both with the path leading with a dash, and a path with a percent. These make the path half of the change testable: the leg's path in the end-to-end test is the ordinary spec.

Runs

The load was about 50. Two pytest processes at a time, through run9.sh; logs are under $Q/v118/w896/.

  • git 2.43.0, at the merged tree before the unit rows were added: the new file, test_bootstrap_pasteable_lines.py, test_bootstrap_non_utf8_branch.py, test_bootstrap_leg_moves.py, test_bootstrap_after_bump.py and the trio: 329 passed in 7:04 (r2-run-243.log).
  • git 2.47.1, the same tree: the new file and test_bootstrap_pasteable_lines.py: 72 passed in 2:09 (r2-run-2471.log).
  • The unit rows are a change to the test file alone, so the new file was run again on both gits at the final tree: git 2.43.0 32 passed in 16 s (r2-new-243.log), git 2.47.1 32 passed in 18 s (r2-new-2471.log). No test was skipped.

Mutants (the 32 tests, each in a scratch tree at the merged head)

mutant result
_paste_word dropped from the path (spelled(shlex.quote(path))) 3 failed: the rows of a path with the byte, both with a dash, and a path with a percent
_paste_word dropped from the branch 3 failed: the end-to-end test and the rows of a branch with the byte and of both
the old form restored for both 5 failed: the end-to-end test and four rows; the ordinary pair passes, by design (the old form is the same word for it)

None survives.

Merge checks

git merge-tree --write-tree, re-fetched first: origin/main 450279ca (unchanged since the merge): clean, tree aabc059b. Slice D's 0ef875c0 (#958, unchanged on origin): clean, tree af0a54b7.

@brettheap

Copy link
Copy Markdown
Contributor Author

Re-check of #948 (#896)'s fix round 1 at 45415296, for lane openRepoShape-5: READY

The round answers all four NITs and SHOULD 1, and nothing new is wrong. The merge 60df34fc is a plain merge. Every output that is not a twin's prints byte for byte what main 450279ca printed.

Findings

No MUST and no SHOULD. One note, outside this PR:

NIT (not #948's to fix). A leading = is still a bare word, which zsh expands.

Checked and true

  1. The merge (check 1). git show --remerge-diff 60df34fc is 0 bytes. Its tree 664513ff equals the ort remerge of its parents 48222b5e and 450279ca.

  2. Part 1 (check 2). The diff 80353dd1..48222b5e holds only the four NIT hunks; every other line is unchanged.

    • NIT 1: the risk line now reads "the commits a branch spelled like main holds". The unborn twin is still refused, as I ruled.
    • NIT 2: the line now reads branch main does not exist (the pin is <sha12>). The packed test checks out the sha it reads from the printed line.
    • NIT 3: rows refs/notes/Main and refs/tags/xMain were added. R2 re-run at 45415296: 2 failed (those two rows), so it is killed.
    • NIT 4: the module docstring now separates moved from made, and the "packed or not" wording in _say_twin is gone. 48222b5e's message corrects the earlier complexity figures (15, 6 and 13).
  3. Part 2, SHOULD 1 (check 3).

    • The words read back exactly. I printed 13 path and branch pairs through the head's _say_twin. Each word read back as the exact bytes, as two arguments, under /bin/sh, /bin/dash and /bin/bash, in LC_ALL=C and C.UTF-8. The pairs covered the 0xff byte in the path, the branch or both; a leading -; % (with and without 0xff); a space and an apostrophe; and a leading = together with 0xff, which takes the printf form. zsh gave 10 of 13; the 3 failures are the bare leading-= pairs in the NIT.

    • The end-to-end test. With the old spelled(shlex.quote(...)) line restored, it fails:

      - git -C spec switch "$(printf -- 'a\377b')"
      + git -C spec switch 'a\udcffb'
      

      It passes at head.

  4. Unchanged output (check 4).

    • I drove place_at_pin, fast_forward_to_pin and create_at_pin through every non-twin state: 23,040 calls, all byte-identical to main 450279ca.
    • _say_in_progress and move_back_to_pin have no hunks against main.
    • test_bootstrap_leg_moves.py, test_bootstrap_after_bump.py and test_bootstrap_pasteable_lines.py pass; they were in the 2.43 run.
  5. Mutants (check 5). All five killed, run against the casefold file (32 tests).

    Mutant Result
    w896's: old form restored for both words 5 failed: the end-to-end test and 4 unit rows
    w896's: old form for the path only 3 failed
    Mine: path and branch words swapped 11 failed
    Mine: (the pin is <sha>) printing head instead of pin 2 failed
    R2 (from my first review) 2 failed
  6. Runs at 45415296 through run9.sh (check 6):

    • git 2.43.0: the casefold, pasteable, test_bootstrap_non_utf8_branch.py, test_bootstrap_leg_moves.py and test_bootstrap_after_bump.py files plus the trio: 334 passed, 0 skipped, in 223 s.
    • git 2.47.1: the casefold file: 32 passed, 0 skipped.
  7. Merges (check 7). origin/main has moved to 2f5cabf5, re-fetched; it is a validate-pins.py and repo_shape.py change that leaves spelled alone.

    • With 2f5cabf5: clean, tree 20bcfd13. On a predicted merge commit ae4d7f8b, the casefold file, the pasteable file and test_validate_pins_spelled_and_named.py pass: 128 passed.
    • With slice D 0ef875c0: clean, tree af0a54b7.
    • fix/961-paste-words is not on origin.
  8. Sonar-style checks (check 8).

    • Complexity: the round adds no branch. The ASTs of fast_forward_to_pin, place_at_pin and _folded_twin are identical to 80353dd1 once docstrings are set aside. create_at_pin and _say_twin changed only inside f-strings. So fast_forward_to_pin stays at 15, create_at_pin at 13 and _say_twin at 1.
    • Duplication: the longest repeated run in the test file is 6 lines (:267 and :294, the two missing-branch tests). Neither the end-to-end test nor the five unit rows share a run longer than 4 lines with other tests.
    • Asserts: none joins conditions with and.
    • Line length: lines added since 80353dd1 are at most 79 columns.
  9. Authorship. 48222b5e and 45415296 are by Brett Heap <service@opensoft.dev>, with the Lane and Co-Authored-By trailers.

Could not verify

  • A real APFS or NTFS disk.
  • git 2.55 (CI's version).
  • Sonar's own analyser; the counts above are by hand plus an AST comparison.
  • Whether the PR body's merge-check section was updated. The body is not saved in scratch, and the accounts give the new trees.

@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 2 days and 23 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit ebbd9fa into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/896-casefold-branch-f branch October 18, 2026 20:02
brettheap pushed a commit that referenced this pull request Oct 19, 2026
…eads/` branch, packed or loose, folds to it, whatever else holds its commits, and the way out renames the twin with `branch -m`, HEAD's twin included (#962)

Fix round 1 on #984, on rv984's review of 5a435ac (READY; SHOULD 1 was a
decision, and lane 5 ruled: follow #962's Goal).

SHOULD 1. `_kept_elsewhere`'s sparing is dropped with `_spelling_at_risk`.
`_folded_spelling` returns the first `refs/heads/` ref, packed or loose, that
folds (NFC, then casefold) to the branch, whether or not a tag, a
remote-tracking ref, another branch, the stash or the pin holds its commits:
the ref `checkout -b` would make is one a folding disk reads as a branch that
is already there. The way out renames the twin, `git -C <leg> branch -m
<twin> <name>`, the path and both names through `_paste_word`, and
`_kept_name` chooses a name that is not there: `<twin>-kept`, then `-kept-2`
and on, the first no branch of the leg folds to, so the line can be pasted
as printed.

#948's HEAD-twin keep line (the missing branch, HEAD on a twin) is
`_say_spelling` with `where` = `the checkout is on`: its first two lines are
byte for byte #948's, and the third is the rename. git moves HEAD with the
rename of the branch HEAD is on, an unborn one included, so the next run
says `the leg was on <name>, which keeps its commits` and makes the branch.
`_say_twin` keeps T7 and P9; `_say_keep` is back to the one-parameter text
main had before #948, which no twin line uses now.

NIT 1. `_folded_twin` rows for `main` and U+00B2 against `main2`, and U+2460
against `1`, both False: NFKC would fold them, so the NFKC mutant is
killed.

NIT 3. The docstrings and the tests say what a real folding disk does with a
packed twin (the name is shadowed and the state is fragile; reasoned from
git's files backend, not measured on APFS or NTFS), not that the commit is in
no ref.

Tests: tests/test_bootstrap_create_twins.py, 31 tests (20 fail on 5a435ac,
11 pass there by design), and 3 changed tests in test_bootstrap_casefold_twin.py.
bootstrap.py is a pinned copy: every project's `bootstrap.py` takes an
`upstream-changed` row, and re-pins it.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 19, 2026
…eads/` branch, packed or loose, folds to it, and a twin folds in NFC first, so an NFD name moves no leg, and the way out renames the twin, HEAD's twin included (#962) (#984)

Follows #948 (#896), which landed as `ebbd9fa4`.

**Head `f1cceddf`**: `414e9e0a` (round 0, on #948's head `45415296`), `5a435acd` (the merge of origin/main `ebbd9fa4`, #948's squash) and `f1cceddf` (fix round 1, on rv984's review of `5a435acd`). The squash is not an ancestor of this branch, so the merge conflicted in `bootstrap.py` in seven hunks, and this branch's side was taken whole: main's `bootstrap.py` (blob `e31385cd`) is `45415296`'s byte for byte, so nothing of main's was dropped. Against `ebbd9fa4` the branch is three files, `templates/assembly-root/scripts/bootstrap.py`, `tests/test_bootstrap_casefold_twin.py` (three tests changed) and the new `tests/test_bootstrap_create_twins.py` (+737, -68). `git merge-tree --write-tree` of the head against origin/main `c4dd19dc` is clean. One file of `templates/` changes.

A pinned copy: every project's `bootstrap.py` takes an `upstream-changed` row, and re-pins it.

#948 leaves a leg alone when HEAD's own ref is a TWIN of the tracking branch, a branch that folds to it on a disk that folds case (`Main` for `main`). Two things were not covered:

1. **Only HEAD's ref was asked.** `create_at_pin` asked about HEAD's ref, so a leg DETACHED AT THE PIN over a packed `Main`, and a leg on an unborn `Main` beside a packed `MAIN`, ran `checkout -b main <pin>` with no refusal. On a disk that folds case that writes the loose file `Main` and `main` are both read from. What such a disk then does to the packed `Main` is reasoned from git's files backend (rv984), and not measured on APFS or NTFS: the packed entry stays, `for-each-ref` goes on listing it at its old sha while `rev-parse Main` reads the new file `main`, and a later `git branch -D Main` unlinks that file. The commit is still in a ref; its name is shadowed, and the state is fragile.
2. **The fold was `casefold()` alone.** APFS also reads the NFC and the NFD spelling of a name as one file, so a leg on the NFD spelling of `caf` and U+00E9 is a twin that `_folded_twin` did not see: bootstrap fast-forwarded the NFC branch under HEAD's feet and the index was left at the old commit (`D NEW-CONTRACT.md;M README.md`), the same harm as #896's.

`create_at_pin` asks `git for-each-ref refs/heads/` (packed refs are listed too) for a branch that is a twin of the tracking branch (`_folded_spelling`), just before the `checkout -b` and after every refusal that was already there. If one is found, `_say_spelling` leaves the leg and prints three lines: where the leg is and the pin's sha, why it is left, and the way out. Nothing is moved and nothing is made.

- **EVERY twin is a reason to leave**, whether or not a tag, a remote-tracking ref, another branch, the stash or the pin holds its commits (fix round 1, below).
- A twin is only ever left in place, never moved (#948's rule, kept), and every output that is not a twin's is byte for byte what it was. `fast_forward_to_pin` is not changed in complexity (15, as it was).

`_fold(name)` is `unicodedata.normalize("NFC", name).casefold()`, used by `_folded_twin` and so by all three step (a) paths, and by item 1. A name held as surrogate escapes (#704) passes through `normalize` as the text it is and raises nothing (a test). NFKC is not used: APFS does not fold compatibility forms, and `main` with U+00B2 stays no twin of `main2` (two rows, and the NFKC mutant is killed).

`_twin_words` words the refusal by what the twin differs in. A twin that is one under `casefold()` alone prints #896's words byte for byte (`only in case`; `a disk that folds case (macOS, Windows)`). Any other says it differs `only in case or Unicode form`, and that `a disk that folds case or Unicode forms (macOS folds both, Windows folds case)` reads the two as one, because Windows folds case only and the words must not say it folds Unicode forms.

**This replaces round 0's `_kept_elsewhere`, a departure from the brief that rv984 found breaks #962's Goal, and lane 5 ruled to follow the Goal.** Round 0 spared a twin whose commit was an ancestor of the pin, or was held by another branch, a tag, a remote-tracking ref or the stash, and its way out kept the commits on another branch and ran again. rv984 measured that in each case that made `main`, a folding disk would read `Main` as the pin: the Goal says bootstrap never makes, with `checkout -b`, a ref that a folding disk reads as a branch that already exists, and these cases broke its letter. The loop the sparing avoided is real only for a way out that leaves the twin where it is: #948's keep line ("keep any commit ... check out the pin") leaves `Main` in place, so the next run refuses again. A way out that REMOVES the twin ends it.

- **`_kept_elsewhere` and `_spelling_at_risk` are gone.** `_folded_spelling(sub, branch)` returns the first `refs/heads/` ref, packed or loose, that folds to the branch, whatever else holds its commits. `_heads(sub)` is the shared listing.
- **The way out is a rename:** ``git -C <leg> branch -m <twin> <name>``, the path and both names through `_paste_word`. The twin disappears, its commits stay on the renamed branch, and the next run makes `main`.
- **The line can be pasted as printed.** `_kept_name(sub, twin)` chooses a name that is not there: `<twin>-kept`, then `-kept-2` and on, the first that no branch of the leg folds to (so `Main-kept` is taken when `main-kept` is there, as on a folding disk they are one file). It is longer than the twin, so it differs from the tracking branch by more than case or Unicode form.
- **#948's HEAD-twin keep line is amended the same way.** A leg ON a twin with `main` missing goes through `_say_spelling` with `where` = `the checkout is on`: its first two lines are byte for byte #948's, and the third is the rename. `git branch -m` moves HEAD with the rename of the branch HEAD is on, an unborn branch included (probed on git 2.43.0 and 2.47.1, with and without the dangling link the emulation uses), so the next run takes the ordinary `the leg was on <name>, which keeps its commits` path. `_say_twin` keeps T7 and P9, whose `switch` way out is unchanged.
- **`_say_keep`** is back to the one-parameter text main had before #948: no twin line uses its second parameter now, and no non-twin line changes.

**What it costs:** several twins are named one at a time, each rename one run. A name is chosen when the line prints; if something makes it first, git refuses the rename and the person picks another. The cost of the old sparing, a shadowed name on a folding disk, is gone with it.

At `5a435acd` and at `f1cceddf`. A packed `Main` holding a commit, `main` missing:

    detached over a packed Main, at 5a435ac:
      [spec] spec: branch main does not exist (the pin is <pin>), and the leg has the branch Main, which differs from main only in case
              ... the leg is left as it is
              keep the commits Main holds on a branch whose name differs from main by more than case: `git -C spec branch <another name> Main`, and run bootstrap again.
    at f1ccedd, the third line is:
              rename Main to a name that differs from main by more than case, its commits staying on it: `git -C spec branch -m Main Main-kept`, and run bootstrap again.
      run 2 after the pasted line: [spec] spec: branch main created at <pin> - at the pin
      after: HEAD -> refs/heads/main, heads: Main-kept (the commit), main (the pin)

    the same, the commit held by a tag (rv984's SHOULD 1 case):
      at 5a435ac: [spec] spec: branch main created at <pin> - at the pin, with Main left in packed-refs
      at f1ccedd: the refusal above, and `Main-kept` after the pasted rename

    HEAD on the packed Main (#948's keep line):
      at 5a435ac: "not moving the checkout off it. Keep any commit made there on a branch whose name differs from main by more than case, check out the pin in the leg, and run bootstrap again."
      at f1ccedd: the rename line, then HEAD -> refs/heads/Main-kept; run 2: branch main created at <pin>, "the leg was on Main-kept, which keeps its commits; it is on main now"

    HEAD on an unborn Main beside a packed MAIN:
      at f1ccedd: run 1 names Main (rename Main Main-kept; HEAD goes with it); run 2 names MAIN (rename MAIN MAIN-kept); run 3 makes main, "the leg was on Main-kept, a branch with no commit yet"

The NFD twin of an NFC tracking branch, T7, is as in round 0: at `45415296` bootstrap fast-forwarded `caf` and U+00E9 under HEAD (`D NEW-CONTRACT.md;M README.md`); at this head the leg is left, `switch` is printed, and nothing moves.

`tests/test_bootstrap_create_twins.py`, 31 tests. It uses #948's emulation and helpers (`test_bootstrap_casefold_twin`), `main_of` (`test_bootstrap_leg_moves`), `head_bytes` (`test_bootstrap_non_utf8_branch`) and `pasted` (`test_bootstrap_pasteable_lines`); the lines are compared exactly, and every way out is run as printed through `/bin/sh`.

1. A leg detached at the pin over a real spelling `Main`, packed and loose (2): the lines exact, nothing moved, the rename run as printed, the next run makes `main`, and the commit is on `Main-kept`.
2. A leg ON a packed `Main` with `main` missing: the rename moves HEAD, and the next run makes `main` and says the leg was on `Main-kept`.
3. An unborn `Main` beside a packed `MAIN`: each twin named in turn and renamed, the third run makes `main`.
4. Two twins, `MAIN` and `Main`: the second rename is `Main-kept-2`, not `Main-kept`, which differs from `MAIN-kept` only in case.
5. A twin is a reason to leave however its commit is held (5 rows: the pin alone, another branch, a tag, a remote-tracking ref, the stash).
6. `mains` is no twin: `main` is made, as it was.
7. The NFD T7 test, and a missing branch beside its NFD spelling, left and renamed.
8. `_kept_name` (4 rows, and the NFC/NFD one), and a shell reads each word of the rename as the bytes of its names (4 rows, a byte that is not UTF-8 and a dash-led path included).
9. `_folded_twin` over Unicode forms (8 rows, with `main` and U+00B2 against `main2`, and U+2460 against `1`, expecting False), and the fold raises nothing on a name that is not UTF-8.

**At `5a435acd`, with these test files copied in: 23 failed, 40 passed.** 20 of the 31 tests of the new file fail there (cases 1 to 5, the NFD missing-branch test, the five `_kept_name` tests and the four shell rows) and the three changed tests of the casefold file fail (the unborn twin, the packed twin, the non-UTF-8 lines). The 11 that pass there pass by design: `mains`, the NFD T7 test, the eight `_folded_twin` rows (the two NFKC rows guard a mutant, not a base fault) and the fold test. At this head: 63 passed.

| mutant | failed |
|---|---|
| the check dropped from `create_at_pin` (item 1) | 10 |
| `_fold`: `casefold()` alone, no NFC | 6 |
| `_twin_words`: always the case words | 2 |
| `_say_spelling` names the branch, not the twin | 14 |
| `_fold`: NFKC in place of NFC (rv984's O1) | 2 (the two new rows) |
| the printed `branch -m` is a plain `branch` (a copy, not a rename) | 17 |
| the rename's two arguments swapped | 17 |
| `_kept_name` never loops (always `-kept`) | 4 |
| `_kept_name` compares byte for byte, with no fold | 3 |
| `_kept_name` folds the taken names but not the candidate | 3 |
| the HEAD-twin line says `the leg has the branch` for `the checkout is on` | 4 |
| the HEAD-twin check dropped from `create_at_pin` | 4 |
| the sparing put back (a twin another ref holds is left alone) | 5 |
| the new name not `_paste_word`ed (`spelled(shlex.quote(...))`) | 2 |
| the twin not `_paste_word`ed | 2 |
| `_heads` lists loose refs only | 13 |

Round 0's four mutants of `_kept_elsewhere` (the ancestor rule, the holders, heads-only holders, the first twin) are gone with the code.

Cognitive complexity (S3776, counted from the ast): `create_at_pin` 14, `fast_forward_to_pin` 15 (unchanged), `place_at_pin` 6, `_folded_spelling` 3, `_folded_twin` 3, `_heads` 1, `_kept_name` 1, `_twin_words` 1, `_say_spelling` 0, `_say_twin` 0. New lines are within 79 columns and ASCII; the docstrings spell the Unicode forms (`cafe` and U+0301, `caf` and U+00E9). `_paste_word` (#973 edits it) and the NOT INITIALIZED line (#958 moves it) are not touched.

At the tree of `f1cceddf` (the head), git 2.43.0: the new file, `test_bootstrap_casefold_twin.py`, `test_bootstrap_pasteable_lines.py`, `test_bootstrap_leg_moves.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py` and `test_pinned_copy_text.py`, **392 passed**. git 2.47.1: the new file and the casefold file, **63 passed**. Output that is not a twin's: a differential of 27 states (the tracking branch missing, at the pin, behind it, ahead of it, by HEAD on `main`, detached at the pin, detached elsewhere, on `feature`, on an unborn `feature`, on a ref outside `refs/heads/`, on `mains`), the leg and the printed lines read after each run: byte for byte the same at `ebbd9fa4` and at this head. The rest of the suite is left to CI's `tests` job.

`git merge-tree --write-tree` of this head against: origin/main `c4dd19dc` clean; `fix/961-paste-words` (#973, `b2887cf5`) clean; `fix/430-d-assembly-root-guards` (#958, `0ef875c0`) and slice D's round 1 (`wip/l5-430d-r1-20261018`, `b49f34e1`) one conflict each, the import block of `bootstrap.py` (`import unicodedata` here against `from collections.abc import Callable, Iterator` there). The resolution keeps `import unicodedata` and slice D's `from collections.abc import Callable, Iterator`, and does not keep both `collections.abc` lines, which would be a duplicate import (rv984's NIT 2). Round 0's scratch merges of those two (95 and 222 passed) were not re-run for round 1, whose changes lie below the import block.

- A real APFS or NTFS disk is not available in CI: the shadowing a folding disk does to a packed twin is reasoned from git's files backend, not measured. The tests emulate the fold with a symlink, and run the real names where the disk keeps case.
- Several twins are named one at a time, and each rename takes one run.

Review: rv984b, lane 5's independent reviewer, not the writer (w962): READY at `f1cceddf`, the head that lands (issuecomment-6266764236), with no MUST or SHOULD. Lane 5's ruling on round 0's SHOULD 1 is carried out as ruled; its two NITs (a `-kept` name with a branch stored under it, and a test row for a twin with a slash) go to a follow-up. CI (Brett Heap's ruling 27): H0 is the head f1ccedd itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base ebbd9fa; and the local gate passed on its predicted squash tree daeb11e over main c7a3ff1: the bootstrap_casefold_twin, bootstrap_create_twins, repo_hygiene, agents_md_codes, cli_reference sets, 233 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #962

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 20, 2026
…s user cannot read is named, never read as absent, so a `scripts/` nobody may search no longer SKIPs every validator into `bootstrap ok` (#430, slice D) (#958)

# Title

the assembly root's validate-manifest.py and bootstrap.py: a path this user cannot read is named, never read as absent, so a `scripts/` nobody may search no longer SKIPs every validator into `bootstrap ok` (#430, slice D)

# Body

Part of #430 (slice D of lane 1's plan, #430 issuecomment-6251069236). A v1.2.0 DRAFT.

Head `cf8d5667`, on `50178305` (the v1.1.1 release commit): the change (`df1bb186`); a pure move in `bootstrap.py` (`0ef875c0`) that clears a merge conflict with lane 5's #896 branch; the merge of origin/main `ebbd9fa4`, with #896's twin refusals (#948) in it (`bb58c441`, clean); fix round 1 on lane 4's RV958 review (`b420171f`); and the merge of origin/main `2a78ae1a`, with #984 (#962) in it (`cf8d5667`), which keeps #981's `_shown` in `validate-manifest.py` and one `collections.abc` import line in `bootstrap.py`. Files: `templates/assembly-root/scripts/validate-manifest.py`, `templates/assembly-root/scripts/bootstrap.py` and a new `tests/test_assembly_root_unreadable_paths.py`.

Every guard below asked `Path.is_file()`, `Path.exists()` or `os.path.lexists`. Up to Python 3.13 the first two raise a refused `stat` (`PermissionError`) out of the run: a traceback, exit 1, which is a finding's code. From 3.14 they answer False to any `OSError`, and `os.path.lexists` always has. So a path this user cannot read was taken for one that is not there.

Measured at `50178305` in a scaffold's recursive clone (local bare remotes, a stub `gh`), on Python 3.12.3 with the mode taken away, and under a `sitecustomize` shim that answers as 3.14's `is_file`, `is_dir`, `exists` and `is_symlink` do. A `project.yaml` or a pin file is reached through a symlink into a folder nobody may enter, since no tool finds a root it cannot search.

| Cell | 3.12 | 3.14-like | Now, on both |
|---|---|---|---|
| validate-manifest.py, `project.yaml` behind such a folder | traceback, exit 1 | `REFUSED manifest-missing`, exit 2 | `REFUSED yaml-unreadable: project.yaml: cannot be read (Permission denied)` and its remedy, exit 2 |
| validate-manifest.py, a declared referent's pin file behind such a folder | traceback, exit 1 | `FINDING naming-referent-missing`, exit 1 | `REFUSED yaml-unreadable: contracts/<pin>: cannot be read (Permission denied)`, exit 2 |
| bootstrap.py, `project.yaml` behind such a folder | traceback, exit 1 | no leg placed, no validator run, `bootstrap ok`, exit 0 | `REFUSED yaml-unreadable: project.yaml: ...`, exit 2, before step (a) |
| bootstrap.py run with `--root`, `scripts/` at 000 | traceback in step (b), exit 1 | all three validators `SKIPPED`, `bootstrap ok`, exit 0 | `REFUSED validator-unreadable: scripts/validate-repository-naming.py: cannot be read (Permission denied)`, exit 2, before any validator runs |
| bootstrap.py, `governance/` at 000 | traceback in step (c), exit 1 | `authority is not wallet-carried in this org`, exit 0 | step (c) heads it `register, if one is there:`, names it as `cannot be read`, with the cure, and claims nothing from it; exit 0 |
| bootstrap.py, `spec/` at 000 | traceback in step (a), exit 1 | `[spec] spec: NOT INITIALIZED`, then pins refuses, exit 2 | `[spec] spec: spec/.git: cannot be read (Permission denied), so the leg is left as it is`, with the cure; pins refuses; exit 2 |
| bootstrap.py, the spec leg's `sequencer/` at 000 over a `todo` | the leg is put on `main`, exit 0 | the same | `.git/modules/spec/sequencer/todo: cannot be read (Permission denied), so the leg is left as it is`; the leg stays detached; exit 0 |
| validate-manifest.py, `contracts/` at 000, no `--policy` (RV958's S1) | `REFUSED yaml-unreadable: <ROOT>/contracts/repository-naming.yaml: cannot be read (Permission denied)`, no remedy, exit 2 | the same | `REFUSED yaml-unreadable: contracts/repository-naming.yaml: cannot be read (Permission denied)` and its remedy, exit 2 |
| bootstrap.py, `scripts/validate-pins.py` at 000 (RV958's S2) | two validators run, then `/usr/bin/python3: can't open file '<ROOT>/scripts/validate-pins.py': [Errno 13] Permission denied`, `bootstrap REFUSED: pins (exit 2)` | the same | `REFUSED validator-unreadable: scripts/validate-pins.py: cannot be read (Permission denied)` and its remedy, exit 2, before any validator runs |
| bootstrap.py, a register under a leg whose `path:` is absolute, in a folder at 000 or readable (RV958's M1) | `ValueError: ... is not in the subpath of ...` in step (c), exit 1 | the same | the register named whole; refused: `register, if one is there:` and nothing claimed; readable: its rows; exit 0 |

The last three rows were measured at `00a6f839` (this branch's first merge of main) by lane 4's RV958 review and again by this branch; the first seven at `50178305`. The `sequencer/` row is wrong on every Python: `bootstrap.py` moved a leg over a cherry-pick that was not done, and the pick's `--continue` then goes on over the tracking branch (#704's harm). Each readable control printed the same at base and at head, byte for byte, with shas and paths made equal.

| Site at `50178305` | Class | What changed |
|---|---|---|
| validate-manifest.py `:1085`, `main`, `manifest_path.is_file()` | OPEN-FALSE | FIXED. `_is_file` refuses `yaml-unreadable`, naming `project.yaml`. One this user cannot OPEN (mode 000, `load_yaml` at `:1092`) says the same line now; it was `<absolute path>: cannot be read (Permission denied)` with no remedy. |
| validate-manifest.py `:406`, `_naming_referent_missing_pin_finding`, `pin_path.is_file()` | OPEN-FALSE | FIXED. A refusal, `yaml-unreadable`, naming the pin file, exit 2; `main`'s `try` now covers `_findings`. |
| bootstrap.py `:1409`, `_run_neutral_validators`, the validator skip | OPEN-SKIP | FIXED. Every validator is looked for before any runs. One whose `stat` is refused is `validator-unreadable`, exit 2; one that is not there is still `SKIPPED`. |
| bootstrap.py `:1328`, `_load_root_and_manifest`, `manifest_path.is_file()` | OPEN-FALSE | FIXED. `yaml-unreadable`, naming `project.yaml`, exit 2, before step (a). One this user cannot open says the same line. |
| bootstrap.py `:1191`, `_register_paths`, `c.is_file()` | OPEN-FALSE | FIXED. A register whose `stat` is refused is kept, and step (c) names it `cannot be read` with the cure. A register this user cannot open is named the same way, relative to the root, where its absolute path was printed. |
| bootstrap.py `:1056`, `checkout_tracking_branch`, `(sub / ".git").exists()` | OPEN-FALSE | FIXED. Step (a) names the leg's `.git` and leaves the leg as it is (`_initialized`). |
| bootstrap.py `:717`, `_in_progress`, `os.path.lexists(gitdir / marker)` | OPEN-FALSE | FIXED. A marker whose `lstat` is refused is named, and the leg is left as it is (`_stays_for_an_operation`). |
| bootstrap.py `:750`, `_sequence_in_progress`, `os.path.lexists(todo)` | OPEN-FALSE | FIXED. The same. |
| validate-manifest.py `main`, `NamingPolicy.load(policy_path)` (round 1, RV958's S1) | OPEN-FALSE (named, no remedy) | FIXED. Loaded through `_load`, so an `OSError` cause is `_unreadable_file`'s refusal, named relative to the root, with the remedy. A missing policy, which was the absolute path alone, now says the same line with `OTHER_REASON`. |
| bootstrap.py `_run_neutral_validators`, a validator it can `stat` and cannot open (round 1, RV958's S2) | OPEN-SKIP's twin (the child's own words) | FIXED. `_a_validator` opens each after its `stat`, before any runs, as #934's `_validators_readable` does. |
| bootstrap.py `_report_one_register`, `relative_to(root)` (round 1, RV958's M1) | a traceback for an absolute leg path | FIXED. The header goes through `_named`, whole where the path is not under the root. |

Left as BENIGN: the upstream notice's three reads (`:1123`, `:1159` and `:1180`), each an absent answer by #764's ruling and the last the tool's own cache; `:753`, where a `todo` that cannot be read is taken for a cherry-pick and the leg is still left; and each file's `Path(__file__).resolve()`. Neither file has an OPEN-DEST site.

- **One rule.** Each file keeps a local `_is_a`, a copy of `validate-pins.py`'s, with the one errno set: ENOENT, ENOTDIR, EBADF and ELOOP, and Windows' 21, 123 and 1921, mean absent. Any other `OSError` is named `<path relative to the root>: cannot be read (<strerror>)`. The cure is `validate-pins.py`'s (`chmod u+r` on a file, `chmod u+rx` on a directory and every directory above it, or fix who owns it), or its `OTHER_REASON` for a cause that is not a permission. Nothing new is imported from `repo_shape.py` (#370, #610), and `scripts/repo_shape.py` is untouched. bootstrap.py's `_is_a` takes `link=True` for the two `lexists` sites, which ask with `lstat`.
- **Refusals.** `yaml-unreadable`, the id `load_yaml` and `validate-pins.py` already give such a file, for `project.yaml` in both files and for the referent's pin file. One new id, `validator-unreadable`, for a validator bootstrap cannot reach or open. Each remedy says what the file is read for, then the cure, then `Then run the same command again.`
- **A referent's pin file is a refusal, not a finding.** The check cannot run, so the run is refused, exit 2, as `validate-pins.py` refuses the same file.
- **Steps (a) and (c) still never fail the run.** A leg or a register this user cannot read is a line in its step, and the step goes on. In a normal run `validate-pins.py` refuses such a leg (`pin-submodule-unreadable`), so the run ends `bootstrap REFUSED: pins (exit 2)`. Under `--skip-validators` the run ends `bootstrap ok`, as it did for a leg NOT INITIALIZED.
- **A register whose `stat` is refused is kept, and not said to be there.** Whether one is there cannot be told, so step (c) does not say the degrade line, and heads it `register, if one is there: <leg>/governance/review-authority/register.yaml` (RV958's N1). A register whose `stat` works and whose open is refused keeps `register:`.
- **The open as well as the stat.** A validator is opened, and closed unread, before any runs (RV958's S2). A `project.yaml`, the naming policy (RV958's S1) or a register this user cannot open is read off the refusal's `__cause__`, as `validate-pins.py`'s `_load` reads it (#712), so the two causes say one line. Every other `load_yaml` refusal keeps its words; `tests/test_manifest_validator.py`'s not-UTF-8 case, which pins the absolute path, passes.
- **Every path is spelled.** bootstrap.py names each through `spelled` (#391); validate-manifest.py's `_unreadable_file` through a local `_shown`, validate-pins.py's (RV958's N3). A path not under the root (a leg's absolute `path:`, a `--policy` given elsewhere) is named whole.
- **Readable paths print what they printed**, which lane 1's slice A (`fix/430-a-bump-leg-unsearchable`) relies on when it runs `validate-manifest.py`.

Both files are pinned copies (`COPIED_VERBATIM`), so each project takes one `upstream-changed` row for each. No `--help` text changes, so `docs/cli.md` is unchanged. `AGENTS.md`, `templates/assembly-root/AGENTS-shape.md`, the template README and README's degrade rule stay true.

`tests/test_assembly_root_unreadable_paths.py`, 71 tests (46 in round 0, 25 added in round 1), on `conftest.scaffolded` through the `project` fixture.

- Each refusal is asked twice and must say the same, exactly, stdout and stderr: as a project runs the tool, with the mode taken away and put back in a `finally` (`mode_of`; skipped on Windows and as root), and in-process under `refused_stat` from `tests/test_bump_leg_unopenable.py`, which answers as 3.14 does on any Python. `failing` from `tests/test_validate_pins_named_and_spelled.py` gives an I/O error from a `stat`, an open or a read, whose advice is `OTHER_REASON`, and a refused `lstat`.
- Round 1 adds: the naming policy (mode 000 with no `--policy`; an I/O error; a `--policy` outside the root whose name holds an ESC); a validator at mode 000 and one whose open fails; a register under an absolute leg path, readable and refused; a leg in a folder at 000 whose name holds an ESC, inside and outside the root; a leg path holding a NUL; the relative branch of validate-manifest.py's spelling; and, for the survivors RV958 named, an I/O-error row at every site that reads the cause, a directory where `project.yaml`, a validator or a register goes, and anything at a leg's name for `_found`.
- The controls: a referent's pin file that is a dangling link (ENOENT) or a link to itself (ELOOP) is still `naming-referent-missing`; a validator that is not there, or a directory in its place, is still `SKIPPED`; a directory where `project.yaml` goes is still `manifest-missing` to the gate and no manifest to `bootstrap.py`; a directory where a register goes is no register.
- The errno set: each file's `_is_a` answers False to the seven absent causes and to a name no file can have (a NUL, a lone surrogate), and raises EACCES, EPERM, EIO and ENAMETOOLONG.

**At origin/main** (`ebbd9fa4`; both scripts as main has them; the new file kept): 64 of the 71 do not pass (38 fail, and 26 error at setup because main has no `_is_a`). The 7 that pass there by design are the controls: `test_a_referent_pin_that_is_not_there_is_still_missing[dangling-link]` and `[link-to-itself]`, `test_a_validator_that_is_not_there_is_still_skipped[taken-away]` and `[a-directory-in-its-place]`, `test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool`, `test_only_a_file_is_a_register_where_the_stat_works[a-directory]` and `test_a_leg_path_holding_a_nul_is_not_initialized`.

**At `00a6f839`**, this branch's first merge of main (`2f5cabf5`), before round 1: 14 fail, 57 pass. The 14 are round 1's eleven new cells for its changes and the three cells whose step (c) header N1 changed. The 14 new cells that pass there do so by design: each pins behaviour the branch already had and kills a survivor (see Mutants).

**Round 1:** 27 mutants, one string each, run against the new file at the round's code on the earlier merge (`b220336b`'s scripts; #948's merge changes none of the mutated lines, and each mutant's text is found once at the head): 27 killed, 0 survived. B* and V* are RV958's thirteen survivors (B2, B3, B5, B6, B7, B8, B10, B11, B15, V1, V2, V4, V5) as this branch reconstructs them, since RV958 did not publish its mutant texts, with B5b for the second branch of `_named`; K* are further guards of the same kind; R* are one or more for each change of the round.

| Mutant | What it changes | Result |
|---|---|---|
| B2 | bootstrap.py `_is_a` without its `except ValueError` | killed: 3 test(s): test_a_leg_path_holding_a_nul_is_not_initialized, test_a_name_no_file_can_have_is_absent[bootstrap.py-a-lone-surrogate], test_a_name_no_file_can_have_is_absent[bootstrap.py-a-nul] |
| V1 | validate-manifest.py `_is_a` without its `except ValueError` | killed: 2 test(s): test_a_name_no_file_can_have_is_absent[validate-manifest.py-a-lone-surrogate], test_a_name_no_file_can_have_is_absent[validate-manifest.py-a-nul] |
| B5 | bootstrap.py `_named`, the relative branch unspelled | killed: 1 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[inside-the-root] |
| B5b | bootstrap.py `_named`, the whole-path branch unspelled | killed: 1 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root] |
| B6 | bootstrap.py `_named`, out of the root: `path.name` for the whole path | killed: 2 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root], test_a_register_under_a_leg_path_outside_the_root_is_read |
| V5 | validate-manifest.py `_unreadable_file`, out of the root: `path.name` | killed: 1 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc] |
| B7 | bootstrap.py `_is_file` takes only `PermissionError` | killed: 2 test(s): test_a_manifest_bootstrap_cannot_stat_or_open_is_the_same_refusal[stat-io-error], test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs[stat-io-error] |
| B8 | bootstrap.py `_a_register` takes only `PermissionError` | killed: 1 test(s): test_a_register_whose_stat_is_refused_is_not_dropped[io-error] |
| B10 | bootstrap.py `_load_manifest` maps only a `PermissionError` cause | killed: 1 test(s): test_a_manifest_bootstrap_cannot_stat_or_open_is_the_same_refusal[open-io-error] |
| B11 | bootstrap.py `_load_register_rows` names only a `PermissionError` cause | killed: 1 test(s): test_a_register_that_cannot_be_opened_claims_nothing_on_every_python |
| V4 | validate-manifest.py `_load` maps only a `PermissionError` cause | killed: 1 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[io-error] |
| B3 | bootstrap.py `_is_file` takes anything for a file | killed: 2 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool, test_a_validator_that_is_not_there_is_still_skipped[a-directory-in-its-place] |
| B15 | bootstrap.py `_a_register` takes anything for a register | killed: 1 test(s): test_only_a_file_is_a_register_where_the_stat_works[a-directory] |
| V2 | validate-manifest.py `_is_file` takes anything for a file | killed: 1 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool |
| K1 | bootstrap.py `_is_a` ignores `kind` | killed: 3 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool, test_a_validator_that_is_not_there_is_still_skipped[a-directory-in-its-place], test_only_a_file_is_a_register_where_the_stat_works[a-directory] |
| K2 | validate-manifest.py `_is_a` ignores `kind` | killed: 1 test(s): test_a_manifest_that_is_a_directory_is_no_manifest_to_either_tool |
| K3 | bootstrap.py `_found` asks for a regular file | killed: 2 test(s): test_anything_at_a_name_in_a_leg_is_found[a-directory], test_anything_at_a_name_in_a_leg_is_found[a-link-to-nothing-asked-of-the-name] |
| K4 | bootstrap.py `_found` drops `link` | killed: 3 test(s): test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-merge], test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-sequence], test_anything_at_a_name_in_a_leg_is_found[a-link-to-nothing-asked-of-the-name] |
| K5 | bootstrap.py `_found` takes only `PermissionError` | killed: 1 test(s): test_a_leg_whose_stat_is_refused_is_not_uninitialized[io-error] |
| R1 | M1: the header back to `relative_to(root)` | killed: 2 test(s): test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root], test_a_register_under_a_leg_path_outside_the_root_is_read |
| R2 | N1: a refused `stat` headed `register` again | killed: 6 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[inside-the-root], test_a_leg_in_a_folder_nobody_may_search_is_named_and_spelled[outside-the-root], test_a_register_bootstrap_cannot_read_is_named_not_absent[a-folder-nobody-may-enter], test_a_register_whose_stat_is_refused_is_not_dropped[io-error], test_a_register_whose_stat_is_refused_is_not_dropped[permission] |
| R3 | S2: `_a_validator` opens nothing | killed: 2 test(s): test_a_validator_at_mode_000_is_refused_before_any_runs, test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs[open-io-error] |
| R4 | S2: `_a_validator` takes only `PermissionError` | killed: 1 test(s): test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs[open-io-error] |
| R5 | S1: the policy back to `NamingPolicy.load(policy_path)` | killed: 3 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc], test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[io-error], test_a_naming_policy_the_gate_cannot_reach_is_refused_by_name |
| R6 | N3: `_shown` returns the name as it is | killed: 2 test(s): test_a_name_that_does_not_print_is_spelled_under_the_root_too, test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc] |
| R7 | N3: the relative branch unspelled | killed: 1 test(s): test_a_name_that_does_not_print_is_spelled_under_the_root_too |
| R8 | N3: the whole-path branch unspelled | killed: 1 test(s): test_a_naming_policy_the_gate_cannot_open_is_named_on_every_python[given-outside-the-root-with-an-esc] |

**Round 0:** 18 mutants, one string each, run against the new file at `df1bb186` (the move commit after it changes no line). All were killed; none survived.

| Mutant | Caught by |
|---|---|
| M1 validate-manifest.py `:1085`: `_is_file` back to `manifest_path.is_file()` | killed: 2 test(s): test_a_manifest_the_gate_cannot_reach_or_open_is_refused_by_name[behind-a-folder-nobody-may-enter], test_a_manifest_whose_stat_is_refused_is_the_same_refusal |
| M2 validate-manifest.py: `_load` back to `load_yaml` | killed: 1 test(s): test_a_manifest_the_gate_cannot_reach_or_open_is_refused_by_name[at-mode-000] |
| M3 validate-manifest.py `:406`: `_is_file` back to `pin_path.is_file()` | killed: 4 test(s): test_a_contracts_folder_the_gate_cannot_search_names_the_pin, test_a_referent_pin_the_gate_cannot_reach_is_refused_not_missing, test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[io-error], test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[permission] |
| M4 validate-manifest.py: `_findings` back outside `main`'s `try` | killed: 4 test(s): test_a_contracts_folder_the_gate_cannot_search_names_the_pin, test_a_referent_pin_the_gate_cannot_reach_is_refused_not_missing, test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[io-error], test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[permission] |
| M5 bootstrap.py `:1328`: `_is_file` back to `manifest_path.is_file()` | killed: 2 test(s): test_a_manifest_bootstrap_cannot_reach_or_open_is_refused_first[behind-a-folder-nobody-may-enter], test_a_manifest_bootstrap_cannot_stat_is_the_same_refusal |
| M6 bootstrap.py: `_load_manifest` back to `load_yaml` | killed: 1 test(s): test_a_manifest_bootstrap_cannot_reach_or_open_is_refused_first[at-mode-000] |
| M7 bootstrap.py `:1409`: the look-ahead back to `(root / argv_[0]).is_file()` | killed: 2 test(s): test_a_scripts_folder_bootstrap_cannot_search_is_refused_not_skipped, test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs |
| M8 bootstrap.py `:1056`: `_found` back to `(sub / ".git").exists()` | killed: 3 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_leg_whose_stat_is_refused_is_not_uninitialized[io-error], test_a_leg_whose_stat_is_refused_is_not_uninitialized[permission] |
| M9 bootstrap.py `:717`: `_found(..., link=True)` back to `os.path.lexists` | killed: 1 test(s): test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-merge] |
| M10 bootstrap.py `:750`: the same for `sequencer/todo` | killed: 2 test(s): test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-sequence], test_an_operation_bootstrap_cannot_see_keeps_the_leg_where_it_is |
| M11 bootstrap.py `:1191`: `_a_register` back to `c.is_file()` | killed: 3 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_register_bootstrap_cannot_read_is_named_not_absent[a-folder-nobody-may-enter], test_a_register_whose_stat_is_refused_is_not_dropped |
| M12 bootstrap.py: a register's open refusal said as before (`unread = False`) | killed: 3 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_register_bootstrap_cannot_read_is_named_not_absent[a-folder-nobody-may-enter], test_a_register_bootstrap_cannot_read_is_named_not_absent[at-mode-000] |
| M13 validate-manifest.py: ELOOP dropped from `_ABSENT_ERRNOS` | killed: 2 test(s): test_a_name_that_is_not_there_is_absent[validate-manifest.py-ELOOP], test_a_referent_pin_that_is_not_there_is_still_missing[link-to-itself] |
| M14 validate-manifest.py: EACCES added to `_ABSENT_ERRNOS` | killed: 6 test(s): test_a_contracts_folder_the_gate_cannot_search_names_the_pin, test_a_manifest_the_gate_cannot_reach_or_open_is_refused_by_name[behind-a-folder-nobody-may-enter], test_a_manifest_whose_stat_is_refused_is_the_same_refusal, test_a_referent_pin_the_gate_cannot_reach_is_refused_not_missing, test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[permission], test_every_other_refusal_is_raised[validate-manifest.py-EACCES] |
| M15 bootstrap.py: ELOOP dropped from `_ABSENT_ERRNOS` | killed: 1 test(s): test_a_name_that_is_not_there_is_absent[bootstrap.py-ELOOP] |
| M16 bootstrap.py: EACCES added to `_ABSENT_ERRNOS` | killed: 12 test(s): test_a_leg_bootstrap_cannot_search_is_left_as_it_is, test_a_leg_whose_stat_is_refused_is_not_uninitialized[permission], test_a_manifest_bootstrap_cannot_reach_or_open_is_refused_first[behind-a-folder-nobody-may-enter], test_a_manifest_bootstrap_cannot_stat_is_the_same_refusal, test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-merge], test_a_marker_whose_lstat_is_refused_keeps_the_leg_on_every_python[a-sequence], test_a_register_bootstrap_cannot_read_is_named_not_absent[a-folder-nobody-may-enter], test_a_register_whose_stat_is_refused_is_not_dropped, test_a_scripts_folder_bootstrap_cannot_search_is_refused_not_skipped, test_a_validator_whose_stat_is_refused_stops_step_b_before_any_runs, test_an_operation_bootstrap_cannot_see_keeps_the_leg_where_it_is, test_every_other_refusal_is_raised[bootstrap.py-EACCES] |
| M17 bootstrap.py: `_cure` gives the `chmod` advice for every cause | killed: 1 test(s): test_a_leg_whose_stat_is_refused_is_not_uninitialized[io-error] |
| M18 validate-manifest.py: the `chmod` advice for every cause | killed: 1 test(s): test_a_referent_pin_whose_stat_is_refused_is_refused_on_every_python[io-error] |

Each run is `run9s.sh` with `SHARDS=3` (three `run9.sh` processes: the gh stub first on `PATH`, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, a blank git identity), one run at a time, at head `b420171f`.

- **git 2.43.0, the first brief's whole list, 134 files: 9656 passed, 25 skipped (longest shard 40 min), rc 0.** 2026-10-19T11:37:42Z to 2026-10-19T12:17:59Z. The list is every test file that names the assembly root's `bootstrap.py` or `validate-manifest.py`, or runs a scaffolded root's `make bootstrap` or its validators, with #948's new `test_bootstrap_casefold_twin.py`, and `test_pinned_copy_text.py`, `test_bom_reads.py`, `test_validator_child_encoding.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`.
  The 25 skips: 8 of `test_leg_path_yaml_scalars.py` (empty parameter sets, and the templates read the value back); 6 of `test_doctor_legs_members_next.py` (git 2.43 dies on a `shallow` of -2**31, and crashes on `submodule init`; CI's 2.55 does neither); 3 reftable cases of `test_bootstrap_ref_store.py` (git 2.45 and later); 3 NTFS junction cases of `test_update_shape_add_symlink.py`, 3 PowerShell lines of `test_shape_doctor.py` and 1 of `test_stdout_encoding.py` (Windows only); 1 of `test_bump_leg.py` (a disk that ignores case).
- **git 2.47.1 (a build first on `PATH`), 24 files: 1764 passed (longest shard 4 min), rc 0.** 2026-10-18T20:07:50Z to 2026-10-18T20:11:58Z. Files: `test_agents_md_codes.py`, `test_assembly_root_unreadable_paths.py`, `test_bom_reads.py`, `test_bootstrap_after_bump.py`, `test_bootstrap_branch_bytes.py`, `test_bootstrap_casefold_twin.py`, `test_bootstrap_leg_moves.py`, `test_bootstrap_non_utf8_branch.py`, `test_bootstrap_pasteable_lines.py`, `test_bootstrap_placement_texts.py`, `test_bootstrap_ref_store.py`, `test_bootstrap_refusal_exit.py`, `test_bootstrap_spelling.py`, `test_bootstrap_upstream_notice.py`, `test_bootstrap_without_git.py`, `test_bump_leg_unopenable.py`, `test_bump_leg_unreadable.py`, `test_cli_reference.py`, `test_manifest_validator.py`, `test_naming_policy.py`, `test_pinned_copy_text.py`, `test_repo_hygiene.py`, `test_validate_pins_named_and_spelled.py`, `test_validator_child_encoding.py`.
- A first 2.43.0 run of the same list at the same head, on 2026-10-18, was stopped from outside at 20:31Z (a signal; its shards had reached 52%, 29% and 41% with no failure); the run above is its re-run.
- Before #948 landed, the same list less `test_bootstrap_casefold_twin.py` (23 files), on git 2.47.1 at the round's commit on the earlier merge (`b220336b`): 1713 passed, rc 0.

<details><summary>The 134 files of the git 2.43.0 run</summary>

`test_adopt_attributes_eol_block.py`, `test_adopt_clone_template.py`, `test_adopt_e2e.py`, `test_adopt_git_reads.py`, `test_adopt_ignored_shape_files.py`, `test_adopt_manifest_values.py`, `test_adopt_mount_ignored.py`, `test_adopt_nested_attributes.py`, `test_adopt_origin_repository.py`, `test_adopt_pins.py`, `test_adopt_plan_yaml_quoting.py`, `test_adopt_reencoded_files.py`, `test_adopt_relative_submodule_url.py`, `test_adopt_root_manifest.py`, `test_adopt_root_symlinks.py`, `test_adopt_shape_pin_and_work_dir.py`, `test_adopt_spec_only.py`, `test_adopt_submodules.py`, `test_adopt_verify.py`, `test_agents_md_codes.py`, `test_assembly_root_unreadable_paths.py`, `test_bom_reads.py`, `test_bootstrap_after_bump.py`, `test_bootstrap_branch_bytes.py`, `test_bootstrap_casefold_twin.py`, `test_bootstrap_leg_moves.py`, `test_bootstrap_non_utf8_branch.py`, `test_bootstrap_pasteable_lines.py`, `test_bootstrap_placement_texts.py`, `test_bootstrap_ref_store.py`, `test_bootstrap_refusal_exit.py`, `test_bootstrap_spelling.py`, `test_bootstrap_upstream_notice.py`, `test_bootstrap_without_git.py`, `test_bump_leg.py`, `test_bump_leg_branch_and_push_line.py`, `test_bump_leg_dry_run_tracking.py`, `test_bump_leg_dry_run_truth.py`, `test_bump_leg_paste_lines.py`, `test_bump_leg_rollback.py`, `test_bump_leg_unopenable.py`, `test_bump_leg_unreadable.py`, `test_chain_remedy_callers.py`, `test_cli_reference.py`, `test_doctor_gitlink_repairs.py`, `test_doctor_gitmodules_not_utf8.py`, `test_doctor_legs_members_next.py`, `test_doctor_machine_row.py`, `test_doctor_members_spelled.py`, `test_doctor_next_is_a_command.py`, `test_doctor_no_lazy_fetch.py`, `test_doctor_non_ascii_paths.py`, `test_doctor_partial_clone.py`, `test_doctor_paste_through_git.py`, `test_doctor_pin_restore_merges.py`, `test_doctor_plan_overrides.py`, `test_doctor_recorded_answers.py`, `test_doctor_unfinished_merge.py`, `test_doctor_unreadable_pin.py`, `test_doctor_validator_rows.py`, `test_doctor_validator_site.py`, `test_doctor_validator_verdicts.py`, `test_family.py`, `test_family_bootstrap_gate_order.py`, `test_family_bump_remove_rollback.py`, `test_family_gitmodules_values.py`, `test_family_head_lost_gitmodules.py`, `test_family_holder_bootstrap_texts.py`, `test_family_makefile_siblings.py`, `test_family_member_name.py`, `test_family_members_not_a_list.py`, `test_family_siblings_placement.py`, `test_git_output_decoding.py`, `test_leg_path_yaml_scalars.py`, `test_legs_not_a_list.py`, `test_legs_readers_not_a_list.py`, `test_manifest_validator.py`, `test_naming_policy.py`, `test_non_list_project_fields.py`, `test_non_utf8_oserror_sites.py`, `test_park_resume_targets.py`, `test_pin_source_env_collisions.py`, `test_pinned_copy_text.py`, `test_pins_child_kind_and_siblings_root_spelling.py`, `test_push_branch_refspec.py`, `test_reference_default.py`, `test_repo_hygiene.py`, `test_scaffold_e2e.py`, `test_scaffold_free_text_values.py`, `test_scaffold_ignored_shape_files.py`, `test_scaffold_leg_path_collisions.py`, `test_scaffold_manifest_values.py`, `test_scaffold_mount_ignored.py`, `test_scaffold_pin_and_reuse.py`, `test_scaffold_referent_chain_links.py`, `test_setup_by_hand_commands.py`, `test_setup_project_py.py`, `test_setup_sh.py`, `test_shape_doctor.py`, `test_siblings_family_name.py`, `test_siblings_inputs.py`, `test_stdout_encoding.py`, `test_template_quoted_values.py`, `test_template_text_placeholders.py`, `test_template_values_single_pass.py`, `test_update_shape.py`, `test_update_shape_add.py`, `test_update_shape_add_ignored.py`, `test_update_shape_add_not_a_directory.py`, `test_update_shape_add_symlink.py`, `test_update_shape_apply_lines_and_headers.py`, `test_update_shape_branch_any_directory.py`, `test_update_shape_hardlink.py`, `test_update_shape_header_comments.py`, `test_update_shape_kept_helper.py`, `test_update_shape_landing.py`, `test_update_shape_merged_away.py`, `test_update_shape_messages.py`, `test_update_shape_next_quoting.py`, `test_update_shape_partial_rollback.py`, `test_update_shape_paste_lines.py`, `test_update_shape_pr_base.py`, `test_update_shape_rewrite_symlink.py`, `test_validate_family_branch_step.py`, `test_validate_family_member_path_glob.py`, `test_validate_family_remedies_typed.py`, `test_validate_family_typed_states.py`, `test_validate_pins_lockstep_lines.py`, `test_validate_pins_named_and_spelled.py`, `test_validate_workflow_gitmodules_urls.py`, `test_validator_child_encoding.py`, `test_windows_paths.py`, `test_yaml_indicator_openers.py`, `test_yaml_reader_spaces.py`

</details>

The branch merged main twice. Most recently, `cf8d5667` merges origin/main `2a78ae1a` (#984, landed) into round 1's head `b420171f`: its one conflict, the import block of `bootstrap.py`, keeps #984's `import unicodedata` and this branch's `from collections.abc import Callable, Iterator`, and the branch's own `_shown` in `validate-manifest.py` is deleted in favour of #981's `_shown(path)` (the account of that merge is on the PR). Before that, `bb58c441` merges origin/main `ebbd9fa4` (#948, #896's twin refusals, landed) into `0ef875c0` with no conflict (`bootstrap.py` auto-merged), and the round's commit `b420171f` sits on it. The round was first written on an earlier merge of main (`00a6f839`, `2f5cabf5`); that merge was replaced, unpushed, once #948 landed. The round's commit was reapplied unchanged (the same `git patch-id`). Its tree is the one `git merge-tree` predicted for the earlier round commit with `ebbd9fa4` (`9f4c2991`).

`git merge-tree --write-tree` of head `b420171f`, each ref fetched with `gc.auto=0` and `maintenance.auto=false` just before:

- origin/main `c7a3ff16`, re-fetched on 2026-10-19 after the head was pushed: clean as text (tree `977f0980`), and NOT merged here: the landing pilot refreshes the head under the fast-refresh ruling. Since `ebbd9fa4`, main's #981 (`ca6a0b40`) changes `validate-manifest.py`, and its own `_shown(path)` (`text = str(path)`, then `isprintable` or `repr`) lands beside this branch's `_shown(typed)`: the merged file defines `_shown` twice. The two give the same answer for every string this branch passes, the later definition wins, and the new file passes on the merged tree (71 passed, `$Q/v118/w430d/r1/m981.log`), but the refresh should delete this branch's `_shown` and keep #981's. Main's other changes in the files near this slice are to `validate-pins.py` and `tests/conftest.py`, which this branch does not touch.
- #948 at `45415296`: clean (tree `9f4c2991`, the head's own).
- #973 at `b2887cf5` (`fix/961-paste-words`): clean. #973 does not yet change the NOT INITIALIZED line; when it does, the new file's `not_initialized` helper is the one place to follow it.
- Each clean merge's `bootstrap.py` parses, with no top-level name defined twice.
- #962's `wip/l5-962-create-twins-20261018` at `414e9e0a`: conflicts in `bootstrap.py`, 8 hunks. 7 of them are with main alone: #962 is stacked on #948's head before #948 was squashed, and `git merge-tree` of origin/main with it gives the same 7. The 8th is this branch's only one, the import lines: `from collections.abc import Callable, Iterator` here against #962's `import unicodedata` and `from collections.abc import Iterator`. Taking both resolves it.

- No duplicated block: the new file shares no run of five code lines with any test file but an import block, reuses `as_a_descendant_by_chain`, `link_tree`, `refused_stat`, `failing`, `os_error`, `mode_of`, `CANNOT_RUN_AS_ROOT` and the cure constants instead of copying them, and puts its own repeated lines in helpers (`refused`, `left_as_it_is`, `nothing_claimed`, `not_initialized`, `refusing`, `leg_path`, `up_to_step_b`, `skipping_validators`, `a_register`).
- No assert joins conditions with `and`.
- Cognitive complexity, counted by hand: `checkout_tracking_branch` 13, unchanged; `_load_register_rows` 11; `_in_progress` 8; `_run_neutral_validators` 7; `_report_one_register` 6; `_register_paths` 4; `read_authority` 4; `validate-manifest.py`'s `main` 9 and `_load` 3; every other new function 2 or less.
- Every added line is within 79 columns.

Round 0's first two candidates (the naming policy named by its absolute path, and a validator the child cannot open) are fixed in round 1, on RV958's S1 and S2.

- **The assembly root's `validate-repository-naming.py` names a naming policy it cannot read by its absolute path, with no remedy.** Measured with `contracts/` at 000 in a full `bootstrap.py` run with round 1's change: `REFUSED yaml-unreadable: <ROOT>/contracts/repository-naming.yaml: cannot be read (Permission denied)`, while `validate-manifest.py`'s refusal just after it names `contracts/repository-naming.yaml` with the remedy. Not this slice's file.
- **`OTHER_REASON` tells a validator's I/O error to "take away one nobody wants".** A validator taken away is `SKIPPED`. #934 gives bump-leg.py a `git checkout --` line instead. These are validate-pins.py's words, kept as the plan rule asks.
- **The cure's "(and on every directory above it inside the root)"** reads oddly for a folder outside the root (an absolute leg path, a `--policy` given elsewhere). validate-pins.py's words, as RV958's N2 found for a symlinked `project.yaml`.
- **Seen in the code, not measured:** validate-manifest.py prints `project.yaml`'s absolute path raw in `manifest-missing`, `manifest-unreadable` and `<n> finding(s) in <path>`, where `validate-pins.py` and `bootstrap.py` spell every path they print (#391, #712).

Part of #430

Review: RV958, lane 4's independent reviewer, not the writer (w430d): READY at `7e87e05f` (issuecomment-6278103469), the reviewed head, after its READY on round 1 at `cf8d5667` (issuecomment-6271860975); the delta was a merge of main and one test-only commit for CI. CI (Brett Heap's ruling 27): H0 is the head 7c06c57 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 051176c; and the local gate passed on its predicted squash tree 194700b over main 051176c: the adopt_reencoded_files, assembly_root_unreadable_paths, repo_hygiene, agents_md_codes, cli_reference sets, 294 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".


Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 20, 2026
… of the bytes git is handed (UTF-8 on Windows), and the NOT INITIALIZED command names a leg's path as one word after a `--`, with `./` before a `:` (#961) (#973)

#944 (#932) made each command step (a) prints for a person to paste name its leg path and its upstream as ONE shell word, through `_paste_word`. rv932's independent review of it (#944 issuecomment-6252637720) left a SHOULD and four NITs in that function, and #961 filed them, with a SHOULD for the one printed command #944 did not word: the NOT INITIALIZED line. This takes them all, in `templates/assembly-root/scripts/bootstrap.py`, `tests/test_bootstrap_pasteable_lines.py`, `tests/test_assembly_root_unreadable_paths.py` (#958's file), `tests/test_bootstrap_spelling.py` and the docstring of `tests/test_bootstrap_after_bump.py`.

The branch is restacked on main `af0e60f2`, which holds #958 (#430 slice D, which moved the NOT INITIALIZED line into `_initialized`): #958's final head, then its squash, then main (nothing newer). The squash's tree is identical to #958's final head, so the merges brought in no code of their own. Against main this PR changes those five files and no other, and `git diff -U0` against main has the same changed lines as this PR's own commits on #958's head `7e87e05f` (checked from objects, see "What this adds to main").

**1. A leading `=` (SHOULD 1).** `_paste_word` quoted with `shlex.quote`, which leaves `=x` bare, and zsh expands a bare `=word` to the path of a command.
- Before, read back by `/usr/bin/zsh` 5.9 (`printf %s <word>`): `=x`, `=ls`, `==x` and `=%` did not print themselves; `=ls` was `/usr/bin/ls` and `=x` was `zsh: x not found`. dash and bash read them all.
- After: the word is `'=x'`, written locally with `quoted.startswith("=")`, as `repo_shape.shell_quoted` quotes it (#486). `bootstrap.py` imports nothing new from `repo_shape.py`, and a test holds the import set. A word `shlex.quote` quotes already (`=a b`) is not quoted again, and `a=b` stays bare.
- The test row `("=x", "=x")` is now `("=x", "'=x'")`, in its own test, because the one it was in asserts "byte for byte what it was".
- rv932's probe (every byte 0x01 to 0xff in seven places, about 160 special names, 3,194 names, each word read by dash, bash and `/usr/bin/zsh` under `LC_ALL=C` and `C.UTF-8`, as `set -- <word>`, one argument and the bytes git is handed): 450 mismatches at `450279ca`, all zsh and all a leading `=` (225 names in each locale), and **0 at the first head**, with 603 names that begin with `=` among those read. The 4 names that end in a line feed have no word (item 3).

**2. The codec (NIT 2).** `_prints_as_it_is` asked the stdout's codec only, but git is handed another set of bytes.
- Before: under a latin-1 or cp1252 stdout, `origin/café` was the quoted word, printed as the byte `e9`, where the ref is `c3 a9`. Under a latin-1 filesystem and a UTF-8 stdout it was the other way round.
- After: a non-ASCII word is kept quoted only where `text.encode(stdout's codec)` equals the bytes git is handed (`_handed_to_git`: `os.fsencode`'s on POSIX, UTF-8 on Windows, item 8). Otherwise it is the `printf` word, which is ASCII. An ASCII word skips the comparison, so `origin/main` stays bare on any stdout. `spelled`'s check comes first.
- **A decision:** the brief says to compare with `sys.getfilesystemencoding()`. `os.fsencode` is that codec with its error handler, and it is what `subprocess` hands git on POSIX, so comparing the two byte strings asks both at once and cannot disagree with what git is given. It is also the call a test shims in process (`LegacyWindowsOs` does, #944).

**3. The round-trip guard (NIT 3).** The guard `os.fsdecode(raw) != name` kept the escape text for `sp\udcc3\udca9c`, though `"$(printf -- 'sp\303\251c')"` reads back exactly the bytes git is handed (the directory `spéc`).
- After: no name is read back. git IS handed those bytes, so a name `os.fsdecode` would read as another has its word. A name that ends in a line feed keeps today's text, as the brief says. (On Windows the guard was replaced by item 8.)
- **An addition inside "what can be pasted":** a name with a NUL has no word either. `$(...)` drops a NUL, so the old word `"$(printf -- 'a\000b')"` printed `ab` in dash and bash (zsh keeps it). It keeps today's text now. It cannot be reached from a real leg path or ref, so this is a guard and a unit row, nothing more.

**4. R6 and R8 (NIT 4).** Two unit rows, in process:
- R6, `except UnicodeEncodeError` alone: a `sys.stdout` stub whose `encoding` is `no-such-codec` (a `LookupError`, not a `UnicodeEncodeError`); the word is the `printf` word and nothing raises.
- R8, `name.encode("utf-8", "surrogateescape")` for `os.fsencode`: a shim `os` whose `fsencode` is latin-1, and `café` + tab; the word is `"$(printf -- 'caf\351\011')"`, and `/bin/sh` prints the shim's bytes.

**5. Stale words (NIT 5).** `tests/test_bootstrap_after_bump.py`'s docstring no longer says the command is "spelled" (#391): the names are, the command is `_paste_word`'s. The pasteable test file's docstring now says what the rule is, and which tests run what was printed and which ask the helper. The module paragraph says where the stdout cannot write the name, the leading `=`, the new command, and the names with no word. `_prints_as_it_is` and `_paste_word` have new docstrings.

**6. The NOT INITIALIZED command (SHOULD 3).** `_initialized` printed `git submodule update --init {spelled(path)}`, bare, for a leg that is not there.
- Before, pasted from the root (rv973 measured 40 awkward paths in sh, dash, bash and zsh): `my spec` was two pathspecs; a path with an ESC, a U+2028, the byte 0xff, `%s` or a tab was its escape text, which names no path; `sp'ec` was a syntax error in every shell; `=spec` failed in zsh (`zsh:1: spec not found`); `-spec` was an option.
- After: `git submodule update --init -- <word>`, the path as `_paste_word` words it, after a `--`. The leg is checked out at its pin again, in every shell and on git 2.43 and 2.47.1 (rv973: 40 of 40). The label `[spec] <path>:` is still spelled.
- A path that is not a name `printf` can print (a lone surrogate, a NUL, a final line feed) keeps its text, as in item 3.
- rv973 asked for a landing condition: the PR does not land without this commit.

**7. A path that begins with `:` (rv973's NIT 1 on the staged commit).** Git reads a word that begins with `:` as pathspec magic, even after the `--`, so `--init -- :spec` matched nothing and the leg stayed empty (all four shells, git 2.43 and 2.47.1). `_initialized` words `./:spec`: `./` goes inside the one word, so a path that needs quoting is `'./:my spec'` and a byte path is `"$(printf -- './:sp\377ec')"`. Only this command has it: `git -C :spec` is a directory, not a pathspec.

**8. Windows: the bytes are UTF-8 (rv973's NIT 1 and NIT 2).** git for Windows is handed UTF-16 and stores UTF-8, and Git Bash reads a paste as UTF-8.
- `_handed_to_git(text)` is `os.fsencode(text)` on POSIX and strict `text.encode("utf-8")` where `os.name == "nt"`. `_prints_as_it_is` compares the stdout with it, and `_paste_word` makes its `printf` bytes from it.
- Before, under `PYTHONLEGACYWINDOWSFSENCODING=1` (where `os.fsencode` is the code page's): on main a UTF-8 stdout got `"$(printf -- 'origin/caf\351')"` for `origin/café` and `\2005` for `€5`, which Git Bash holds as other names, where the quoted word was right; and a codec rule made of `os.fsencode` would have given a cp1252 stdout the same kind of word (it was the staged commit's case, rv973's NIT 2). A lone surrogate had a word of three junk bytes in the default mode.
- After: the quoted word stays on a UTF-8 stdout, the `printf` word is UTF-8 in either mode, and a lone surrogate, which has no UTF-8 bytes, keeps its text. The Windows read-back guard `os.fsdecode(raw) != name` is gone, and with it the one `os.name` test in `_paste_word`. POSIX is unchanged.
- I have no Windows host: the evidence is in-process shims, as in #944.

`tests/test_bootstrap_pasteable_lines.py` is now 128 tests (45 in #944). `tests/test_assembly_root_unreadable_paths.py` has 11 more rows, and `tests/test_bootstrap_spelling.py`'s NOT INITIALIZED test asks for the word.
- **Items 1 to 5, first head, against `450279ca`:** 19 of 97 pasteable tests failed there, 33 of the 52 new cases passed there by design (a word `shlex.quote` quotes already, every `=` name in sh, dash and bash, the cases where both codecs agree, R6's and R8's rows, which kill mutants that base's code does not contain), and 97 of 97 passed at that head.
- **Item 6, on #958's head, against the merged tree with the old line:** 20 failed, 377 passed in the three changed files. 15 new cases failed: five paste rows, the `-spec` row, the zsh row of the `=` test, six rows in `test_assembly_root_unreadable_paths.py`, the legacy-mode UTF-8 row and the euro row. 5 changed existing cases failed, since the line they assert changed. The helper `not_initialized` in #958's file takes the word. Each paste test empties the leg (`git submodule deinit`), runs bootstrap, checks the line whole, runs the printed command from the root in a shell, and checks the leg's `.git` is back at the pin. Git ignores a submodule path that begins with `-`, so `-spec` is checked another way: the command is exactly `git submodule update --init -- -spec`, and git's stderr names the path (without the `--` it prints its usage).
- **Items 7 and 8, against the staged commit:** 11 failed, 405 passed: the `:spec` paste in sh, dash, bash and zsh (4), three text rows for a leading `:` (3), the default-mode lone surrogate (1) and three legacy-mode `printf` rows (3). 8 of the 19 new cases pass there by design (the rows for a space at either end, which kill `_paste_word(path.strip())` and are right in the old code).
- **Pass at this head:** all of them.

Each applied alone to the head's `bootstrap.py`, the changed test files run with `-x`.
- **First head:** 30 mutants (R6, R8, four for item 1, seven for item 2, seven for item 3, and #944's ten), 30 killed.
- **On #958's head, first round:** 38 killed, among them rv973's O3 (the comparison asked outside its `try`).
- **Last round:** 45 applied, 45 killed: five for the `./` (none, `:/` only, always, also before `-`, glued in front of the word), R-H (`.strip()`) and R-F (`_paste_word(spelled(path))`), the command (`spelled(path)`, no `--`, `shlex.quote` only), six for Windows (the `nt` branch dropped, UTF-8 everywhere, `nt` inverted, `surrogatepass`, `os.fsencode` in the word, the read-back guard back), O3, R6, R8 and the rest of #961's and #944's. **One survived the first pass:** the `./` glued in front of the word. It is the same name to a shell, so no paste could tell; two text rows (`:my spec`, `:sp<0xff>ec`) kill it.

All through `run9.sh` (the real `gh` unreachable, `EMAIL`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null` set), at the restacked head `d286b289`.

| git | files | result |
|---|---|---|
| 2.43.0 | the pasteable file, `test_assembly_root_unreadable_paths.py`, `test_bootstrap_non_utf8_branch.py`, `test_bootstrap_after_bump.py`, `test_bootstrap_spelling.py`, `test_pinned_copy_text.py`, `test_adopt_reencoded_files.py` and the trio | 805 passed |
| 2.47.1 | the same | 805 passed |

Earlier heads of this branch also ran the other eight `test_bootstrap*.py` files (343 passed, 3 skipped for the reftable rows), and a predicted merge with main that added the casefold and create-twins files (662 passed).

`git diff origin/main HEAD` touches five files: `templates/assembly-root/scripts/bootstrap.py`, `tests/test_assembly_root_unreadable_paths.py`, `tests/test_bootstrap_after_bump.py`, `tests/test_bootstrap_pasteable_lines.py` and `tests/test_bootstrap_spelling.py`. Their changed lines, in order, are the same as `git diff -U0 7e87e05 e23137c` gives (this PR's own commits on #958's head): 109, 54, 19, 483 and 25 lines, no difference. The three merges are: #958's final head `7c06c578` (clean), its squash `af0e60f2` (two conflicts, `bootstrap.py` and `tests/test_assembly_root_unreadable_paths.py`, both taken from this branch's side, which holds #958's content plus this change; the squash's tree equals the final head's) and main (already merged).

- `_say_twin`, `fast_forward_to_pin`, `place_at_pin` and `create_at_pin`, which #948 (#896) edits. #948 has landed, with its `switch` line and its rename line through `_paste_word`, so they take this PR's leading-`=` quote with no change of their own (rv932's SHOULD 2).
- A leg path that ends in a line feed keeps today's text, a deliberate exception to the Goal's "or the line says it cannot be pasted" (rv932's NIT 1).
- Git ignores a `submodule.<name>.path` that begins with `-`, so no command can initialize a leg there; the printed command is right, and `validate-manifest.py` is not asked about such a path here.
- Not checked: a real Windows host, Git Bash's argument conversion, macOS's `/bin/sh`, git 2.55 and Sonar's own analysis (cognitive complexity by hand: `_prints_as_it_is` 4, `_paste_word` 6, `_handed_to_git` 1, `_initialized` 4).

A pinned copy: every project's `bootstrap.py` takes an `upstream-changed` row, and re-pins it.

Review: rv973, lane 5's independent reviewer, not the writer (w961): READY at `e23137c7` (issuecomment-6282595318), after its READY at `2a2bc8f9` (issuecomment-6282590057) and at the first head `b2887cf5` (issuecomment-6253889729); `d286b289`, the head that lands, restacks that change unchanged onto #958's squash (lane 5's check from objects, issuecomment-6282600447). CI (Brett Heap's ruling 27): H0 is the head d286b28 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base af0e60f; and the local gate passed on its predicted squash tree 993946c over main ee6f553: the assembly_root_unreadable_paths, bootstrap_after_bump, bootstrap_pasteable_lines, bootstrap_spelling, repo_hygiene, agents_md_codes, cli_reference sets, 635 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #961

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant