From 80353dd1b3470b798ba2270b12ef746b19a5881f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 15:02:26 +0000 Subject: [PATCH 1/3] 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) The assembly root's pinned bootstrap.py compared HEAD's ref with `refs/heads/` 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 ` 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 switch `, 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 --- templates/assembly-root/scripts/bootstrap.py | 182 +++++++- tests/test_bootstrap_casefold_twin.py | 429 +++++++++++++++++++ 2 files changed, 588 insertions(+), 23 deletions(-) create mode 100644 tests/test_bootstrap_casefold_twin.py diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index f7531de7..16fb186d 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -29,7 +29,16 @@ whatever the branch or `origin` holds: a commit made there is on no branch, and a checkout would leave it only in the reflog, so bootstrap says where the leg is instead, and so is one whose HEAD is on a ref - outside `refs/heads/`, where a commit lands on that ref (#704). A leg + outside `refs/heads/`, where a commit lands on that ref (#704). So is + a leg whose HEAD is on a branch that differs from the tracking branch + only in case (`Main` for `main`), whether the tracking branch is + behind the pin, at it or not there: a disk that folds case (macOS, + Windows) reads the two as one branch, so a branch moved or made as + `main` there could be the ref HEAD stands on, with the index left at + the old commit. No ref moves and none is made; the line says where + the leg is, and gives `git -C switch ` as the way out, + or, for a branch that is not there, to keep the commits on a branch + named otherwise and check out the pin (#896). A leg with a merge, a cherry-pick, a revert, a rebase or an `am` in progress is not moved at all: a checkout ends the first three, carrying what they staged onto the branch, and leaves the last two to go on over @@ -575,7 +584,10 @@ def _check_out(sub: Path, branch: str, def _move_and_check_out(sub: Path, branch: str, pin: str) -> tuple[bool, bool, list[str] | None]: """From HEAD DETACHED AT THE PIN, or on ANOTHER BRANCH (#603, D1): - `branch -f `, then `_check_out` of the branch. + `branch -f `, then `_check_out` of the branch. Another + branch is never a twin of `branch`, one that differs from it only in + case: `branch -f` would move the ref HEAD stands on, so + `fast_forward_to_pin` leaves that leg first (#896). From the pin the branch is by then the commit already checked out, so no file in the leg changes, a local edit included. From another branch @@ -661,11 +673,13 @@ def _say_left(head: str | None, current: str, named: str) -> None: f"{named} now") -def _say_keep(options: str = "the pin or the branch") -> None: +def _say_keep(options: str = "the pin or the branch", + kept: str = "a branch") -> None: """What to do about a checkout step (a) will not move, in the words - both refusals of it share. Nothing moves.""" + both refusals of it share, and a third's (`_say_twin`, which names the + branch the commits are kept on, `kept`). Nothing moves.""" print(" not moving the checkout off it. Keep any commit made " - f"there on a branch, check out {options} in the leg, and run " + f"there on {kept}, check out {options} in the leg, and run " "bootstrap again.") @@ -689,6 +703,88 @@ def _on_a_branch(current: str | None) -> bool: return current is not None and current.startswith("refs/heads/") +def _folded_twin(current: str | None, branch: str) -> bool: + """Whether HEAD's ref is a TWIN of the tracking branch: a branch under + `refs/heads/` whose name is the branch's under `str.casefold()` and not + byte for byte, `refs/heads/Main` for `main` (#896). + + WHAT IS WRONG WITH ONE. A disk that folds case (macOS APFS and Windows + NTFS by default) reads the loose files `refs/heads/Main` and + `refs/heads/main` as one file, so `git switch Main` in a leg whose + branch is `main` puts HEAD on the very ref `branch -f main ` + moves. Git's own guard against moving a branch that is checked out + compares the two names byte for byte and lets it through; the branch + moves under HEAD, `checkout main` then finds HEAD at the pin and + changes no file, and the index and the work tree stay at the old + commit. Each path that would move or make the branch asks this first + and leaves the leg: `fast_forward_to_pin`, `place_at_pin` and + `create_at_pin`. + + A detached HEAD (`None`) and a ref outside `refs/heads/` are never a + twin. The names after `refs/heads/` are compared, and a name this + system holds as surrogate escapes (a branch that is not UTF-8, #704) + folds as the text it is, so no name raises here. NFC and NFD spellings + of one name are NOT folded: APFS reads those as one file too, and that + is not handled here. + """ + if current is None or not current.startswith("refs/heads/"): + return False + held = current[len("refs/heads/"):] + return held != branch and held.casefold() == branch.casefold() + + +#: What step (a) says a HEAD on a twin of the tracking branch could do, by +#: what it would otherwise have done (#896): `move` is `fast_forward_to_pin`'s +#: `branch -f`, `place` is `place_at_pin`'s checkout and its line that the +#: leg left a branch, and `make` is `create_at_pin`'s `checkout -b`. +TWIN_RISKS = { + "move": "moving {branch} could move the ref HEAD stands on and leave " + "the index at the old commit", + "place": "the leg is already on {branch} there, and bootstrap would say " + "it left a branch that keeps its commits", + "make": "making {branch} could hide the commits {twin} holds behind the " + "new ref", +} + + +def _say_twin(label: str, path: str, branch: str, current: str, state: str, + act: str) -> None: + """A leg whose HEAD is on a twin of the tracking branch (`_folded_twin`, + #896): nothing moves and no branch is made, and the lines say where the + leg is, why it is left and the way out. `state` says what the branch is + to the pin, and `act` which of `TWIN_RISKS` is the harm. + + THE WORDS HOLD ON A DISK THAT KEEPS CASE TOO, where `Main` and `main` + are two branches: they say only that a disk that folds case reads them + as one, and never that they are one. Where the branch is there, the + way out is `git -C switch `, 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` + of `fast_forward_to_pin` or the no-op checkout of `place_at_pin`; where + the two are branches, it puts the leg on the real one, and `Main` keeps + its commits. Where the branch is NOT there (`make`), that command is no + way out on a disk that keeps case, so the commits `Main` holds are kept + on a branch of another name, and the pin is checked out: on a disk that + folds case, `checkout -b main` would write the file `Main` reads, and + hide what `Main` holds, packed or not, behind it. + """ + named = spelled(branch) + twin = spelled(current[len("refs/heads/"):]) + risk = TWIN_RISKS[act].format(branch=named, twin=twin) + print(f" {label}: {state}, and the checkout is on {twin}, which differs " + f"from {named} only in case") + print(f" a disk that folds case (macOS, Windows) reads {twin} " + f"and {named} as one branch, so {risk}; the leg is left as it is") + if act == "make": + _say_keep("the pin", "a branch whose name differs from " + f"{named} by more than case") + return + command = (f"git -C {spelled(shlex.quote(path))} switch " + f"{spelled(shlex.quote(branch))}") + print(f" run `{command}`, which moves no branch, and then run " + "bootstrap again.") + + def _in_progress(sub: Path) -> tuple[tuple[str, str, str], str] | None: """The `IN_PROGRESS` row of the operation git has in progress in the leg, with where its marker is (`git dir` or `ref store`), or `None` @@ -782,7 +878,8 @@ def _say_in_progress(label: str, path: str, row: tuple[str, str, str], f"(`{command} --abort`), then run bootstrap again.") -def place_at_pin(sub: Path, label: str, branch: str, pin: str) -> None: +def place_at_pin(sub: Path, label: str, path: str, branch: str, + pin: str) -> None: """Step (a) for a leg whose tracking branch IS the pin: check it out, and say `on ` only when HEAD is on it afterwards. @@ -798,15 +895,25 @@ def place_at_pin(sub: Path, label: str, branch: str, pin: str) -> None: bootstrap, checked the branch out and printed `on at ` over a commit that was then on no branch, held by HEAD's reflog alone. Detached AT the pin, or already on the branch, the checkout is as it was. - On ANOTHER branch it is as it was too: that branch holds its commits, - and the line under `on ` now says the leg left it, as T7's does - (#704). A HEAD on a ref outside `refs/heads/` (`git symbolic-ref HEAD + On ANOTHER branch it is as it was too, unless that branch is a twin of + this one (below): that branch holds its commits, and the line under + `on ` now says the leg left it, as T7's does (#704). A HEAD on + a ref outside `refs/heads/` (`git symbolic-ref HEAD refs/remotes/origin/main`) is left where it is, as T7 leaves it: a commit made there lands on that ref, and this placed the leg while T7 - said `which is neither` of the same HEAD (#704). + said `which is neither` of the same HEAD (#704). So is a HEAD on a + branch that differs from `branch` only in case (`Main` for `main`, + `_folded_twin`): a disk that folds case reads the two as one branch, + so the leg is not on another branch there, and the line that it left + one would be false (#896). `path` is the leg's, for the command the + line gives (`_say_twin`). """ named = spelled(branch) head, current = _head_of(sub) + if _folded_twin(current, branch): + _say_twin(label, path, branch, current, + f"branch {named} is at the pin {_short(pin)}", "place") + return if not (_on_a_branch(current) or (current is None and head == pin)): _say_neither(label, f"branch {named} is at the pin {_short(pin)}", head, current) @@ -823,8 +930,8 @@ def place_at_pin(sub: Path, label: str, branch: str, pin: str) -> None: _say_after(why, moved=not already) -def fast_forward_to_pin(sub: Path, label: str, branch: str, local: str, - pin: str, remote: str | None) -> None: +def fast_forward_to_pin(sub: Path, label: str, path: str, branch: str, + local: str, pin: str, remote: str | None) -> None: """Step (a) for a leg whose tracking branch is BEHIND the pin (#475). The branch tip `local` is an ancestor of `pin`, so moving the branch to @@ -845,12 +952,13 @@ def fast_forward_to_pin(sub: Path, label: str, branch: str, local: str, option; such a git has no autostash and ignores the key. - HEAD detached AT THE PIN: `_move_and_check_out`. - HEAD on ANOTHER BRANCH, a `refs/heads/` ref that is not the tracking - branch (#603, D1 of its checklist triage): `_move_and_check_out`. The - other branch is not touched and keeps every commit made on it, so the - move loses no commit and that work is no reason to leave the leg - there. `place_at_pin` already checks the tracking branch out from - this checkout when it is AT the pin; leaving the leg for a branch - BEHIND the pin was a difference nobody could explain (#458). The + branch and not a twin of it (#603, D1 of its checklist triage, and + #896, below): `_move_and_check_out`. The other branch is not + touched and keeps every commit made on it, so the move loses no + commit and that work is no reason to leave the leg there. + `place_at_pin` already checks the tracking branch out from this + checkout when it is AT the pin; leaving the leg for a branch BEHIND + the pin was a difference nobody could explain (#458). The checkout changes files from here, so git can refuse it over a local edit or an untracked or ignored file, and `_move_and_check_out` says what is left then. A HEAD on a branch with no commit yet takes this @@ -872,11 +980,27 @@ def fast_forward_to_pin(sub: Path, label: str, branch: str, local: str, and nothing moves. So is a HEAD on a ref outside `refs/heads/`, which `place_at_pin` now leaves too (#704). + SO IS A HEAD ON A TWIN OF THE BRANCH (#896): a branch that differs from + `branch` only in case, `Main` for `main` (`_folded_twin`). A disk that + folds case (macOS, Windows) reads the two as one loose ref, so `branch + -f main ` moves the very ref HEAD stands on, git's own guard lets + it through (it compares the names byte for byte), `checkout main` finds + HEAD at the pin and changes no file, and the index and the work tree + stay at the old commit: the next commit made in the leg reverts the + pin's changes, under a line that said it was fast-forwarded. No ref + moves; `_say_twin` says where the leg is, why it is left, and that + `git -C switch ` is the way out. `path` is the leg's. + `label` is the leg as `checkout_tracking_branch` prints it, already spelled; the branch names and git's own lines are spelled here (#391). """ named = spelled(branch) head, current = _head_of(sub) + if _folded_twin(current, branch): + _say_twin(label, path, branch, current, + f"branch {named} tip {_short(local)} is behind the PIN " + f"{_short(pin)}", "move") + return other_branch = (_on_a_branch(current) and current != f"refs/heads/{branch}") if current == f"refs/heads/{branch}": @@ -976,7 +1100,7 @@ def _stays(sub: Path, branch: str, pin: str) -> str: return f"at {_short(head)} ({_place_of(current)})" -def create_at_pin(sub: Path, label: str, branch: str, pin: str, +def create_at_pin(sub: Path, label: str, path: str, branch: str, pin: str, remote: str | None) -> None: """Step (a) for a leg whose tracking branch IS NOT THERE: `checkout -b `, from HEAD detached at the pin or on a branch, a branch @@ -988,7 +1112,15 @@ def create_at_pin(sub: Path, label: str, branch: str, pin: str, one is, where git overwrote it silently. The line used to say `leaving detached` of a leg on another branch, too; it says where the leg stays. A HEAD on a ref outside `refs/heads/` is left where it is, as the - branch-at-the-pin and the behind-branch paths leave it (#704). + branch-at-the-pin and the behind-branch paths leave it (#704). So is a + HEAD on a TWIN of the branch, one that differs from it only in case + (`Main` for `main`, `_folded_twin`, #896), and no branch is made: a + disk that folds case reads the new file `refs/heads/main` as the file + `refs/heads/Main`, so the commits `Main` holds, packed as they are + after `git pack-refs`, are left behind it, in no ref anyone reads. + `switch ` is no way out of a leg with no such branch, so + `_say_twin` says to keep the commits on a branch of another name and to + check out the pin; `path` is the leg's. The checkout is judged by WHERE HEAD IS AFTERWARDS, as `_check_out` judges one (#533; rv704's S3): a post-checkout hook that fails after @@ -999,6 +1131,10 @@ def create_at_pin(sub: Path, label: str, branch: str, pin: str, """ named = spelled(branch) head, current = _head_of(sub) + if _folded_twin(current, branch): + _say_twin(label, path, branch, current, + f"branch {named} does not exist", "make") + return if head is not None and current is None and head != pin: # `checkout -b ` from a leg detached anywhere else # leaves the commit made there on no branch (#533), as placing a @@ -1078,15 +1214,15 @@ def checkout_tracking_branch(root: Path, leg: dict, branch: str) -> None: if pin is None: print(f" {where}: no gitlink recorded; leaving as is") else: - create_at_pin(sub, where, branch, pin, remote) + create_at_pin(sub, where, path, branch, pin, remote) return if local == pin: - place_at_pin(sub, where, branch, pin) + place_at_pin(sub, where, path, branch, pin) return if _is_ancestor(sub, local, pin): - fast_forward_to_pin(sub, where, branch, local, pin, remote) + fast_forward_to_pin(sub, where, path, branch, local, pin, remote) return upstream = _held_upstream_past_the_pin(sub, branch, local, pin) diff --git a/tests/test_bootstrap_casefold_twin.py b/tests/test_bootstrap_casefold_twin.py new file mode 100644 index 00000000..b2cb8cd2 --- /dev/null +++ b/tests/test_bootstrap_casefold_twin.py @@ -0,0 +1,429 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`make bootstrap` leaves a leg whose HEAD is on a TWIN of the tracking +branch, one that differs from it only in case, and moves nothing (#896). + +THE DEFECT, from lane 3's triage of #458 (R603, "reasoned only"). The +assembly root's pinned `scripts/bootstrap.py` compared HEAD's ref with +`refs/heads/` byte for byte. A disk that folds case (macOS APFS, +Windows NTFS, by default), with loose refs, reads `refs/heads/Main` and +`refs/heads/main` as ONE file, so `git switch Main` in a leg whose tracking +branch is `main` puts HEAD on the file `branch -f main ` moves. Once the +pin moved past that commit, step (a) called the leg "on another branch": + +1. `branch -f main ` was let through, since git's guard against moving + a checked-out branch compares the two names byte for byte, and it moved + the file HEAD stands on; +2. `checkout main` then found HEAD at the pin and changed no file; +3. bootstrap printed `branch main fast-forwarded` and `the leg was on Main, + which keeps its commits; it is on main now`; +4. the index and the work tree still held the old commit, so the next + commit in the leg reverted the pin's changes. + +THE EMULATION. CI is Linux only, so a disk that folds case is emulated on +one that keeps it: a symlink `.git/refs/heads/Main -> main`, which git's files +backend reads through, and HEAD on `refs/heads/Main`. The issue's own probe +made the state the same way (`git status --short` said `M f.txt` after). + +THE RULE NOW. Where HEAD's ref and `refs/heads/` are equal under +`str.casefold()` and differ in bytes, bootstrap leaves the leg on each of the +three paths that would have moved or made the branch: T7 (the branch is +behind the pin), P9 (it is at the pin) and the missing-branch path. No ref +moves and none is made. The lines say where the leg is, why it is left, and +the way out: `git -C switch ` for T7 and P9, which on a disk +that folds case re-spells HEAD and moves no file, and for the missing branch, +where that command has no branch to go to, keeping the commits on a branch of +another name and checking out the pin. + +THE WORDS HOLD ON A DISK THAT KEEPS CASE TOO, where `Main` and `main` are two +branches: they say that a disk that folds case reads them as one, never that +they are. `test_on_a_disk_that_keeps_case_the_words_are_true_too` runs the +way out there, with a real `Main` that has commits of its own. + +A test with a symlink skips on Windows, where a tracked or ref symlink is a +plain file or needs Developer Mode, and wherever `os.symlink` raises: a disk +that folds case refuses the link, `Main` already being `main`. A test that +makes a real `Main` skips on such a disk too. +""" + +from __future__ import annotations + +import itertools +import os +import sys +from pathlib import Path + +import pytest + +from conftest import git +from test_bootstrap_after_bump import ( # noqa: F401 + EDITED, MAIN, bootstrapped, bumped, fast_forwarded, head_ref, + left_branch, rev, root, skip_validators, +) +from test_bootstrap_branch_bytes import bootstrap_module # noqa: F401 +from test_bootstrap_leg_moves import main_of, typed + +TWIN = "Main" +TWIN_REF = f"refs/heads/{TWIN}" +#: A branch of the spec remote per `behind` call, each its own so that no +#: test sees another's commit (as `bumped` asks). +ADVANCES = itertools.count() +#: The second line of the three twin lines, by what the step would have done. +RISKS = { + "move": "moving main could move the ref HEAD stands on and leave the " + "index at the old commit", + "place": "the leg is already on main there, and bootstrap would say it " + "left a branch that keeps its commits", + "make": "making main could hide the commits Main holds behind the new " + "ref", +} +#: The way out of a leg with a branch to go to (T7, P9). +SWITCH = (" run `git -C spec switch main`, which moves no branch, " + "and then run bootstrap again.\n") +#: The way out of a leg with no `main` (#896's missing-branch path). +KEEP = (" 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.\n") +HEADING = "\n(a) legs on tracking branches, pins untouched\n" + + +def twin_lines(state: str, act: str, way: str, twin: str = TWIN) -> str: + """The three lines step (a) prints of a leg on a twin, byte for byte as + main prints them, `state` being what the branch is to the pin.""" + return (f" [spec] spec: {state}, and the checkout is on {twin}, which " + "differs from main only in case\n" + f" a disk that folds case (macOS, Windows) reads {twin} " + f"and main as one branch, so {RISKS[act]}; the leg is left as it " + f"is\n{way}") + + +def step_a(stdout: str) -> str: + """Step (a)'s lines of the spec leg, the manifest's first: from the + heading to the second leg's line, which none of these tests touches.""" + return stdout.split(HEADING, 1)[1].split(" [code] code:", 1)[0] + + +def gitdir_of(leg: Path) -> Path: + return Path(git("rev-parse", "--absolute-git-dir", + cwd=leg).stdout.strip()) + + +def twin_on_disk(leg: Path, twin: str = TWIN) -> None: + """HEAD on `refs/heads/`, which reads the file `main` is: what + `git switch Main` does on a disk that folds case, emulated by a symlink + ` -> main` that git's files backend reads through. Skips on + Windows, where a symlink needs Developer Mode and git does not read + through one by default, and wherever the platform cannot make one: a + disk that folds case refuses the link, `Main` being `main`. `main` is + loose, as the link needs.""" + if sys.platform == "win32": + pytest.skip("a ref symlink is not read through on Windows") + link = gitdir_of(leg) / "refs" / "heads" / twin + try: + os.symlink("main", link) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"this platform cannot make the symlink that emulates " + f"a disk that folds case: {exc}") + git("symbolic-ref", "HEAD", f"refs/heads/{twin}", cwd=leg) + + +def keeps_case(directory: Path) -> bool: + """Whether the disk tells `case-probe` from `CASE-PROBE`.""" + (directory / "case-probe").write_bytes(b"") + return not (directory / "CASE-PROBE").exists() + + +def needs_a_disk_that_keeps_case(tmp_path: Path) -> None: + """Skip where `Main` and `main` are one file, so no real second branch + can be made.""" + if not keeps_case(tmp_path): + pytest.skip("this disk folds case: `Main` is `main` here, and a " + "real second branch cannot be made") + + +def behind(root: Path, remotes: Path, tmp_path: Path) -> tuple[str, str]: + """T7's state, from the bootstrapped clone: the pin moved past `main` + (`bump-leg.py`, the commit also editing a tracked file), then `main` + checked out, so HEAD is on it, behind the pin. Returns `main`'s old + commit and the pin.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, remotes, tmp_path, f"twin-{next(ADVANCES)}", EDITED) + git("checkout", "-q", "main", cwd=leg) + return old, new + + +def commit_on(leg: Path, name: str) -> str: + """A commit of its own on the branch HEAD is on. Returns it.""" + (leg / name).write_bytes(b"work\n") + git("add", "--", name, cwd=leg) + git("commit", "-q", "-m", f"work: {name}", cwd=leg) + return rev(leg) + + +def printed_way_out(stdout: str) -> str: + """The command the `run ...` line gives, between its backticks.""" + line = next(row for row in stdout.splitlines() + if row.startswith(" run `")) + return line.split("`")[1] + + +def status(leg: Path) -> str: + return git("status", "--short", cwd=leg).stdout + + +def test_t7_leaves_a_leg_on_a_twin_and_moves_no_ref( + root, bootstrapped, tmp_path): + """THE ISSUE'S PROBE. `main` loose and behind the pin, HEAD on the + symlink twin `Main`. `main` moved to the pin under HEAD, and `M + README.md` followed, under `fast-forwarded` and `it is on main now`.""" + old, new = behind(root, bootstrapped["remotes"], tmp_path) + leg = root / "spec" + twin_on_disk(leg) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert step_a(result.stdout) == twin_lines( + f"branch main tip {old[:12]} is behind the PIN {new[:12]}", "move", + SWITCH), result.stdout + assert main_of(leg) == old, "`main` is not moved" + assert head_ref(leg) == TWIN_REF, "HEAD stays where it was" + assert rev(leg) == old + assert status(leg) == "", "the index is the commit HEAD is at" + assert rev(root, "HEAD:spec") == new, "and the pin is not moved" + + +def test_the_way_out_of_t7_puts_the_leg_on_main_and_the_next_run_moves_it( + root, bootstrapped, tmp_path): + """The printed `git -C spec switch main`, run as printed, re-spells + HEAD and moves no file; the next bootstrap takes the on-branch + `merge --ff-only` path, which moves the files with the branch.""" + old, new = behind(root, bootstrapped["remotes"], tmp_path) + leg = root / "spec" + twin_on_disk(leg) + first = skip_validators(root) + assert first.returncode == 0, first.stderr + first.stdout + + typed(root, printed_way_out(first.stdout)) + assert head_ref(leg) == MAIN, "HEAD is re-spelled" + assert main_of(leg) == old, "and no branch moved" + + again = skip_validators(root) + assert again.returncode == 0, again.stderr + again.stdout + assert step_a(again.stdout) == fast_forwarded(old, new), again.stdout + assert head_ref(leg) == MAIN + assert rev(leg) == new == main_of(leg), "at the pin" + assert status(leg) == "" + + +def test_p9_leaves_a_leg_on_a_twin_of_the_branch_at_the_pin(root): + """`main` AT the pin, HEAD on the twin. Bootstrap checked `main` out and + said `on main at ` and that the leg was on `Main`, which keeps its + commits: a branch that, on such a disk, is `main`.""" + leg = root / "spec" + pin = rev(leg) + twin_on_disk(leg) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert step_a(result.stdout) == twin_lines( + f"branch main is at the pin {pin[:12]}", "place", SWITCH), ( + result.stdout) + assert main_of(leg) == pin + assert head_ref(leg) == TWIN_REF, "HEAD stays where it was" + assert status(leg) == "" + + typed(root, printed_way_out(result.stdout)) + again = skip_validators(root) + assert again.returncode == 0, again.stderr + again.stdout + assert step_a(again.stdout) == ( + f" [spec] spec: on main at {pin[:12]} (branch tip == pin)\n") + assert head_ref(leg) == MAIN + + +def without_main(leg: Path) -> str: + """The pin's commit, the leg detached there with `main` deleted: the + state a missing-branch step (a) starts from.""" + pin = rev(leg) + git("checkout", "-q", "--detach", pin, cwd=leg) + git("branch", "-q", "-D", "main", cwd=leg) + return pin + + +def test_the_missing_branch_path_leaves_an_unborn_twin_and_makes_nothing( + root): + """`Main` unborn (no commit, the dangling symlink `Main -> main` a disk + that folds case reads as one), `main` not there. `checkout -b main` made + the file `Main` reads, and said the leg had been on a branch with no + commit yet.""" + leg = root / "spec" + pin = without_main(leg) + twin_on_disk(leg) + assert main_of(leg) is None + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert step_a(result.stdout) == twin_lines( + "branch main does not exist", "make", KEEP), result.stdout + assert main_of(leg) is None, "no `main` is made" + assert not (gitdir_of(leg) / "refs" / "heads" / "main").exists() + assert head_ref(leg) == TWIN_REF, "HEAD stays where it was" + assert pin == rev(root, "HEAD:spec") + + +def test_the_missing_branch_path_leaves_a_packed_twin_with_commits( + root, tmp_path): + """The state that loses something on a disk that folds case: `Main` + holds a commit of its own, packed (`git pack-refs --all`, which is + what a `gc` leaves), and `main` is not there. A new loose `main` is the + file `Main` reads, which hides what `Main` holds. Left, with the way + out run as printed: the commit kept on a branch of another name, the + pin checked out, bootstrap again, which makes `main` at the pin.""" + needs_a_disk_that_keeps_case(tmp_path) + leg = root / "spec" + pin = rev(leg) + git("checkout", "-q", "-b", TWIN, cwd=leg) + mine = commit_on(leg, "MINE.md") + git("branch", "-q", "-D", "main", cwd=leg) + git("pack-refs", "--all", cwd=leg) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert step_a(result.stdout) == twin_lines( + "branch main does not exist", "make", KEEP), result.stdout + assert main_of(leg) is None, "no `main` is made" + assert head_ref(leg) == TWIN_REF + assert rev(leg) == mine, "HEAD stays on `Main`'s commit" + assert status(leg) == "" + + git("branch", "-q", "kept-from-Main", cwd=leg) + git("checkout", "-q", "--detach", pin, cwd=leg) + again = skip_validators(root) + assert again.returncode == 0, again.stderr + again.stdout + assert step_a(again.stdout) == ( + f" [spec] spec: branch main created at {pin[:12]} — at the pin\n"), ( + again.stdout) + assert head_ref(leg) == MAIN + assert rev(leg, "refs/heads/kept-from-Main") == mine + assert rev(leg, TWIN_REF) == mine, "`Main` keeps its commit here" + + +def test_on_a_disk_that_keeps_case_the_words_are_true_too( + root, bootstrapped, tmp_path): + """No symlink: a REAL loose `Main` with a commit of its own, HEAD on + it, `main` behind the pin. The leg is left with the same lines. The way + out, run as printed, puts the leg on the real `main`, which bootstrap + then fast-forwards, and `Main` keeps its commit: the words said only + that a disk that folds case reads the two as one branch.""" + needs_a_disk_that_keeps_case(tmp_path) + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, + f"twin-{next(ADVANCES)}", EDITED) + git("checkout", "-q", "-b", TWIN, "main", cwd=leg) + mine = commit_on(leg, "MINE.md") + + first = skip_validators(root) + assert first.returncode == 0, first.stderr + first.stdout + assert step_a(first.stdout) == twin_lines( + f"branch main tip {old[:12]} is behind the PIN {new[:12]}", "move", + SWITCH), first.stdout + assert main_of(leg) == old + assert rev(leg, TWIN_REF) == mine + assert head_ref(leg) == TWIN_REF + assert status(leg) == "" + + typed(root, printed_way_out(first.stdout)) + assert head_ref(leg) == MAIN, "on the real `main`" + assert rev(leg) == old + again = skip_validators(root) + assert again.returncode == 0, again.stderr + again.stdout + assert step_a(again.stdout) == fast_forwarded(old, new), again.stdout + assert head_ref(leg) == MAIN + assert rev(leg) == new == main_of(leg) + assert rev(leg, TWIN_REF) == mine, "`Main` keeps its commit" + assert status(leg) == "" + + +@pytest.mark.parametrize("other", [None, "feature", "mains"], + ids=["main itself", "feature", "mains"]) +def test_every_other_checkout_is_moved_as_it_was( + root, bootstrapped, tmp_path, other): + """T7 from HEAD on `main` itself, on a branch that does not fold to + `main` (`feature`) and on one that nearly does (`mains`): the leg is + moved, and the line that it left a branch is said for the two others + only, byte for byte as before.""" + old, new = behind(root, bootstrapped["remotes"], tmp_path) + leg = root / "spec" + if other is not None: + git("checkout", "-q", "-b", other, "main", cwd=leg) + expected = fast_forwarded(old, new) + if other is not None: + expected += left_branch(other) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert step_a(result.stdout) == expected, result.stdout + assert head_ref(leg) == MAIN + assert rev(leg) == new == main_of(leg) + assert status(leg) == "" + + +SIGMA = chr(0x3C3) +FINAL_SIGMA = chr(0x3C2) +SURROGATE = chr(0xDCFF) + + +@pytest.mark.parametrize("current, branch, twin", [ + pytest.param("refs/heads/Main", "main", True, id="Main"), + pytest.param("refs/heads/MAIN", "main", True, id="MAIN"), + pytest.param("refs/heads/main", "Main", True, id="branch is the twin"), + pytest.param("refs/heads/main", "main", False, id="the branch itself"), + pytest.param("refs/heads/Main", "Main", False, id="Main itself"), + pytest.param(None, "main", False, id="detached"), + pytest.param("refs/remotes/origin/main", "main", False, id="not a head"), + pytest.param("refs/heads/feature", "main", False, id="another branch"), + pytest.param("refs/heads/mains", "main", False, id="a longer name"), + pytest.param("refs/Heads/main", "main", False, id="not refs/heads/"), + pytest.param(f"refs/heads/{SIGMA}", FINAL_SIGMA, True, + id="casefold folds a final sigma, lower() does not"), + pytest.param(f"refs/heads/a{SURROGATE}b", f"a{SURROGATE}b", False, + id="a name that is not UTF-8, itself"), + pytest.param(f"refs/heads/A{SURROGATE}b", f"a{SURROGATE}b", True, + id="a name that is not UTF-8, a twin"), +]) +def test_the_helper_asks_the_names_after_refs_heads( + bootstrap_module, current, branch, twin): + """`_folded_twin`: equal under `casefold()` and not in bytes, for a + branch under `refs/heads/` only. It raises on no name (#704).""" + assert bootstrap_module._folded_twin(current, branch) is twin + + +def test_the_way_out_is_quoted_for_a_shell_as_the_move_back_line_is( + bootstrap_module, capsys): + """The command is the leg's path and the branch, `shlex.quote`d and then + spelled, as `move_back_to_pin` prints its own.""" + bootstrap_module._say_twin("[spec] my leg", "my leg", "a$b", + "refs/heads/A$b", "branch a$b is at the pin", + "place") + lines = capsys.readouterr().out.splitlines() + assert lines[-1] == (" run `git -C 'my leg' switch 'a$b'`, which " + "moves no branch, and then run bootstrap again.") + + +def test_the_lines_spell_a_name_that_is_not_utf_8(bootstrap_module, capsys): + """A twin named with a byte that is not UTF-8 holds a surrogate escape + here (#704): printed raw it would raise `UnicodeEncodeError`, so each + name in the lines is spelled (#391), the leg's path too.""" + twin = f"A{SURROGATE}b" + branch = f"a{SURROGATE}b" + leg = f"a{chr(0x1B)}b" + spelled = bootstrap_module.spelled + bootstrap_module._say_twin( + f"[spec] {spelled(leg)}", leg, branch, f"refs/heads/{twin}", + f"branch {spelled(branch)} does not exist", "make") + out = capsys.readouterr().out + assert out.isascii(), "every name is written as an escape" + assert chr(0x1B) not in out + assert ("the checkout is on A\\udcffb, which differs from a\\udcffb " + "only in case") in out + assert "differs from a\\udcffb by more than case" in out From 48222b5e6a66d23474db8b9cc6b3cec70cff0961 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 17:54:13 +0000 Subject: [PATCH 2/3] bootstrap.py: the missing-branch twin lines print the pin's sha and name 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 ), 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 --- templates/assembly-root/scripts/bootstrap.py | 24 ++++++++++++-------- tests/test_bootstrap_casefold_twin.py | 18 +++++++++++---- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index 16fb186d..cf131573 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -33,12 +33,14 @@ a leg whose HEAD is on a branch that differs from the tracking branch only in case (`Main` for `main`), whether the tracking branch is behind the pin, at it or not there: a disk that folds case (macOS, - Windows) reads the two as one branch, so a branch moved or made as - `main` there could be the ref HEAD stands on, with the index left at - the old commit. No ref moves and none is made; the line says where - the leg is, and gives `git -C switch ` as the way out, - or, for a branch that is not there, to keep the commits on a branch - named otherwise and check out the pin (#896). A leg + Windows) reads the two as one branch, so 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. No ref moves and none is made; the line + says where the leg is, and gives `git -C switch ` as + the way out, or, for a branch that is not there, to keep the commits + on a branch named otherwise and check out the pin, whose sha it + gives (#896). A leg with a merge, a cherry-pick, a revert, a rebase or an `am` in progress is not moved at all: a checkout ends the first three, carrying what they staged onto the branch, and leaves the last two to go on over @@ -742,8 +744,8 @@ def _folded_twin(current: str | None, branch: str) -> bool: "the index at the old commit", "place": "the leg is already on {branch} there, and bootstrap would say " "it left a branch that keeps its commits", - "make": "making {branch} could hide the commits {twin} holds behind the " - "new ref", + "make": "making {branch} could hide the commits a branch spelled like " + "{branch} holds behind the new ref", } @@ -766,7 +768,8 @@ def _say_twin(label: str, path: str, branch: str, current: str, state: str, way out on a disk that keeps case, so the commits `Main` holds are kept on a branch of another name, and the pin is checked out: on a disk that folds case, `checkout -b main` would write the file `Main` reads, and - hide what `Main` holds, packed or not, behind it. + hide behind it what `Main` holds in `packed-refs`. A loose `Main` there + makes `refs/heads/main` resolve, so this path never runs for it. """ named = spelled(branch) twin = spelled(current[len("refs/heads/"):]) @@ -1133,7 +1136,8 @@ def create_at_pin(sub: Path, label: str, path: str, branch: str, pin: str, head, current = _head_of(sub) if _folded_twin(current, branch): _say_twin(label, path, branch, current, - f"branch {named} does not exist", "make") + f"branch {named} does not exist (the pin is " + f"{_short(pin)})", "make") return if head is not None and current is None and head != pin: # `checkout -b ` from a leg detached anywhere else diff --git a/tests/test_bootstrap_casefold_twin.py b/tests/test_bootstrap_casefold_twin.py index b2cb8cd2..c55f0ce7 100644 --- a/tests/test_bootstrap_casefold_twin.py +++ b/tests/test_bootstrap_casefold_twin.py @@ -49,6 +49,7 @@ import itertools import os +import re import sys from pathlib import Path @@ -73,8 +74,8 @@ "index at the old commit", "place": "the leg is already on main there, and bootstrap would say it " "left a branch that keeps its commits", - "make": "making main could hide the commits Main holds behind the new " - "ref", + "make": "making main could hide the commits a branch spelled like main " + "holds behind the new ref", } #: The way out of a leg with a branch to go to (T7, P9). SWITCH = (" run `git -C spec switch main`, which moves no branch, " @@ -263,7 +264,8 @@ def test_the_missing_branch_path_leaves_an_unborn_twin_and_makes_nothing( result = skip_validators(root) assert result.returncode == 0, result.stderr + result.stdout assert step_a(result.stdout) == twin_lines( - "branch main does not exist", "make", KEEP), result.stdout + f"branch main does not exist (the pin is {pin[:12]})", "make", + KEEP), result.stdout assert main_of(leg) is None, "no `main` is made" assert not (gitdir_of(leg) / "refs" / "heads" / "main").exists() assert head_ref(leg) == TWIN_REF, "HEAD stays where it was" @@ -289,14 +291,18 @@ def test_the_missing_branch_path_leaves_a_packed_twin_with_commits( result = skip_validators(root) assert result.returncode == 0, result.stderr + result.stdout assert step_a(result.stdout) == twin_lines( - "branch main does not exist", "make", KEEP), result.stdout + f"branch main does not exist (the pin is {pin[:12]})", "make", + KEEP), result.stdout assert main_of(leg) is None, "no `main` is made" assert head_ref(leg) == TWIN_REF assert rev(leg) == mine, "HEAD stays on `Main`'s commit" assert status(leg) == "" git("branch", "-q", "kept-from-Main", cwd=leg) - git("checkout", "-q", "--detach", pin, cwd=leg) + shown = re.search(r"\(the pin is ([0-9a-f]{12})\)", result.stdout) + assert shown is not None, "the line prints the pin" + git("checkout", "-q", "--detach", shown.group(1), cwd=leg) + assert rev(leg) == pin, "the printed sha is the pin" again = skip_validators(root) assert again.returncode == 0, again.stderr + again.stdout assert step_a(again.stdout) == ( @@ -384,6 +390,8 @@ def test_every_other_checkout_is_moved_as_it_was( pytest.param("refs/heads/feature", "main", False, id="another branch"), pytest.param("refs/heads/mains", "main", False, id="a longer name"), pytest.param("refs/Heads/main", "main", False, id="not refs/heads/"), + pytest.param("refs/notes/Main", "main", False, id="a notes ref"), + pytest.param("refs/tags/xMain", "main", False, id="a tag, 11 characters"), pytest.param(f"refs/heads/{SIGMA}", FINAL_SIGMA, True, id="casefold folds a final sigma, lower() does not"), pytest.param(f"refs/heads/a{SURROGATE}b", f"a{SURROGATE}b", False, From 45415296546c2b8a384348160a46ab4f200bdafb Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 18:15:25 +0000 Subject: [PATCH 3/3] bootstrap.py: the `switch` line of a twin refusal words the leg's path 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 450279ca 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 --- templates/assembly-root/scripts/bootstrap.py | 29 ++--- tests/test_bootstrap_casefold_twin.py | 110 +++++++++++++++++-- 2 files changed, 117 insertions(+), 22 deletions(-) diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index f3602557..e31385cd 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -772,17 +772,21 @@ def _say_twin(label: str, path: str, branch: str, current: str, state: str, THE WORDS HOLD ON A DISK THAT KEEPS CASE TOO, where `Main` and `main` are two branches: they say only that a disk that folds case reads them as one, and never that they are one. Where the branch is there, the - way out is `git -C switch `, 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` - of `fast_forward_to_pin` or the no-op checkout of `place_at_pin`; where - the two are branches, it puts the leg on the real one, and `Main` keeps - its commits. Where the branch is NOT there (`make`), that command is no - way out on a disk that keeps case, so the commits `Main` holds are kept - on a branch of another name, and the pin is checked out: on a disk that - folds case, `checkout -b main` would write the file `Main` reads, and - hide behind it what `Main` holds in `packed-refs`. A loose `Main` there - makes `refs/heads/main` resolve, so this path never runs for it. + way out is `git -C switch `, the path and the branch each + one `_paste_word` (#932), as `move_back_to_pin` words its path and its + upstream: a name `shlex.quote` and `spelled` leave as it was is that + word, and one with a byte that is not UTF-8 is a `printf` that prints + the bytes, where its spelled escape named no branch to a shell. 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` of `fast_forward_to_pin` or + the no-op checkout of `place_at_pin`; where the two are branches, it + puts the leg on the real one, and `Main` keeps its commits. Where the + branch is NOT there (`make`), that command is no way out on a disk that + keeps case, so the commits `Main` holds are kept on a branch of another + name, and the pin is checked out: on a disk that folds case, + `checkout -b main` would write the file `Main` reads, and hide behind + it what `Main` holds in `packed-refs`. A loose `Main` there makes + `refs/heads/main` resolve, so this path never runs for it. """ named = spelled(branch) twin = spelled(current[len("refs/heads/"):]) @@ -795,8 +799,7 @@ def _say_twin(label: str, path: str, branch: str, current: str, state: str, _say_keep("the pin", "a branch whose name differs from " f"{named} by more than case") return - command = (f"git -C {spelled(shlex.quote(path))} switch " - f"{spelled(shlex.quote(branch))}") + command = f"git -C {_paste_word(path)} switch {_paste_word(branch)}" print(f" run `{command}`, which moves no branch, and then run " "bootstrap again.") diff --git a/tests/test_bootstrap_casefold_twin.py b/tests/test_bootstrap_casefold_twin.py index c55f0ce7..1631b28e 100644 --- a/tests/test_bootstrap_casefold_twin.py +++ b/tests/test_bootstrap_casefold_twin.py @@ -50,18 +50,21 @@ import itertools import os import re +import subprocess import sys from pathlib import Path import pytest -from conftest import git +from conftest import git, run_script from test_bootstrap_after_bump import ( # noqa: F401 EDITED, MAIN, bootstrapped, bumped, fast_forwarded, head_ref, left_branch, rev, root, skip_validators, ) from test_bootstrap_branch_bytes import bootstrap_module # noqa: F401 from test_bootstrap_leg_moves import main_of, typed +from test_bootstrap_non_utf8_branch import head_bytes, needs_the_byte +from test_bootstrap_pasteable_lines import pasted TWIN = "Main" TWIN_REF = f"refs/heads/{TWIN}" @@ -108,19 +111,19 @@ def gitdir_of(leg: Path) -> Path: cwd=leg).stdout.strip()) -def twin_on_disk(leg: Path, twin: str = TWIN) -> None: - """HEAD on `refs/heads/`, which reads the file `main` is: what +def twin_on_disk(leg: Path, twin: str = TWIN, branch: str = "main") -> None: + """HEAD on `refs/heads/`, which reads the file `` is: what `git switch Main` does on a disk that folds case, emulated by a symlink - ` -> main` that git's files backend reads through. Skips on + ` -> ` that git's files backend reads through. Skips on Windows, where a symlink needs Developer Mode and git does not read through one by default, and wherever the platform cannot make one: a - disk that folds case refuses the link, `Main` being `main`. `main` is - loose, as the link needs.""" + disk that folds case refuses the link, `Main` being `main`. The branch + is loose, as the link needs.""" if sys.platform == "win32": pytest.skip("a ref symlink is not read through on Windows") link = gitdir_of(leg) / "refs" / "heads" / twin try: - os.symlink("main", link) + os.symlink(branch, link) except (NotImplementedError, OSError) as exc: pytest.skip(f"this platform cannot make the symlink that emulates " f"a disk that folds case: {exc}") @@ -350,6 +353,62 @@ def test_on_a_disk_that_keeps_case_the_words_are_true_too( assert status(leg) == "" +#: A tracking branch whose name is not UTF-8 (the byte 0xff, a lone +#: surrogate to Python), the twin HEAD is on, and the names as step (a) +#: spells them (#391) and the word the command holds for the branch (#932). +BYTE_BRANCH = f"a{chr(0xDCFF)}b" +BYTE_TWIN = f"A{chr(0xDCFF)}b" +BYTE_SPELLED = "a\\udcffb" +BYTE_TWIN_SPELLED = "A\\udcffb" +BYTE_WORD = r'''"$(printf -- 'a\377b')"''' + + +def test_the_way_out_of_a_twin_that_is_not_utf_8_is_one_a_shell_reads( + root, bootstrapped, tmp_path): + """T7 with a tracking branch `a<0xff>b` and HEAD on its twin `A<0xff>b` + (POSIX, on a disk that takes the byte; `--branch` names the branch). + `spelled(shlex.quote(...))` printed `switch 'a\\udcffb'`, which `/bin/sh` + reads as the characters it holds, so the pasted command named no branch + (#932's defect, in this line). `_paste_word` writes the bytes in a + `printf`. The leg is left; the command, run as printed through `sh -c`, + puts HEAD on the branch, byte for byte, and the next run moves it.""" + needs_the_byte(tmp_path) + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, + f"twin-{next(ADVANCES)}", EDITED) + git("checkout", "-q", "main", cwd=leg) + git("branch", "-q", "-m", "main", BYTE_BRANCH, cwd=leg) + twin_on_disk(leg, BYTE_TWIN, BYTE_BRANCH) + bootstrap = root / "scripts" / "bootstrap.py" + + first = run_script(bootstrap, "--skip-validators", "--branch", + BYTE_BRANCH, cwd=root) + assert first.returncode == 0, first.stderr + first.stdout + assert step_a(first.stdout).splitlines()[0] == ( + f" [spec] spec: branch {BYTE_SPELLED} tip {old[:12]} is behind the " + f"PIN {new[:12]}, and the checkout is on {BYTE_TWIN_SPELLED}, which " + f"differs from {BYTE_SPELLED} only in case"), first.stdout + command = printed_way_out(first.stdout) + assert command == f"git -C spec switch {BYTE_WORD}" + assert head_bytes(leg) == b"refs/heads/A\xffb\n", "HEAD stays put" + assert rev(leg, f"refs/heads/{BYTE_BRANCH}") == old + assert status(leg) == "" + + pasted(root, command) + assert head_bytes(leg) == b"refs/heads/a\xffb\n", "on the branch" + assert rev(leg) == old + + again = run_script(bootstrap, "--skip-validators", "--branch", + BYTE_BRANCH, cwd=root) + assert again.returncode == 0, again.stderr + again.stdout + assert (f" [spec] spec: branch {BYTE_SPELLED} fast-forwarded " + f"{old[:12]} -> {new[:12]}, the pin\n") in again.stdout + assert head_bytes(leg) == b"refs/heads/a\xffb\n" + assert rev(leg) == new + assert status(leg) == "" + + @pytest.mark.parametrize("other", [None, "feature", "mains"], ids=["main itself", "feature", "mains"]) def test_every_other_checkout_is_moved_as_it_was( @@ -408,8 +467,10 @@ def test_the_helper_asks_the_names_after_refs_heads( def test_the_way_out_is_quoted_for_a_shell_as_the_move_back_line_is( bootstrap_module, capsys): - """The command is the leg's path and the branch, `shlex.quote`d and then - spelled, as `move_back_to_pin` prints its own.""" + """The command is the leg's path and the branch, each one `_paste_word` + (#932), as `move_back_to_pin`'s are: a name that prints as it is stays + `shlex.quote`d, and `test_the_way_out_of_a_twin_that_is_not_utf_8_...` + holds the name that has to be written as bytes.""" bootstrap_module._say_twin("[spec] my leg", "my leg", "a$b", "refs/heads/A$b", "branch a$b is at the pin", "place") @@ -418,6 +479,37 @@ def test_the_way_out_is_quoted_for_a_shell_as_the_move_back_line_is( "moves no branch, and then run bootstrap again.") +@pytest.mark.skipif(os.name == "nt", reason="the words are `sh`'s") +@pytest.mark.parametrize("path, branch", [ + pytest.param("spec", "main", id="ordinary names"), + pytest.param(f"sp{BYTE_BRANCH[1]}ec", "main", id="path with the byte"), + pytest.param("spec", BYTE_BRANCH, id="branch with the byte"), + pytest.param(f"-sp{BYTE_BRANCH[1]}ec", BYTE_BRANCH, + id="both, the path leading with a dash"), + pytest.param(f"sp%s{BYTE_BRANCH[1]}ec", "main", id="path with a percent"), +]) +def test_a_shell_reads_each_word_of_the_way_out_as_the_bytes_of_its_name( + bootstrap_module, capsys, path, branch): + """`_paste_word` for the leg's path and for the branch, each of them: + the two words `/bin/sh` reads from the printed command are the bytes of + the two names, where the spelled escape of a byte that is not UTF-8 + named neither (#932).""" + if not os.path.exists("/bin/sh"): + pytest.skip("this system has no /bin/sh") + bootstrap_module._say_twin( + "[spec] x", path, branch, f"refs/heads/{branch.swapcase()}", + "branch is at the pin", "place") + last = capsys.readouterr().out.splitlines()[-1] + command = last.split("`")[1] + assert command.startswith("git -C ") + words = command[len("git -C "):].split(" switch ") + assert len(words) == 2, command + read = subprocess.run( + ["/bin/sh", "-c", f"printf '%s\\0%s' {words[0]} {words[1]}"], + capture_output=True, check=True).stdout + assert read == os.fsencode(path) + b"\0" + os.fsencode(branch) + + def test_the_lines_spell_a_name_that_is_not_utf_8(bootstrap_module, capsys): """A twin named with a byte that is not UTF-8 holds a surrogate escape here (#704): printed raw it would raise `UnicodeEncodeError`, so each