diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index 0c84142a..e31385cd 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -29,7 +29,18 @@ 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 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 @@ -588,7 +599,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 @@ -674,11 +688,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.") @@ -702,6 +718,92 @@ 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 a branch spelled like " + "{branch} 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 `, 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/"):]) + 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 {_paste_word(path)} switch {_paste_word(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` @@ -861,7 +963,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. @@ -877,15 +980,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) @@ -902,8 +1015,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 @@ -924,12 +1037,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 @@ -951,11 +1065,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}": @@ -1058,7 +1188,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 @@ -1070,7 +1200,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 @@ -1081,6 +1219,11 @@ 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 (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 # leaves the commit made there on no branch (#533), as placing a @@ -1160,15 +1303,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..1631b28e --- /dev/null +++ b/tests/test_bootstrap_casefold_twin.py @@ -0,0 +1,529 @@ +# 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 re +import subprocess +import sys +from pathlib import Path + +import pytest + +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}" +#: 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 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, " + "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, 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 + ` -> ` 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`. 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(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}") + 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( + 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" + 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( + 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) + 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) == ( + 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) == "" + + +#: 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( + 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("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, + 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, 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") + 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.") + + +@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 + 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