diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index d2b25a9c..2913aac8 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -13,13 +13,21 @@ the thing that makes people hate submodules, and the first thing an engineer meets on day one. Bootstrap creates the tracking branch AT the pinned commit, so the WORKING state is branch-shaped while the RECORDED - state stays an exact pin. It never advances the pin and it never moves a - branch that already exists somewhere else; when the pin and a branch tip - disagree it prints both and leaves them alone. Advancing a pin remains an - explicit commit in this repository — see `README.md`, "The lockstep - invariant". This is why `.gitmodules` carries no `branch=`: that would - buy the same ergonomics by weakening the pin, and then "what commit is - this project" would have two answers. + state stays an exact pin. It never advances the pin. A tracking branch + that already exists is FAST-FORWARDED to the pin when its tip is an + ancestor of it, because that move loses nothing: it is the state + `bump-leg.py` leaves, the leg detached at the new pin over a branch at + the old one. A branch AHEAD of the pin with no commit of its own, its + tip being its upstream's remote-tracking ref, is MOVED BACK to the pin + from a leg detached there, because that loses nothing either: it is + what git's own recursive clone leaves when the leg's remote has moved + past the pin, and the remote-tracking ref still holds every commit. + Any other branch holding a commit the pin does not contain is never + moved; bootstrap prints both and leaves them alone. Advancing a pin + remains an explicit commit in this repository — see `README.md`, "The + lockstep invariant". This is why `.gitmodules` carries no `branch=`: + that would buy the same ergonomics by weakening the pin, and then "what + commit is this project" would have two answers. (b) RUNS THE NEUTRAL VALIDATORS — naming, manifest, lockstep pins. These are the only ones a fork with no review lane and no wallet register has, and @@ -49,6 +57,7 @@ import argparse import os import re +import shlex import subprocess import sys from pathlib import Path @@ -72,6 +81,13 @@ SHAPE_UPSTREAM_ENV = "SHAPE_UPSTREAM_PATH" SHAPE_UPSTREAM_CACHE = ".shape-upstream-tip" COMMIT_40 = re.compile(r"^[0-9a-fA-F]{40}$") +#: Where a remote-tracking ref lives. Only an upstream there holds a branch's +#: commits on the remote's behalf; an upstream of `.` is another local branch. +REMOTE_TRACKING = "refs/remotes/" +#: What step (a) prints under git's own lines when git refuses a move. +NOTHING_ELSE = (" nothing else is tried; the recorded pin is " + "authoritative. Deal with what git said and run bootstrap " + "again.") REGISTER_RELPATH = Path("governance") / "review-authority" / "register.yaml" VALIDATORS = ( ("naming", ["scripts/validate-repository-naming.py", "--project", @@ -102,6 +118,278 @@ def _short(sha: str | None) -> str: return sha[:12] if sha else "?" +def _git_says(args: list[str], cwd: Path) -> list[str] | None: + """`None` when `git ` succeeds in `cwd`, else the lines git wrote + to say why not (never an empty list, and never a blank line). + + `_try_git` answers only whether a command worked, which is all a read + needs. A MOVE git refuses has a reason the person has to act on (an + untracked file in the way, a branch another worktree has checked out), + so bootstrap prints git's own words rather than a guess. The bytes are + decoded here, with `replace`, so no locale's codec decides whether the + reason can be read. The blank line git puts between two paragraphs is + dropped: printed under the indent, it was a line of trailing spaces. + """ + proc = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, + check=False) + if proc.returncode == 0: + return None + said = proc.stderr.decode("utf-8", "replace").splitlines() + verb = args[2] if args[0] == "-c" else args[0] + return [line.rstrip() for line in said if line.strip()] or [ + f"`git {verb}` exited {proc.returncode}"] + + +def _head_of(sub: Path) -> tuple[str | None, str | None]: + """The commit the leg has checked out, and the ref HEAD is on (`None` + when it is detached).""" + return (_try_git(["rev-parse", "--verify", "--quiet", "HEAD"], sub), + _try_git(["symbolic-ref", "--quiet", "HEAD"], sub)) + + +def _is_ancestor(sub: Path, commit: str | None, of: str | None) -> bool: + """Whether `commit` is an ancestor of `of`, as `git merge-base + --is-ancestor` answers it. A commit this leg has not fetched is not + known to be one, and is answered no.""" + return commit is not None and of is not None and _try_git( + ["merge-base", "--is-ancestor", commit, of], sub) is not None + + +def _upstream_holding(sub: Path, branch: str, local: str) -> str | None: + """The remote-tracking ref `branch` builds on, when the branch tip + `local` IS that ref's commit: a branch with no commit of its own. + + `None` for every other branch: one with no upstream, one whose upstream + is another local branch (`branch..remote` of `.`), one whose + remote-tracking ref is not there (never fetched, or pruned) and one + whose tip is not the upstream's. The two are compared by object id. + `for-each-ref` names the upstream whether or not its ref exists, and + takes the branch after `refs/heads/`, where git reads no option. + """ + upstream = _try_git(["for-each-ref", "--format=%(upstream)", + f"refs/heads/{branch}"], sub) + if not upstream or not upstream.startswith(REMOTE_TRACKING): + return None + held = _try_git(["rev-parse", "--verify", "--quiet", upstream], sub) + return upstream if held == local else None + + +def _held_upstream_past_the_pin(sub: Path, branch: str, local: str, + pin: str | None) -> str | None: + """`_upstream_holding`'s ref when `move_back_to_pin` loses nothing, + else `None`: the branch is AHEAD of the pin, the pin an ancestor of its + tip; it has no commit of its own; and HEAD is detached AT the pin, so + no file changes. Every other branch past the pin keeps its refusal.""" + upstream = _upstream_holding(sub, branch, local) + if (upstream is None or not _is_ancestor(sub, pin, local) + or _head_of(sub) != (pin, None)): + return None + return upstream + + +def _check_out(sub: Path, branch: str, + pin: str) -> tuple[bool, list[str] | None]: + """`git checkout `, judged by WHERE HEAD IS AFTERWARDS, not by + git's exit code. Returns whether HEAD is on the branch at the pin, and + git's lines when it exited non-zero (never `None` when HEAD is not). + + The two disagree both ways. A checkout git refused (a stale + `index.lock`, a local edit in the way) leaves HEAD where it was, and a + later run used to print `on ` over a leg still detached. A + post-checkout hook's exit status is the checkout's own, so a hook that + fails after HEAD has moved makes git exit non-zero over a checkout that + happened, which is not a refusal. + """ + why = _git_says(["checkout", "-q", branch], sub) + if _head_of(sub) == (pin, f"refs/heads/{branch}"): + return True, why + return False, why or [ + "`git checkout` exited 0, and HEAD is not on the branch at the pin"] + + +def _move_and_check_out(sub: Path, branch: str, + pin: str) -> tuple[bool, bool, list[str] | None]: + """From HEAD DETACHED AT THE PIN: `branch -f `, then + `_check_out` of the branch, which is by then the commit already checked + out, so no file in the leg changes, a local edit included. + + Returns whether the branch moved, whether HEAD is on it at the pin, and + git's lines (`None` when both steps exited 0). The checkout runs only + once `branch -f` has worked. A branch that moved is left at the pin when + the checkout is refused, never put back: putting it back is a second + ref write a stale lock refuses as well, and the next run finds the + branch at the pin and says again whether it could check it out. Not + `checkout -B`: git 2.43 resets a branch another worktree has checked + out with it, leaving that worktree's files behind its own HEAD, where + `branch -f` refuses. + """ + why = _git_says(["branch", "-f", branch, pin], sub) + if why is not None: + return False, False, why + placed, why = _check_out(sub, branch, pin) + return True, placed, why + + +def _say_lines(why: list[str]) -> None: + for line in why: + print(f" {spelled(line.strip())}") + + +def _say_refused(first: str, why: list[str]) -> None: + """A move git refused: `first`, git's own lines under it, spelled + (#391), then what to do.""" + print(first) + _say_lines(why) + print(NOTHING_ELSE) + + +def _say_after(why: list[str] | None) -> None: + """What git said when it exited non-zero AFTER HEAD reached the branch + (a failing post-checkout hook): the move happened and its line has been + printed, so git's words follow it as they are, not as a refusal.""" + if why: + print(" checked out, and then git said:") + _say_lines(why) + + +def place_at_pin(sub: Path, label: 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. + + A checkout git refuses is said in git's words, and the leg stays where + it is. It used to be ignored and the `on` line printed anyway, over a + leg left detached (a stale `index.lock`) or on another branch (a local + edit in the way): the next commit made there was on no branch, or on + the wrong one, under a line saying otherwise. + """ + named = spelled(branch) + placed, why = _check_out(sub, branch, pin) + if not placed: + _say_refused(f" {label}: branch {named} is at the pin " + f"{_short(pin)}, and git refused to check it out:", why) + return + print(f" {label}: on {named} at {_short(pin)} (branch tip == pin)") + _say_after(why) + + +def fast_forward_to_pin(sub: Path, label: 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 + the pin loses nothing. It is the state `bump-leg.py` leaves: the leg + DETACHED at the new pin over the branch an earlier bootstrap placed at + the old one. It used to stay detached, and the next commit made in the + leg was on no branch. The branch is moved, and the leg put on it, from + exactly two checkouts: + + - HEAD on the branch itself: `merge --ff-only`, which moves the working + tree too, and moves nothing when a local change to a file the move + changes, or an untracked file, is in the way. `merge.autostash` is + off for it: with it on, git stashes the change, moves the branch, + applies the stash back into conflicts and exits 0, and the leg was + left unmerged under a line saying it had been fast-forwarded. `-c`, + not `--no-autostash`, which a git before 2.27 refuses as an unknown + option; such a git has no autostash and ignores the key. + - HEAD detached AT THE PIN: `_move_and_check_out`. + + ANY OTHER CHECKOUT IS LEFT WHERE IT IS. A commit made on the detached + leg after the bump is on no branch, and checking the branch out would + leave it only in the reflog; a leg on another branch is somebody's work. + Either is said, with what to do, and nothing moves. + + `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 current == f"refs/heads/{branch}": + why = _git_says(["-c", "merge.autostash=false", "merge", "--ff-only", + "-q", pin], sub) + moved = placed = why is None + elif current is None and head == pin: + moved, placed, why = _move_and_check_out(sub, branch, pin) + else: + place = "detached" + if current is not None: + place = "on " + spelled(current.split("refs/heads/", 1)[-1]) + print(f" {label}: branch {named} tip {_short(local)} is behind the " + f"PIN {_short(pin)}, and the checkout is at {_short(head)} " + f"({place}), which is neither") + print(" not moving the checkout off it. Keep any commit made " + "there on a branch, check out the pin or the branch in the " + "leg, and run bootstrap again.") + return + if not placed: + if moved: + first = (f" {label}: branch {named} moved {_short(local)} -> " + f"{_short(pin)}, the pin, and git refused to check it " + "out:") + else: + first = (f" {label}: branch {named} tip {_short(local)} is " + f"behind the PIN {_short(pin)}, and git refused to move " + "it:") + _say_refused(first, why or []) + return + note = "" + if remote is not None and remote != pin: + note = (f"; origin/{named} is {_short(remote)}, which the pin " + "deliberately does NOT follow") + print(f" {label}: branch {named} fast-forwarded {_short(local)} -> " + f"{_short(pin)}, the pin{note}") + _say_after(why) + + +def move_back_to_pin(sub: Path, label: str, path: str, branch: str, + local: str, pin: str, upstream: str) -> None: + """Step (a) for a tracking branch AHEAD of the pin with no commit of + its own (#475). + + `git clone --recurse-submodules` makes each leg's branch at its + remote's tip and leaves HEAD detached at the pin. Where the leg's + remote has moved past the pin, which is the ordinary state of an active + project, the branch is ahead of the pin, and step (a) used to leave the + leg detached, where the next commit made in it is on no branch: the + harm `bump-leg.py` left, met on the first day instead. + + The caller has made sure that moving the branch back loses nothing: its + tip `local` IS the commit of `upstream`, a remote-tracking ref, which + still holds every commit the branch has past the pin; and HEAD is + detached AT the pin, so `_move_and_check_out` changes no file. The one + line printed says where those commits are and names the command that + brings the branch forward, which is the person's to run: what this + project records is the pin. The command's leg path and upstream are + shell-quoted, then spelled, as `scripts/family.py` quotes the commands + it prints. + + "Nothing is lost" holds at the last fetch. A commit the branch held + that the remote has since DROPPED, by a force-push to the leg's shared + branch, is left only in the reflog once the next fetch moves the + remote-tracking ref; nothing in the leg can see that before it fetches. + """ + named = spelled(branch) + short = upstream[len(REMOTE_TRACKING):] + held = spelled(short) + moved, placed, why = _move_and_check_out(sub, branch, pin) + if not placed: + if moved: + first = (f" {label}: branch {named} moved back {_short(local)} " + f"-> {_short(pin)}, the pin ({held} still holds " + f"{_short(local)}), and git refused to check it out:") + else: + first = (f" {label}: branch {named} tip {_short(local)} is " + f"ahead of the PIN {_short(pin)} with no commit {held} " + "lacks, and git refused to move it back:") + _say_refused(first, why or []) + return + print(f" {label}: branch {named} moved back {_short(local)} -> " + f"{_short(pin)}, the pin; nothing is lost, {held} still holds " + f"those commits, and `git -C {spelled(shlex.quote(path))} merge " + f"--ff-only {spelled(shlex.quote(short))}` brings the branch " + "forward") + _say_after(why) + + def checkout_tracking_branch(root: Path, leg: dict, branch: str) -> None: """Step (a) for one leg. Prints; never raises; never moves the pin.""" path = str(leg.get("path") or "") @@ -141,9 +429,16 @@ def checkout_tracking_branch(root: Path, leg: dict, branch: str) -> None: return if local == pin: - _try_git(["checkout", "-q", branch], sub) - print(f" {where}: on {named} at {_short(pin)} " - f"(branch tip == pin)") + place_at_pin(sub, where, branch, pin) + return + + if _is_ancestor(sub, local, pin): + fast_forward_to_pin(sub, where, branch, local, pin, remote) + return + + upstream = _held_upstream_past_the_pin(sub, branch, local, pin) + if upstream is not None: + move_back_to_pin(sub, where, path, branch, local, pin, upstream) return print(f" {where}: PIN {_short(pin)} != branch {named} tip " diff --git a/tests/test_bootstrap_after_bump.py b/tests/test_bootstrap_after_bump.py new file mode 100644 index 00000000..4f3da5cb --- /dev/null +++ b/tests/test_bootstrap_after_bump.py @@ -0,0 +1,719 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`bump-leg.py`, then `make bootstrap`: the leg ends on its tracking branch +AT the new pin (#475). + +THE DEFECT. AGENTS.md's "Advancing a leg", README, the line `bump-leg.py` +prints and every project's `AGENTS-shape.md` say that `make bootstrap` +re-places a bumped leg on its tracking branch at the new pin. On a project +bootstrapped once, which is every project setup.sh makes, it did not: the +leg's `main` was still at the old pin, bootstrap moved no existing branch, +printed `not moving an existing branch`, exited 0 and left the leg DETACHED, +where the next commit made in it is on no branch. `tests/test_bump_leg.py` +never ran bootstrap after a bump, and its clone was never bootstrapped, so +it had no branch to lag. + +THE SAME HARM ON THE FIRST DAY. A fresh `git clone --recurse-submodules` +makes each leg's `main` at its remote's tip and leaves the leg detached at the +pin. Where the leg's remote has moved past the pin, the ordinary state of an +active project, bootstrap met a `main` ahead of the pin and left the leg +detached under the same `not moving an existing branch`, which AGENTS.md's +section 3 says it does not do. + +THE RULE NOW. A tracking branch whose tip is an ancestor of the pin is +fast-forwarded to it from the two checkouts that state comes in: detached at +the pin, which is what `bump-leg.py` leaves, and on the branch itself, where +`merge.autostash` is off for the one merge. A branch AHEAD of the pin with no +commit of its own (its tip IS its upstream's remote-tracking ref) is moved +back to the pin from a leg detached there, and the line names what still +holds those commits and the command that brings the branch forward. Any other +branch holding a commit the pin lacks, ahead or diverged, is still never moved +and keeps its message, byte for byte. A checkout that is neither is not moved +off. A move git refuses is said in git's words, without its blank lines, and +nothing else moves; a branch that moved before git refused the checkout is +said to have moved. A checkout is judged by where HEAD is afterwards, for a +branch already at the pin too: one git refused is never called `on main`, +and a failing post-checkout hook after HEAD moved is not called a refusal. +The command the move-back line prints is shell-quoted. + +EACH TEST GOES THE WAY A PERSON DOES: a scaffold into bare repositories on +disk, cloned recursively and bootstrapped, so each leg is on `main` at its +pin; a new commit pushed to the spec leg's remote; a branch in the root, +`bump-leg.py`, then `bootstrap.py` again. The scaffold is this module's own, +not the session's, because every test pushes a commit to the spec remote, +each to a branch of its own so that no test sees another's. The fresh-clone +tests have a second scaffold of their own, whose spec remote's `main` HAS +moved past the pin, cloned recursively per test and never bootstrapped +before. No bash and no `gh`: every step is git or one of the Python tools, so +the module runs unchanged on Linux, macOS and Windows. +""" + +from __future__ import annotations + +import os +import shlex +import shutil +from pathlib import Path + +import pytest + +from conftest import (FILE_PROTOCOL, ORG, PROJECT, REPO, SCAFFOLD, git, + run_script) + +BUMP = REPO / "scripts" / "bump-leg.py" +MAIN = "refs/heads/main" +NOT_MOVING = "not moving an existing branch" +REFUSED = "and git refused to move it:" +#: The file the new leg commit adds, and so the one an untracked copy of +#: blocks a fast-forward on. +ADDED = "NEW-CONTRACT.md" +#: A file the spec leg tracks, which a new leg commit can change too. +EDITED = "README.md" +#: The file the commit that moves the spec remote's `main` past the pin adds. +MOVED_ON = "MOVED-ON.md" + + +def not_moving(pin: str, tip: str) -> str: + """Step (a)'s refusal of a spec branch holding a commit the pin lacks, + byte for byte as main prints it.""" + return (f" [spec] spec: PIN {pin[:12]} != branch main tip {tip[:12]}\n" + " not moving an existing branch. The checkout stays at " + "the pin; the recorded pin is authoritative and advancing it is " + "an explicit commit in this repository.\n") + + +def moved_back(tip: str, pin: str, branch: str = "main", leg: str = "spec", + command: str | None = None) -> str: + """The one line step (a) prints when it moves the spec leg's branch + back to the pin, `branch` and `leg` as they are spelled, and `command` + as it is quoted when that is not the plain one.""" + command = command or f"git -C {leg} merge --ff-only origin/{branch}" + return (f" [spec] {leg}: branch {branch} moved back {tip[:12]} -> " + f"{pin[:12]}, the pin; nothing is lost, origin/{branch} still " + f"holds those commits, and `{command}` brings the branch forward") + + +def lock_index(leg: Path) -> Path: + """A stale `index.lock` in the leg's gitdir, as a git that crashed + leaves one. Returns it, for the test to remove.""" + gitdir = Path(git("rev-parse", "--absolute-git-dir", + cwd=leg).stdout.strip()) + (gitdir / "index.lock").write_bytes(b"") + return gitdir / "index.lock" + + +def skip_validators(root: Path): + """`bootstrap.py --skip-validators`: what these tests ask is step (a).""" + return run_script(root / "scripts" / "bootstrap.py", "--skip-validators", + cwd=root) + + +def configured(key: str, value: str) -> dict: + """`key` set to `value` for one run, as a person's `~/.gitconfig` + would set it, through git's `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_` + and `GIT_CONFIG_VALUE_` (git 2.31), never `git config`. APPENDED to + what the environment holds: the Windows job sets two entries already + (`.github/workflows/tests.yml`), and a count of 1 would drop both.""" + held = int(os.environ.get("GIT_CONFIG_COUNT") or 0) + return {"GIT_CONFIG_COUNT": str(held + 1), + f"GIT_CONFIG_KEY_{held}": key, + f"GIT_CONFIG_VALUE_{held}": value} + + +def bootstrap(root: Path): + return run_script(root / "scripts" / "bootstrap.py", cwd=root) + + +def head_ref(leg: Path) -> str | None: + """The branch HEAD is on, or None when it is detached.""" + proc = git("symbolic-ref", "--quiet", "HEAD", cwd=leg, check=False) + return proc.stdout.strip() if proc.returncode == 0 else None + + +def rev(repo: Path, name: str = "HEAD") -> str: + return git("rev-parse", "--verify", name, cwd=repo).stdout.strip() + + +@pytest.fixture(scope="module") +def bootstrapped(tmp_path_factory) -> dict: + """One scaffold, cloned recursively and bootstrapped ONCE, so each leg + is on `main` at its pin: the state every later bootstrap meets.""" + base = tmp_path_factory.mktemp("bootstrap-after-bump") + remotes = base / "remotes" + made = run_script(SCAFFOLD, "--org", ORG, "--project", PROJECT, + "--elected-by", "Test Human", "--elected-on", + "2026-09-02", "--local-remote-dir", str(remotes), + "--work-dir", str(base / "work")) + assert made.returncode == 0, made.stderr + made.stdout + clone = base / "clone" / PROJECT + git(*FILE_PROTOCOL, "clone", "-q", "--recurse-submodules", + str(remotes / f"{PROJECT}.git"), str(clone), cwd=base) + first = bootstrap(clone) + assert first.returncode == 0, first.stderr + first.stdout + assert head_ref(clone / "spec") == MAIN, first.stdout + return {"remotes": remotes, "clone": clone} + + +@pytest.fixture +def root(bootstrapped, tmp_path) -> Path: + """A private copy of the bootstrapped clone. A leg's `.git` file and + its config name `.git/modules/` relatively, so the copy is whole.""" + target = tmp_path / PROJECT + shutil.copytree(bootstrapped["clone"], target, symlinks=True) + return target + + +def bumped(root: Path, remotes: Path, tmp_path: Path, branch: str, + touch: str | None = None) -> str: + """A new commit on the spec leg's REMOTE, then `bump-leg.py` to it from + a branch of the root, exactly as AGENTS.md has it. Returns the commit. + + The commit is pushed to `branch` rather than to `main`, so the remote's + `main` stays where every copy of the clone has it; `bump-leg.py` asks + only that some branch of the remote contains the commit. With `touch`, + the commit also adds a line at the end of that tracked file. + """ + work = tmp_path / "spec-work" + git("clone", "-q", str(remotes / f"{PROJECT}-spec.git"), str(work), + cwd=tmp_path) + (work / ADDED).write_bytes(b"# a contract the leg gained\n") + git("add", "--", ADDED, cwd=work) + if touch is not None: + (work / touch).write_bytes((work / touch).read_bytes() + + b"\nupstream line\n") + git("add", "--", touch, cwd=work) + git("commit", "-q", "-m", "Advance the spec leg", cwd=work) + git("push", "-q", "origin", f"HEAD:refs/heads/{branch}", cwd=work) + new = rev(work) + git("checkout", "-q", "-b", "bump/spec", cwd=root) + result = run_script(BUMP, "--root", str(root), "--leg", "spec", "--to", + new, "--local-remote-dir", str(remotes)) + assert result.returncode == 0, result.stderr + result.stdout + assert head_ref(root / "spec") is None, "bump-leg.py leaves it detached" + assert rev(root / "spec") == new + return new + + +# --- behind the pin: fast-forwarded ----------------------------------------- + +def test_bootstrap_after_a_bump_puts_the_leg_on_its_branch_at_the_new_pin( + root, bootstrapped, tmp_path): + """THE ISSUE'S OWN REPRODUCTION: bootstrapped, bumped, bootstrapped.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-detached") + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert (f"[spec] spec: branch main fast-forwarded {old[:12]} -> " + f"{new[:12]}, the pin") in result.stdout, result.stdout + assert NOT_MOVING not in result.stdout + assert head_ref(leg) == MAIN, "the leg is on its tracking branch" + assert rev(leg, MAIN) == new, "AT the new pin" + assert rev(root, "HEAD:spec") == new, "and the pin is not moved" + assert git("status", "--porcelain", cwd=root).stdout == "", ( + "the leg's checkout is the commit the root records") + + +def test_a_leg_on_its_branch_behind_the_pin_is_fast_forwarded_with_its_files( + root, bootstrapped, tmp_path): + """The other checkout a behind branch comes in: HEAD on the branch at + the old pin. The working tree moves with it.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-on-main") + git("checkout", "-q", "main", cwd=leg) + assert not (leg / ADDED).exists() + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert f"fast-forwarded {old[:12]} -> {new[:12]}" in result.stdout, ( + result.stdout) + assert head_ref(leg) == MAIN + assert rev(leg, MAIN) == new + assert (leg / ADDED).is_file(), "the pin's files are checked out" + assert git("status", "--porcelain", cwd=leg).stdout == "" + + +# --- holding a commit the pin lacks: never moved ---------------------------- + +def test_a_branch_ahead_of_the_pin_is_not_moved(root): + leg = root / "spec" + pin = rev(root, "HEAD:spec") + git("commit", "-q", "--allow-empty", "-m", "local work", cwd=leg) + ahead = rev(leg) + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert f"PIN {pin[:12]} != branch main tip {ahead[:12]}" in result.stdout + assert NOT_MOVING in result.stdout + assert "fast-forwarded" not in result.stdout + assert rev(leg, MAIN) == ahead + assert head_ref(leg) == MAIN + + +def test_a_branch_that_diverged_from_the_pin_is_not_moved( + root, bootstrapped, tmp_path): + """A local commit on `main` that the new pin does not contain: moving + `main` to the pin would drop it.""" + leg = root / "spec" + pin = rev(root, "HEAD:spec") + git("commit", "-q", "--allow-empty", "-m", "local work", cwd=leg) + mine = rev(leg) + # Back at the gitlink, as `git submodule update` leaves it, so the root + # is clean for the bump; `main` keeps the local commit. + git("checkout", "-q", "--detach", pin, cwd=leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-diverged") + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert f"PIN {new[:12]} != branch main tip {mine[:12]}" in result.stdout + assert NOT_MOVING in result.stdout + assert rev(leg, MAIN) == mine, "the local commit is still on main" + assert head_ref(leg) is None and rev(leg) == new + + +# --- a checkout that is neither, and a move git refuses --------------------- + +def test_a_commit_made_on_the_detached_leg_is_not_left_behind( + root, bootstrapped, tmp_path): + """Bumped, then a commit made in the leg before anybody bootstrapped: + it is on no branch, and checking `main` out would leave it only in the + reflog. Nothing moves.""" + leg = root / "spec" + old = rev(leg) + bumped(root, bootstrapped["remotes"], tmp_path, "advance-then-commit") + git("commit", "-q", "--allow-empty", "-m", "made on the detached leg", + cwd=leg) + made = rev(leg) + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert (f"the checkout is at {made[:12]} (detached), which is neither" + in result.stdout), result.stdout + assert head_ref(leg) is None and rev(leg) == made + assert rev(leg, MAIN) == old + + +def test_a_fast_forward_an_untracked_file_blocks_moves_nothing( + root, bootstrapped, tmp_path): + leg = root / "spec" + old = rev(leg) + bumped(root, bootstrapped["remotes"], tmp_path, "advance-blocked") + git("checkout", "-q", "main", cwd=leg) + (leg / ADDED).write_bytes(b"mine\n") + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert REFUSED in result.stdout, result.stdout + assert ADDED in result.stdout, "git's own words name the file in the way" + assert head_ref(leg) == MAIN and rev(leg, MAIN) == old + assert (leg / ADDED).read_bytes() == b"mine\n" + + +@pytest.mark.parametrize("autostash", [False, True], + ids=["plain", "merge.autostash"]) +def test_a_local_edit_the_pin_changes_too_is_refused_and_nothing_moves( + root, bootstrapped, tmp_path, autostash): + """HEAD on `main` behind the pin, with a local edit to a tracked file + the commits up to the pin change as well. Plain, `merge --ff-only` + refuses. With `merge.autostash=true` in the person's config, git + stashed the edit, fast-forwarded, applied the stash back into + conflicts and exited 0, and bootstrap said `fast-forwarded` over an + unmerged leg. Off for the one merge, it refuses the same way.""" + leg = root / "spec" + old = rev(leg) + bumped(root, bootstrapped["remotes"], tmp_path, + f"advance-edit-{'autostash' if autostash else 'plain'}", + touch=EDITED) + git("checkout", "-q", "main", cwd=leg) + edited = (leg / EDITED).read_bytes() + b"\nmy local line\n" + (leg / EDITED).write_bytes(edited) + + result = run_script(root / "scripts" / "bootstrap.py", cwd=root, + env=configured("merge.autostash", "true") + if autostash else None) + assert result.returncode == 0, result.stderr + result.stdout + assert REFUSED in result.stdout, result.stdout + assert f" {EDITED}\n" in result.stdout, "git names the file" + assert "fast-forwarded" not in result.stdout + assert head_ref(leg) == MAIN and rev(leg, MAIN) == old + assert git("stash", "list", cwd=leg).stdout == "", "nothing stashed" + assert git("ls-files", "--unmerged", cwd=leg).stdout == "", "no conflict" + assert (leg / EDITED).read_bytes() == edited, "the edit is as it was" + + +def test_a_checkout_refused_after_the_branch_moved_says_it_moved( + root, bootstrapped, tmp_path): + """A stale `index.lock` in the leg, KEPT across two runs. `branch -f` + moves `main`, which touches no index, and the `checkout` dies on the + lock. The line used to say git `refused to move it` over a branch it + had moved, and printed the blank line in git's message as a line of + spaces. The second run finds `main` at the pin and used to print `on + main at`, over a leg still detached; it says git refused the checkout + again. A run once the lock is gone checks the branch out.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-locked") + lock = lock_index(leg) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert (f" [spec] spec: branch main moved {old[:12]} -> {new[:12]}, the " + "pin, and git refused to check it out:\n") in result.stdout, ( + result.stdout) + assert REFUSED not in result.stdout + assert "index.lock" in result.stdout, "git's own words" + assert [line for line in result.stdout.splitlines() + if line and not line.strip()] == [], "no line of spaces" + assert rev(leg, MAIN) == new and head_ref(leg) is None + + second = skip_validators(root) + assert (f" [spec] spec: branch main is at the pin {new[:12]}, and git " + "refused to check it out:\n") in second.stdout, second.stdout + assert "[spec] spec: on main at" not in second.stdout + assert rev(leg, MAIN) == new and head_ref(leg) is None + + lock.unlink() + again = skip_validators(root) + assert f" [spec] spec: on main at {new[:12]} (branch tip == pin)" in ( + again.stdout), again.stdout + assert head_ref(leg) == MAIN + + +def test_a_branch_at_the_pin_git_will_not_check_out_is_not_called_on_it( + root): + """`main` IS the pin and the leg is detached there, with a stale + `index.lock`. The checkout dies on the lock; bootstrap printed `on main + at` all the same, and the leg stayed detached, where the next commit is + on no branch. Now git's refusal is printed and nothing moves.""" + leg = root / "spec" + pin = rev(leg) + git("checkout", "-q", "--detach", pin, cwd=leg) + lock = lock_index(leg) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert (f" [spec] spec: branch main is at the pin {pin[:12]}, and git " + "refused to check it out:\n") in result.stdout, result.stdout + assert "index.lock" in result.stdout, "git's own words" + assert "[spec] spec: on main at" not in result.stdout + assert head_ref(leg) is None and rev(leg) == pin and rev(leg, MAIN) == pin + + lock.unlink() + again = skip_validators(root) + assert f" [spec] spec: on main at {pin[:12]} (branch tip == pin)" in ( + again.stdout), again.stdout + assert head_ref(leg) == MAIN + + +def test_a_branch_another_worktree_has_checked_out_is_not_reset( + root, bootstrapped, tmp_path): + """`main` checked out in a second worktree of the leg. Resetting it + there would leave that worktree's files behind its own HEAD, which + `git checkout -B` does on git 2.43; bootstrap refuses instead.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-worktree") + elsewhere = tmp_path / "elsewhere" + git("worktree", "add", str(elsewhere), "main", cwd=leg) + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert REFUSED in result.stdout, result.stdout + assert rev(leg, MAIN) == old + assert git("status", "--porcelain", cwd=elsewhere).stdout == "", ( + "the other worktree still matches its HEAD") + assert head_ref(leg) is None and rev(leg) == new + + +# --- what it prints of the manifest is spelled (#391) ----------------------- + +def test_the_branch_is_spelled_in_the_lines_that_name_it(root): + """A tracking branch git accepts and a terminal should not be handed + raw (U+2028), in the two lines that name it here: the checkout that is + neither, and the fast-forward. The validators are skipped, as in + `tests/test_bootstrap_spelling.py`: they are not what is asked.""" + manifest = root / "project.yaml" + text = manifest.read_bytes() + assert text.count(b"tracking_branch: main\n") == 1, text + manifest.write_bytes(text.replace(b"tracking_branch: main\n", + b'tracking_branch: "tb\\u2028x"\n')) + named = r"tb\u2028x" + leg = root / "spec" + old = rev(leg) + + def printed() -> str: + result = run_script(root / "scripts" / "bootstrap.py", + "--skip-validators", cwd=root) + assert result.returncode == 0, result.stderr + result.stdout + assert "\u2028" not in result.stdout, result.stdout + return result.stdout + + assert f"branch {named} created at {old[:12]}" in printed() + # The bumped state by hand: a child of the pin, checked out detached and + # staged as the gitlink, which is what the root answers for the pin. + tree = rev(leg, f"{old}^{{tree}}") + new = git("commit-tree", tree, "-p", old, "-m", "advance", + cwd=leg).stdout.strip() + git("checkout", "-q", "--detach", new, cwd=leg) + git("add", "--", "spec", cwd=root) + git("commit", "-q", "--allow-empty", "-m", "made on the detached leg", + cwd=leg) + made = rev(leg) + assert (f" [spec] spec: branch {named} tip {old[:12]} is behind the PIN " + f"{new[:12]}, and the checkout is at {made[:12]} (detached), " + "which is neither") in printed() + + git("checkout", "-q", "--detach", new, cwd=leg) + assert (f" [spec] spec: branch {named} fast-forwarded {old[:12]} -> " + f"{new[:12]}, the pin") in printed() + + +def test_the_branch_and_its_upstream_are_spelled_in_the_move_back(root): + """The line that moves a branch back names the branch, its upstream and + a command with both in it; each is spelled (#391). The state is built + by hand: a commit past the pin, a remote-tracking ref at it, the branch + tracking that ref, and the leg detached at the pin.""" + manifest = root / "project.yaml" + text = manifest.read_bytes() + assert text.count(b"tracking_branch: main\n") == 1, text + manifest.write_bytes(text.replace(b"tracking_branch: main\n", + b'tracking_branch: "tb\\u2028x"\n')) + raw = "tb\u2028x" + leg = root / "spec" + pin = rev(leg) + tree = rev(leg, f"{pin}^{{tree}}") + ahead = git("commit-tree", tree, "-p", pin, "-m", "the remote moved on", + cwd=leg).stdout.strip() + git("update-ref", f"refs/remotes/origin/{raw}", ahead, cwd=leg) + git("branch", "-q", "--track", raw, f"origin/{raw}", cwd=leg) + git("checkout", "-q", "--detach", pin, cwd=leg) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert "\u2028" not in result.stdout, result.stdout + command = r"git -C spec merge --ff-only 'origin/tb\u2028x'" + assert moved_back(ahead, pin, r"tb\u2028x", command=command) in ( + result.stdout), result.stdout + assert rev(leg, f"refs/heads/{raw}") == pin and rev(leg) == pin + assert head_ref(leg) is not None + + +# --- a fresh clone of a leg whose remote moved past the pin ----------------- + +@pytest.fixture(scope="module") +def leg_main_ahead(tmp_path_factory) -> dict: + """A scaffold whose spec remote's `main` is one commit PAST the pin, as + an active project's is. Its own, because it moves the remote's `main`, + which the scaffold above keeps where every copy of its clone has it.""" + base = tmp_path_factory.mktemp("leg-main-ahead") + remotes = base / "remotes" + made = run_script(SCAFFOLD, "--org", ORG, "--project", PROJECT, + "--elected-by", "Test Human", "--elected-on", + "2026-09-02", "--local-remote-dir", str(remotes), + "--work-dir", str(base / "work")) + assert made.returncode == 0, made.stderr + made.stdout + work = base / "spec-work" + git("clone", "-q", str(remotes / f"{PROJECT}-spec.git"), str(work), + cwd=base) + (work / MOVED_ON).write_bytes(b"# the leg's main moved on\n") + git("add", "--", MOVED_ON, cwd=work) + git("commit", "-q", "-m", "The leg moves on past the pin", cwd=work) + git("push", "-q", "origin", "HEAD:refs/heads/main", cwd=work) + return {"remotes": remotes, "ahead": rev(work)} + + +@pytest.fixture +def fresh(leg_main_ahead, tmp_path) -> Path: + """`git clone --recurse-submodules` of the root, as AGENTS.md has a + person run it, never bootstrapped: git made the spec leg's `main` at + the remote's tip and left the leg detached at the pin.""" + clone = tmp_path / PROJECT + git(*FILE_PROTOCOL, "clone", "-q", "--recurse-submodules", + str(leg_main_ahead["remotes"] / f"{PROJECT}.git"), str(clone), + cwd=tmp_path) + leg = clone / "spec" + assert head_ref(leg) is None and rev(leg) == rev(clone, "HEAD:spec") + assert rev(leg, MAIN) == leg_main_ahead["ahead"] + assert rev(leg, "refs/remotes/origin/main") == leg_main_ahead["ahead"] + return clone + + +def test_a_fresh_clone_whose_leg_moved_on_puts_the_leg_on_main_at_the_pin( + fresh, leg_main_ahead): + """THE ONBOARDING FLOW, end to end: clone, bootstrap. The leg ends on + `main` AT the pin, the root is clean, and the command the line names + brings `main` forward to what origin holds.""" + leg = fresh / "spec" + pin = rev(fresh, "HEAD:spec") + ahead = leg_main_ahead["ahead"] + + result = bootstrap(fresh) + assert result.returncode == 0, result.stderr + result.stdout + assert moved_back(ahead, pin) in result.stdout, result.stdout + assert NOT_MOVING not in result.stdout + assert head_ref(leg) == MAIN, "the leg is on its tracking branch" + assert rev(leg, MAIN) == pin, "AT the pin" + assert rev(fresh, "HEAD:spec") == pin, "and the pin is not moved" + assert git("status", "--porcelain", cwd=fresh).stdout == "", ( + "the root is clean") + assert git("status", "--porcelain", cwd=leg).stdout == "" + assert rev(leg, "refs/remotes/origin/main") == ahead, "origin holds it" + assert git("rev-parse", "--symbolic-full-name", "main@{upstream}", + cwd=leg).stdout.strip() == "refs/remotes/origin/main" + + git("-C", "spec", "merge", "--ff-only", "-q", "origin/main", cwd=fresh) + assert head_ref(leg) == MAIN and rev(leg, MAIN) == ahead + + +def test_a_local_edit_in_a_fresh_leg_is_kept_when_main_moves_back( + fresh, leg_main_ahead): + """HEAD is detached AT the pin, so checking `main` out at the pin + changes no file: a local edit is still there, uncommitted.""" + leg = fresh / "spec" + pin = rev(leg) + edited = (leg / EDITED).read_bytes() + b"\nmy local line\n" + (leg / EDITED).write_bytes(edited) + + result = bootstrap(fresh) + assert moved_back(leg_main_ahead["ahead"], pin) in result.stdout, ( + result.stdout) + assert head_ref(leg) == MAIN and rev(leg, MAIN) == pin + assert (leg / EDITED).read_bytes() == edited + assert git("status", "--porcelain", cwd=leg).stdout == f" M {EDITED}\n" + + +def test_a_fresh_leg_main_with_a_commit_of_its_own_is_not_moved( + fresh, leg_main_ahead): + """`main` checked out, a commit made, and the leg put back at the pin + as `git submodule update` does: `main` holds a commit origin does not, + so moving it would lose that commit. The refusal is main's, byte for + byte.""" + leg = fresh / "spec" + pin = rev(leg) + git("checkout", "-q", "main", cwd=leg) + git("commit", "-q", "--allow-empty", "-m", "mine", cwd=leg) + mine = rev(leg) + git("checkout", "-q", "--detach", pin, cwd=leg) + + result = bootstrap(fresh) + assert result.returncode == 0, result.stderr + result.stdout + assert not_moving(pin, mine) in result.stdout, result.stdout + assert "moved back" not in result.stdout + assert rev(leg, MAIN) == mine, "the commit is still on main" + assert head_ref(leg) is None and rev(leg) == pin + + +def without_upstream(leg: Path) -> None: + git("branch", "-q", "--unset-upstream", "main", cwd=leg) + + +def upstream_ref_missing(leg: Path) -> None: + git("update-ref", "-d", "refs/remotes/origin/main", cwd=leg) + + +def upstream_local(leg: Path) -> None: + """The upstream a LOCAL branch at the tip (`branch.main.remote` of + `.`): no remote holds anything on its behalf.""" + git("branch", "-q", "other", "main", cwd=leg) + git("branch", "-q", "--set-upstream-to=other", "main", cwd=leg) + + +def on_main(leg: Path) -> None: + git("checkout", "-q", "main", cwd=leg) + + +def pinned_beside_main(leg: Path) -> None: + """A pin `main` does not contain: a commit beside it, checked out and + staged as the gitlink, as a bump to it would record it.""" + pin = rev(leg) + beside = git("commit-tree", rev(leg, f"{pin}^{{tree}}"), "-p", pin, + "-m", "beside main", cwd=leg).stdout.strip() + git("checkout", "-q", "--detach", beside, cwd=leg) + git("add", "--", leg.name, cwd=leg.parent) + + +@pytest.mark.parametrize("make", [without_upstream, upstream_ref_missing, + upstream_local, on_main, + pinned_beside_main], + ids=["no upstream", "upstream ref missing", + "upstream a local branch", "HEAD on main", + "diverged from the pin"]) +def test_a_fresh_leg_main_nothing_vouches_for_is_not_moved( + fresh, leg_main_ahead, make): + """Ahead of the pin and moved by nothing: with no upstream, one whose + remote-tracking ref is not there, or a local branch as the upstream, + nothing says the commits past the pin are held on a remote; with HEAD + on `main`, the move would change the files checked out; and a `main` + that does not contain the pin has diverged from it rather than moved + past it. Each refusal is main's, byte for byte.""" + leg = fresh / "spec" + ahead = leg_main_ahead["ahead"] + make(leg) + pin = rev(fresh, ":spec") + head, where = rev(leg), head_ref(leg) + + result = run_script(fresh / "scripts" / "bootstrap.py", + "--skip-validators", cwd=fresh) + assert result.returncode == 0, result.stderr + result.stdout + assert not_moving(pin, ahead) in result.stdout, result.stdout + assert "moved back" not in result.stdout + assert rev(leg, MAIN) == ahead + assert rev(leg) == head and head_ref(leg) == where + + +def test_a_failing_post_checkout_hook_is_not_called_a_refused_checkout( + fresh, leg_main_ahead, tmp_path): + """A post-checkout hook's exit status is the checkout's own, so a hook + that fails after HEAD has moved makes `git checkout` exit non-zero over + a checkout that happened. The line said git refused it, over a leg on + `main` at the pin. HEAD is what is read now: the move is said, and the + hook's words follow it. The hook comes in through `core.hooksPath` in + the command scope, never `git config`; a shebang script, which Git for + Windows runs through its own `sh`.""" + hooks = tmp_path / "hooks" + hooks.mkdir() + (hooks / "post-checkout").write_bytes( + b"#!/bin/sh\necho 'post-checkout hook failed' >&2\nexit 1\n") + (hooks / "post-checkout").chmod(0o755) + leg = fresh / "spec" + pin = rev(leg) + + result = run_script(fresh / "scripts" / "bootstrap.py", + "--skip-validators", cwd=fresh, + env=configured("core.hooksPath", hooks.as_posix())) + assert result.returncode == 0, result.stderr + result.stdout + assert moved_back(leg_main_ahead["ahead"], pin) + ( + "\n checked out, and then git said:\n" + " post-checkout hook failed\n") in result.stdout, ( + result.stdout) + assert "refused" not in result.stdout + assert head_ref(leg) == MAIN and rev(leg, MAIN) == pin + + +def test_the_command_that_brings_the_branch_forward_is_shell_quoted( + fresh, leg_main_ahead): + """A leg at a path with a space, which only a hand-written manifest + holds (scaffold and adopt refuse one). The printed command quotes it, as + `scripts/family.py` quotes the commands it prints, and split the way a + POSIX shell splits it, it runs and brings `main` forward.""" + git("mv", "spec", "my spec", cwd=fresh) + manifest = fresh / "project.yaml" + text = manifest.read_bytes() + assert text.count(b"path: spec\n") == 1, text + manifest.write_bytes(text.replace(b"path: spec\n", b'path: "my spec"\n')) + leg = fresh / "my spec" + pin = rev(leg) + ahead = leg_main_ahead["ahead"] + + result = skip_validators(fresh) + assert result.returncode == 0, result.stderr + result.stdout + line = moved_back(ahead, pin, leg="my spec", + command="git -C 'my spec' merge --ff-only origin/main") + assert line in result.stdout, result.stdout + assert head_ref(leg) == MAIN and rev(leg, MAIN) == pin + + command = shlex.split(line.split("`")[1]) + assert command[:3] == ["git", "-C", "my spec"], command + git(*command[1:], cwd=fresh) + assert rev(leg, MAIN) == ahead