diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py index 59e1f5f5..ca11d884 100755 --- a/scripts/bump-leg.py +++ b/scripts/bump-leg.py @@ -789,8 +789,9 @@ def leg_placement(submodule: Path) -> str | None: Remembered BEFORE the checkout so a rollback puts the leg back where it stood rather than merely back at the right commit: `bootstrap.py` leaves - each leg ON its tracking branch at the pinned commit, and a rollback that - left it detached would have changed something a refusal promised not to. + each leg ON its tracking branch at the pinned commit where that loses + no commit, and a rollback that left it detached would have changed + something a refusal promised not to. """ proc = subprocess.run(["git", "symbolic-ref", "--quiet", "HEAD"], cwd=str(submodule), capture_output=True, text=True, diff --git a/templates/assembly-root/AGENTS-shape.md b/templates/assembly-root/AGENTS-shape.md index e4192140..4d8ce5e9 100644 --- a/templates/assembly-root/AGENTS-shape.md +++ b/templates/assembly-root/AGENTS-shape.md @@ -93,7 +93,7 @@ make resume recreate the parked features here, from that record ``` `make bootstrap` places each leg on its tracking branch AT the pin where that -loses nothing, and leaves one detached at any other commit, saying why. The +loses no commit, and leaves one detached at any other commit, saying why. The line `authority is not wallet-carried in this org` is a report, not a fault. **`make park` BEFORE the human leaves a workstation; on the next one, clone, diff --git a/templates/assembly-root/Makefile b/templates/assembly-root/Makefile index bc8284f0..b9fac472 100644 --- a/templates/assembly-root/Makefile +++ b/templates/assembly-root/Makefile @@ -31,8 +31,9 @@ OVERLAY_MISSING = needs the Speckit worktree overlay; install it with: \ .PHONY: help bootstrap validate pins park resume help: - @echo 'make bootstrap legs onto tracking branches at their pins, then' - @echo ' the validators, then the review-authority readout' + @echo 'make bootstrap legs onto tracking branches at their pins where that' + @echo ' loses no commit, or says why not; then the validators,' + @echo ' then the review-authority readout' @echo 'make validate naming + manifest + lockstep pins (what CI runs)' @echo 'make pins the lockstep pin validator alone' @echo 'make park commit, push and RECORD every open feature, so' diff --git a/templates/assembly-root/README.md b/templates/assembly-root/README.md index d9c657c1..1ae66d50 100644 --- a/templates/assembly-root/README.md +++ b/templates/assembly-root/README.md @@ -18,10 +18,10 @@ make bootstrap ``` `make bootstrap` puts each leg on the `{{TRACKING_BRANCH}}` branch **at its -pinned commit** (so you are not staring at a detached HEAD), runs the three -neutral validators, and prints whatever review authority a wallet register -names for this project — or says plainly that authority is not wallet-carried -here, and continues. +pinned commit** (so you are not staring at a detached HEAD) where that loses +no commit, and says why where it does not, runs the three neutral validators, +and prints whatever review authority a wallet register names for this project +— or says plainly that authority is not wallet-carried here, and continues. ### Parking work for another workstation diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index c2f83263..603d45aa 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -214,19 +214,28 @@ 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: `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. + """From HEAD DETACHED AT THE PIN, or on ANOTHER BRANCH (#603, D1): + `branch -f `, then `_check_out` of the branch. + + 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 + the checkout changes files, as `place_at_pin`'s does, and git refuses + it where a local change to a file that differs, or an untracked file, is + in the way: the branch has moved by then, and HEAD has not. It does not + refuse an IGNORED file at a path the pin tracks: git overwrites that, + as it does for `place_at_pin` and the on-branch `merge --ff-only`, and + it ends a merge in progress on the branch HEAD leaves (#458's to rule + on, #603). 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. + branch at the pin, which is `place_at_pin`'s state, 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: @@ -276,6 +285,22 @@ def _place_of(current: str | None) -> str: return "on " + spelled(current.split("refs/heads/", 1)[-1]) +def _say_left(head: str | None, current: str, named: str) -> None: + """The line under `fast-forwarded` when the leg started on ANOTHER + branch, `current`, and is on the tracking branch `named` now (#603). + + The person is told, since their next commit lands on `named`. P9 + (`place_at_pin`) says nothing of the branch it leaves; that is #458's. + `head` is `None` for a branch with no commit yet, which has no commit to + keep, so it is said so. Its own function, so that `fast_forward_to_pin` + stays within Sonar's cognitive-complexity limit (S3776). + """ + kept = ("a branch with no commit yet" if head is None + else "which keeps its commits") + print(f" the leg was {_place_of(current)}, {kept}; it is on " + f"{named} now") + + def _say_keep(options: str = "the pin or the branch") -> None: """What to do about a checkout step (a) will not move, in the words both refusals of it share. Nothing moves.""" @@ -289,8 +314,9 @@ def _say_neither(label: str, state: str, head: str | None, """The checkout is not one a move starts from: `state` says what the branch is to the pin, and the line names where the leg stands instead. Nothing moves. A commit made on a leg detached off the pin is on no - branch, so checking the branch out would leave it only in the reflog, - and a leg on another branch is somebody's work.""" + branch, so checking the branch out would leave it only in the reflog. + A leg on another branch is not said here (#603): that branch holds its + commits, so the leg is moved off it.""" print(f" {label}: {state}, and the checkout is at {_short(head)} " f"({_place_of(current)}), which is neither") _say_keep() @@ -339,7 +365,7 @@ def fast_forward_to_pin(sub: Path, label: str, branch: str, local: str, 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: + exactly three 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 @@ -350,22 +376,43 @@ def fast_forward_to_pin(sub: Path, label: str, branch: str, local: str, 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. + - 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 + checkout changes files from here, so git can refuse it over a local + edit or an untracked file, and `_move_and_check_out` says what is + left then. A HEAD on a branch with no commit yet takes this path too, + as it does in `place_at_pin`. When the checkout is made, one line + under the `fast-forwarded` one says the leg left that branch, and + that it keeps its commits, since the next commit made in the leg is + on the tracking branch now. NO COMMIT IS LOST, BUT TWO THINGS ARE NOT + REFUSED, as in `place_at_pin`, in the missing-branch path and, for + the first, in the on-branch `merge --ff-only`: git overwrites an + IGNORED file at a path the pin tracks, and a merge in progress on the + other branch is ended, its `MERGE_HEAD` removed and its staged result + carried onto the tracking branch. Both are #458's to rule on. + + A LEG DETACHED OFF THE PIN 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. That 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) + other_branch = (current is not None and current.startswith("refs/heads/") + and current != f"refs/heads/{branch}") 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: + elif other_branch or (current is None and head == pin): moved, placed, why = _move_and_check_out(sub, branch, pin) else: _say_neither(label, f"branch {named} tip {_short(local)} is behind " @@ -388,6 +435,8 @@ def fast_forward_to_pin(sub: Path, label: str, branch: str, local: str, "deliberately does NOT follow") print(f" {label}: branch {named} fast-forwarded {_short(local)} -> " f"{_short(pin)}, the pin{note}") + if other_branch: + _say_left(head, current, named) _say_after(why) diff --git a/templates/family-root/AGENTS-shape.md b/templates/family-root/AGENTS-shape.md index d4343818..f99a90d6 100644 --- a/templates/family-root/AGENTS-shape.md +++ b/templates/family-root/AGENTS-shape.md @@ -61,8 +61,8 @@ make resume the same, with `make resume` ``` `make bootstrap` puts each member's legs on their tracking branch AT their -pinned commits, by running the member's own bootstrap rather than -reimplementing it. +pinned commits where that loses no commit, and says why where it does not, by +running the member's own bootstrap rather than reimplementing it. `make park` and `make resume` carry the ESTATE'S IN-FLIGHT WORK to another workstation, one member at a time and through each member's own verb — the diff --git a/templates/family-root/README.md b/templates/family-root/README.md index 08eda1b8..9242b494 100644 --- a/templates/family-root/README.md +++ b/templates/family-root/README.md @@ -11,10 +11,10 @@ cd {{FAMILY_NAME}} make bootstrap ``` -`make bootstrap` initialises every member recursively — each member is itself -a superproject with two legs — and then runs each member's own -`make bootstrap`, which puts its legs on their tracking branches at their -pinned commits and runs its validators. +`make bootstrap` initialises every member recursively — each member is itself a +superproject with two legs — and then runs each member's own `make bootstrap`, +which puts its legs on their tracking branches at their pinned commits where +that loses no commit, says why where it does not, and runs its validators. | target | what it does | |---|---| diff --git a/tests/test_bootstrap_after_bump.py b/tests/test_bootstrap_after_bump.py index 0719771a..0acbdb59 100644 --- a/tests/test_bootstrap_after_bump.py +++ b/tests/test_bootstrap_after_bump.py @@ -49,6 +49,25 @@ which bootstrap once placed. `not moving an existing branch` names where the checkout stays, which is not always the pin: HEAD may be on the branch itself. +A LEG ON ANOTHER BRANCH (#603, D1). A tracking branch BEHIND the pin is +fast-forwarded from a third checkout too: HEAD on another branch. It was left +there under `which is neither`, while a tracking branch AT the pin was checked +out from the same checkout, and the difference was left to #458 to rule on. The +other branch keeps its commits, so the move loses no commit: `branch -f` moves +the tracking branch to the pin and the checkout puts the leg on it, as from the +pin; the other branch is not touched. One line under the `fast-forwarded` one +says the leg left that branch and that it keeps its commits (or has none yet), +since the next commit made in the leg is on the tracking branch now; no other +start prints it, and nor does a refusal. From another branch the checkout +changes files, so git can refuse it over a local edit or an untracked file, +which a checkout from the pin never meets: the branch is then left at the pin +and HEAD where it was, and the next run meets the branch-at-the-pin state. Two +things are NOT refused, as in the branch-at-the-pin and missing-branch cases +(#458's to rule on): an IGNORED file at a path the pin tracks is overwritten, +and a merge in progress on the other branch is ended, its `MERGE_HEAD` removed +and its staged result carried onto `main`. A leg detached at a commit that is +not the pin is still left, whatever it holds. + 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, @@ -247,6 +266,7 @@ def test_bootstrap_after_a_bump_puts_the_leg_on_its_branch_at_the_new_pin( 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 "the leg was " not in result.stdout, "detached at the pin" 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" @@ -268,6 +288,7 @@ def test_a_leg_on_its_branch_behind_the_pin_is_fast_forwarded_with_its_files( assert result.returncode == 0, result.stderr + result.stdout assert f"fast-forwarded {old[:12]} -> {new[:12]}" in result.stdout, ( result.stdout) + assert "the leg was " not in result.stdout, "HEAD was on `main`" assert head_ref(leg) == MAIN assert rev(leg, MAIN) == new assert (leg / ADDED).is_file(), "the pin's files are checked out" @@ -858,8 +879,10 @@ def test_a_leg_on_another_branch_with_its_own_branch_at_the_pin_is_as_it_was( root, bootstrapped, tmp_path): """HEAD on ANOTHER branch is left as it always was: that branch holds its commits, so checking `main` out strands nothing, and the checkout - is made. `fast_forward_to_pin` refuses the same state for a branch - BEHIND the pin; that difference is #458's to rule on, not this one's.""" + is made. `fast_forward_to_pin` did not do the same for a branch BEHIND + the pin until #603 (D1, option A of its checklist triage) made the two + one rule: the T7 tests at the end of this module are this test's twins + for a branch behind the pin.""" leg, new = branch_at_the_new_pin(root, bootstrapped["remotes"], tmp_path, "advance-other-branch") git("checkout", "-q", "-b", "elsewhere", cwd=leg) @@ -1099,3 +1122,301 @@ def test_the_bump_prints_that_bootstrap_places_the_leg_or_says_why_not( "root puts it on its tracking branch AT the new pin, or says " "why not\n") in result.stdout, result.stdout assert "re-places" not in result.stdout + + +# --- T7: behind the pin, HEAD on another branch is placed as P9 is (#603) --- + +#: A file made on `elsewhere`, which the checkout of `main` takes out again. +MINE = "ELSEWHERE.md" +#: What the line a leg's move off another branch adds starts with (#603). +LEFT = "the leg was " +#: 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.\n") + + +def fast_forwarded(old: str, new: str) -> str: + """The line step (a) prints when the spec leg's `main` is fast-forwarded, + byte for byte as main prints it. `origin/main` is where the scaffold left + it, at `old`: `advance` pushes every new commit to a branch of its own.""" + return (f" [spec] spec: branch main fast-forwarded {old[:12]} -> " + f"{new[:12]}, the pin; origin/main is {old[:12]}, which the pin " + "deliberately does NOT follow\n") + + +def left_branch(other: str = "elsewhere", orphan: bool = False) -> str: + """The line the T7 path prints under the `fast-forwarded` one when HEAD + started on another branch, byte for byte as main prints it (#603): the + leg left `other`, which keeps its commits (or has none yet, `orphan`), + and is on `main` now. Nothing else prints it.""" + kept = ("a branch with no commit yet" if orphan + else "which keeps its commits") + return f" the leg was on {other}, {kept}; it is on main now\n" + + +def moved_and_refused(old: str, new: str) -> str: + """The first line of what step (a) prints when `main` moved to the pin + and git then refused to check it out.""" + return (f" [spec] spec: branch main moved {old[:12]} -> {new[:12]}, the " + "pin, and git refused to check it out:\n") + + +def leg_files(leg: Path) -> dict[str, tuple[bytes, int]]: + """Every file the leg's working tree holds but `.git`, with its bytes and + its mtime, so a checkout that rewrote one shows.""" + return {path.relative_to(leg).as_posix(): + (path.read_bytes(), path.stat().st_mtime_ns) + for path in sorted(leg.rglob("*")) + if path.is_file() and ".git" not in path.relative_to(leg).parts} + + +def test_a_leg_on_another_branch_behind_the_pin_is_put_on_main_at_the_pin( + root, bootstrapped, tmp_path): + """T7, THE STATE THE BUMP LEAVES PLUS WORK ON ANOTHER BRANCH: the leg + detached at the new pin over `main` at the old one, then `git checkout -b + elsewhere` and a commit there. Bootstrap printed `which is neither` and + moved nothing, while a `main` AT the pin was checked out from the same + checkout (#603, D1). `elsewhere` holds its commit, so the move loses + nothing: `main` goes to the pin, the leg goes onto it, `elsewhere` is + not touched. Its twin detached off the pin keeps `which is neither`: + `test_a_commit_made_on_the_detached_leg_is_not_left_behind`.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-t7") + git("checkout", "-q", "-b", "elsewhere", cwd=leg) + (leg / MINE).write_bytes(b"work on elsewhere\n") + git("add", "--", MINE, cwd=leg) + git("commit", "-q", "-m", "work on elsewhere", cwd=leg) + mine = rev(leg) + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert fast_forwarded(old, new) + left_branch() in result.stdout, ( + result.stdout) + assert "neither" not in result.stdout + assert NOT_MOVING not in result.stdout + assert head_ref(leg) == MAIN and rev(leg) == new == rev(leg, MAIN) + assert rev(leg, "refs/heads/elsewhere") == mine, "`elsewhere` is untouched" + assert git("rev-list", "--count", f"{new}..{mine}", + cwd=leg).stdout.strip() == "1", "and holds its commit" + assert not (leg / MINE).exists(), "the checkout took its file out" + assert (leg / ADDED).is_file(), "and put the pin's files in" + assert git("status", "--porcelain", cwd=leg).stdout == "" + assert rev(root, "HEAD:spec") == new, "the pin is not moved" + + +def test_a_leg_on_another_branch_at_the_pin_is_put_on_main_and_changes_no_file( + root, bootstrapped, tmp_path): + """T7 with `elsewhere` EXACTLY AT THE PIN, no commit of its own: the same + result, and as the checkout from `elsewhere` to `main` is between two + trees that are one, no file in the leg is rewritten.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-t7-at-pin") + git("checkout", "-q", "-b", "elsewhere", cwd=leg) + assert rev(leg) == new == rev(leg, "refs/heads/elsewhere") + before = leg_files(leg) + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert fast_forwarded(old, new) + left_branch() in result.stdout, ( + result.stdout) + assert "neither" not in result.stdout + assert head_ref(leg) == MAIN and rev(leg) == new == rev(leg, MAIN) + assert rev(leg, "refs/heads/elsewhere") == new, "`elsewhere` stays" + assert leg_files(leg) == before, "no file in the leg changes" + assert git("status", "--porcelain", cwd=leg).stdout == "" + + +def test_the_branch_a_leg_is_on_is_not_the_one_that_moves_to_the_pin( + root, bootstrapped, tmp_path): + """T7 from a branch that is ITSELF behind the pin: `elsewhere` made at + the old pin, with `main`, and HEAD on it. The branch fast-forwarded is + the TRACKING branch, which is a `branch -f`, and never the one HEAD is on, + which a `merge --ff-only` from there would move: `elsewhere` stays at the + old pin, as a person left it.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-t7-behind") + git("checkout", "-q", "-b", "elsewhere", "main", cwd=leg) + assert rev(leg) == old and not (leg / ADDED).exists() + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert rev(leg, "refs/heads/elsewhere") == old, "`elsewhere` is not moved" + assert fast_forwarded(old, new) + left_branch() in result.stdout, ( + result.stdout) + assert head_ref(leg) == MAIN and rev(leg) == new == rev(leg, MAIN) + assert (leg / ADDED).is_file(), "the pin's files are checked out" + assert git("status", "--porcelain", cwd=leg).stdout == "" + + +def test_a_checkout_off_another_branch_git_refuses_leaves_main_at_the_pin( + root, bootstrapped, tmp_path): + """T7, REFUSED AFTER `branch -f` WORKED. From the pin the checkout of + `main` changes no file, so a local edit never stops it; from `elsewhere` + it changes the files the two differ in, and a local edit to one of them + stops it. `elsewhere` is at the old pin here, so the edit is to a file + the pin changes. `main` is left at the pin and never put back, HEAD stays + on `elsewhere` with the edit intact, and the next run meets the branch AT + the pin (`place_at_pin`), which refuses the same way until the edit is + gone.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-t7-refused", + touch=EDITED) + git("checkout", "-q", "-b", "elsewhere", "main", cwd=leg) + edited = (leg / EDITED).read_bytes() + b"\nmy local line\n" + (leg / EDITED).write_bytes(edited) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert moved_and_refused(old, new) in result.stdout, result.stdout + assert f" {EDITED}\n" in result.stdout, "git names the file" + assert NOTHING_ELSE in result.stdout + assert "fast-forwarded" not in result.stdout + assert "neither" not in result.stdout + assert LEFT not in result.stdout, "HEAD did not leave `elsewhere`" + assert head_ref(leg) == "refs/heads/elsewhere" and rev(leg) == old + assert rev(leg, MAIN) == new, "`main` stays at the pin" + assert (leg / EDITED).read_bytes() == edited, "the edit is intact" + assert git("status", "--porcelain", cwd=leg).stdout == f" M {EDITED}\n" + + 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 head_ref(leg) == "refs/heads/elsewhere" and rev(leg, MAIN) == new + assert LEFT not in second.stdout + + git("checkout", "-q", "--", EDITED, cwd=leg) + again = skip_validators(root) + assert f" [spec] spec: on main at {new[:12]} (branch tip == pin)\n" in ( + again.stdout), again.stdout + assert LEFT not in again.stdout, "P9 says nothing of the branch it left" + assert head_ref(leg) == MAIN and rev(leg) == new + + +def test_a_branch_another_worktree_has_checked_out_is_not_moved_from_elsewhere( + root, bootstrapped, tmp_path): + """T7 with `branch -f` REFUSED: `main` is checked out in a second + worktree of the leg, and git will not move a branch another worktree + holds. Nothing moves, HEAD stays on `elsewhere`, the other worktree still + matches its HEAD, and the line is the one `main` has always printed.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-t7-wt") + git("checkout", "-q", "-b", "elsewhere", cwd=leg) + held = tmp_path / "held" + git("worktree", "add", str(held), "main", cwd=leg) + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert (f" [spec] spec: branch main tip {old[:12]} is behind the PIN " + f"{new[:12]}, and git refused to move it:\n") in result.stdout, ( + result.stdout) + assert NOTHING_ELSE in result.stdout + assert "neither" not in result.stdout + assert LEFT not in result.stdout, "nothing moved" + assert rev(leg, MAIN) == old + assert head_ref(leg) == "refs/heads/elsewhere" and rev(leg) == new + assert head_ref(held) == MAIN and rev(held) == old + assert git("status", "--porcelain", cwd=held).stdout == "" + + +@pytest.mark.parametrize( + "where, blocked", + [("behind", False), ("at", False), ("behind", True), ("at", True)], + ids=["behind, free", "at the pin, free", "behind, a file in the way", + "at the pin, a file in the way"]) +def test_a_leg_on_a_branch_with_no_commit_yet_is_placed_as_on_any_branch( + root, bootstrapped, tmp_path, where, blocked): + """HEAD on a branch with NO COMMIT YET (`git switch --orphan`): there is + no commit to keep, and `place_at_pin` checks `main` out from it, so a + `main` behind the pin does the same (#603). The four lines side by side: + free, `main` goes to the pin and the leg onto it, T7 says the leg left a + branch with no commit yet and P9 says nothing of it; with an untracked + file in the way, git refuses the checkout, HEAD stays on the unborn + branch, `main` is at the pin and nothing is said of the leg leaving.""" + old = rev(root / "spec") + advanced = f"advance-orphan-{where}-{'blocked' if blocked else 'free'}" + if where == "behind": + leg = root / "spec" + new = bumped(root, bootstrapped["remotes"], tmp_path, advanced) + else: + leg, new = branch_at_the_new_pin(root, bootstrapped["remotes"], + tmp_path, advanced) + git("switch", "-q", "--orphan", "orphan", cwd=leg) + assert head_ref(leg) == "refs/heads/orphan" + assert git("rev-parse", "--verify", "--quiet", "HEAD", cwd=leg, + check=False).returncode != 0, "no commit yet" + if blocked: + (leg / ADDED).write_bytes(b"mine\n") + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert "neither" not in result.stdout + assert rev(leg, MAIN) == new + if blocked: + assert ((moved_and_refused(old, new) if where == "behind" else + f" [spec] spec: branch main is at the pin {new[:12]}, and " + "git refused to check it out:\n") in result.stdout), ( + result.stdout) + assert ADDED in result.stdout, "git's own words name the file" + assert NOTHING_ELSE in result.stdout + assert head_ref(leg) == "refs/heads/orphan" + assert (leg / ADDED).read_bytes() == b"mine\n" + assert LEFT not in result.stdout + else: + assert ((fast_forwarded(old, new) + left_branch("orphan", True) + if where == "behind" else + f" [spec] spec: on main at {new[:12]} (branch tip == pin)\n") + in result.stdout), result.stdout + assert (LEFT in result.stdout) == (where == "behind") + assert head_ref(leg) == MAIN and rev(leg) == new + assert git("status", "--porcelain", cwd=leg).stdout == "" + + +def test_a_failing_hook_after_the_move_off_another_branch_is_said_after_it( + root, bootstrapped, tmp_path): + """T7 with a post-checkout hook that fails after HEAD has moved: `git + checkout` exits non-zero over a checkout that happened, so the move is + said, the line that says the leg left `elsewhere` follows it, and the + hook's words follow that, as `place_at_pin`'s do (#533). Nothing is + called a refusal. Without the `_say_after` of `fast_forward_to_pin`, the + hook's words are not said at all, and no other test sees it.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-t7-hook") + git("checkout", "-q", "-b", "elsewhere", "main", cwd=leg) + assert rev(leg) == old + + result = run_script(root / "scripts" / "bootstrap.py", + "--skip-validators", cwd=root, + env=failing_post_checkout(tmp_path)) + assert result.returncode == 0, result.stderr + result.stdout + assert (fast_forwarded(old, new) + left_branch() + + " 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) == new == rev(leg, MAIN) + assert rev(leg, "refs/heads/elsewhere") == old + + +def test_the_branch_the_leg_left_is_spelled_in_the_line_that_names_it( + root, bootstrapped, tmp_path): + """The line that says the leg left a branch prints the BRANCH'S name, which + is a person's, not the manifest's, and git accepts U+2028 in one (#391): + it is spelled, as the tracking branch's is.""" + leg = root / "spec" + old = rev(leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, "advance-t7-spelled") + git("checkout", "-q", "-b", "el\u2028se", cwd=leg) + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert "\u2028" not in result.stdout, result.stdout + assert fast_forwarded(old, new) + left_branch(r"el\u2028se") in ( + result.stdout), result.stdout + assert head_ref(leg) == MAIN and rev(leg) == new