diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py index 2fd2ee7e..cd9028f2 100755 --- a/scripts/bump-leg.py +++ b/scripts/bump-leg.py @@ -1667,7 +1667,8 @@ def cmd_bump(args) -> int: + body, args.trailer) print(f"\n committed {head[:12]}: " + ", ".join(staged)) print(f" {path}/ is left DETACHED at {commit[:12]}; `make bootstrap` in " - "the root re-places it on its tracking branch AT the new pin") + "the root puts it on its tracking branch AT the new pin, or says " + "why not") print(f"\nNEXT git -C {root} push -u origin {branch}") print(f" then open a pull request against {project.tracking}; never " "push the bump to the default branch directly") diff --git a/templates/assembly-root/AGENTS-shape.md b/templates/assembly-root/AGENTS-shape.md index de01154f..e4192140 100644 --- a/templates/assembly-root/AGENTS-shape.md +++ b/templates/assembly-root/AGENTS-shape.md @@ -83,8 +83,8 @@ script guessing at `../`. ## The five commands ``` -make bootstrap the legs onto their tracking branch AT the pinned commit, - then the validators, then the review-authority readout +make bootstrap puts the legs on their tracking branch AT the pin, or says why + not; then the validators, then the review-authority readout make validate naming + manifest + lockstep pins (what CI runs) make pins the lockstep pin validator alone make park commit, push and RECORD every open feature, so another @@ -92,9 +92,9 @@ make park commit, push and RECORD every open feature, so another make resume recreate the parked features here, from that record ``` -`make bootstrap` places each leg on the tracking branch AT its pinned commit — -not a detached HEAD, and not somebody's newer tip. The line `authority is not -wallet-carried in this org` is a report, not a fault. +`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 +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, `make bootstrap`, then `make resume`** — that order, because `resume` diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index 2913aac8..c2f83263 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -23,11 +23,16 @@ 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. + moved; bootstrap prints both and leaves them alone. A leg DETACHED at a + commit that is not the pin is never moved off it either, whether its + branch is at the pin, behind it, ahead of it or not there at all, and + 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. 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 @@ -243,15 +248,54 @@ def _say_refused(first: str, why: list[str]) -> None: print(NOTHING_ELSE) -def _say_after(why: list[str] | None) -> None: +def _say_after(why: list[str] | None, moved: bool = True) -> 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.""" + printed, so git's words follow it as they are, not as a refusal. + + `moved` is whether the checkout changed where HEAD is. HEAD already on + the branch at the pin, there was nothing to check out, so `checked out, + and then git said:` over a stale `.git/index.lock` was false (#533). What + is true of that state whatever git exited over (a stale lock, a failing + post-checkout hook, an unmerged index) is that there was nothing to check + out, so that is what it says, and never `refused`: a hook failing is not + a refusal. Git's lines follow either way: the same lock blocks the + person's next commit. + """ if why: - print(" checked out, and then git said:") + print(" checked out, and then git said:" if moved else + " nothing to check out, and then git said:") _say_lines(why) +def _place_of(current: str | None) -> str: + """Where HEAD is, for a line that names it: `detached`, or `on ` + with the branch spelled (#391).""" + if current is None: + return "detached" + return "on " + spelled(current.split("refs/heads/", 1)[-1]) + + +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.""" + print(" not moving the checkout off it. Keep any commit made " + f"there on a branch, check out {options} in the leg, and run " + "bootstrap again.") + + +def _say_neither(label: str, state: str, head: str | None, + current: str | None) -> 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.""" + print(f" {label}: {state}, and the checkout is at {_short(head)} " + f"({_place_of(current)}), which is neither") + _say_keep() + + 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. @@ -261,15 +305,29 @@ def place_at_pin(sub: Path, label: str, branch: str, pin: str) -> None: 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. + + HEAD is read first (#533). Detached at a commit that is NOT the pin, the + leg is left there, as `fast_forward_to_pin` leaves it. `bump-leg.py` to + a branch's own tip, then a commit made on the detached leg, then + 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. """ named = spelled(branch) + head, current = _head_of(sub) + if current is None and head != pin: + _say_neither(label, f"branch {named} is at the pin {_short(pin)}", + head, current) + return + already = (head, current) == (pin, f"refs/heads/{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) + _say_after(why, moved=not already) def fast_forward_to_pin(sub: Path, label: str, branch: str, local: str, @@ -310,15 +368,8 @@ def fast_forward_to_pin(sub: Path, label: str, branch: str, local: str, 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.") + _say_neither(label, f"branch {named} tip {_short(local)} is behind " + f"the PIN {_short(pin)}", head, current) return if not placed: if moved: @@ -390,6 +441,21 @@ def move_back_to_pin(sub: Path, label: str, path: str, branch: str, _say_after(why) +def _stays(sub: Path, branch: str, pin: str) -> str: + """Where the checkout stays, for the `not moving an existing branch` + line (#533): `on at its tip` when HEAD is on the branch that + holds the commits the pin lacks, `at the pin` when it is detached there, + and else `at (detached)` or `at (on )`, as + `_say_neither` names the place. The line said `at the pin` whatever the + checkout was, which was false for a HEAD on the branch itself.""" + head, current = _head_of(sub) + if current == f"refs/heads/{branch}": + return f"on {spelled(branch)} at its tip" + if current is None and head == pin: + return "at the pin" + return f"at {_short(head)} ({_place_of(current)})" + + 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 "") @@ -413,6 +479,19 @@ def checkout_tracking_branch(root: Path, leg: dict, branch: str) -> None: if pin is None: print(f" {where}: no gitlink recorded; leaving as is") return + head, current = _head_of(sub) + 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 + # branch that exists would. The branch is not there to check + # out, so the way out names the pin alone. A HEAD git cannot + # read (the leg's gitdir is gone) is no commit to keep: the + # checkout below fails, and says it could not. + print(f" {where}: branch {named} does not exist, and the " + f"checkout is at {_short(head)} (detached), which is not " + f"the pin {_short(pin)}") + _say_keep("the pin") + return if _try_git(["checkout", "-q", "-b", branch, pin], sub) is None: print(f" {where}: could not create branch {branch!r} at " f"the pin {_short(pin)}; leaving detached") @@ -443,9 +522,9 @@ def checkout_tracking_branch(root: Path, leg: dict, branch: str) -> None: print(f" {where}: PIN {_short(pin)} != branch {named} tip " f"{_short(local)}") - print(" 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.") + print(" not moving an existing branch. The checkout stays " + f"{_stays(sub, branch, pin)}; the recorded pin is authoritative " + "and advancing it is an explicit commit in this repository.") def shape_upstream_notice(root: Path) -> None: diff --git a/tests/test_bootstrap_after_bump.py b/tests/test_bootstrap_after_bump.py index ee36854c..0719771a 100644 --- a/tests/test_bootstrap_after_bump.py +++ b/tests/test_bootstrap_after_bump.py @@ -35,6 +35,20 @@ and a failing post-checkout hook after HEAD moved is not called a refusal. The command the move-back line prints is shell-quoted. +THE BRANCH AT THE PIN (#533). `bump-leg.py --to` a branch's own tip leaves +the leg detached AT the branch, and a commit made there is on no branch. +Bootstrap used to check the branch out regardless, printing `on main at +` over a commit held only by HEAD's reflog; a leg detached at a commit +that is not the pin is now left there and said, as the behind branch's is. A +checkout that exits non-zero with HEAD already on the branch at the pin (a +stale lock, a failing hook) had nothing to check out, and is said so rather +than as `checked out, and then git said:`, and never as `refused`. The same +rule covers a tracking branch that is not there: a leg detached off the pin +is not given a branch created at the pin over its commit. A leg detached at an +ancestor of the pin, or at `origin/main` past it, is left where it is too, +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. + 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, @@ -72,13 +86,15 @@ MOVED_ON = "MOVED-ON.md" -def not_moving(pin: str, tip: str) -> str: +def not_moving(pin: str, tip: str, stays: str = "at the pin") -> str: """Step (a)'s refusal of a spec branch holding a commit the pin lacks, - byte for byte as main prints it.""" + byte for byte as main prints it. `stays` is where the checkout is: + `at the pin` (detached there), `on main at its tip`, or `at + (detached)` / `at (on )` (#533).""" 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") + f" not moving an existing branch. The checkout stays " + f"{stays}; 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", @@ -119,6 +135,19 @@ def configured(key: str, value: str) -> dict: f"GIT_CONFIG_VALUE_{held}": value} +def failing_post_checkout(tmp_path: Path) -> dict: + """A post-checkout hook that fails, through `core.hooksPath` in the + command scope, never `git config`; a shebang script, which Git for + Windows runs through its own `sh`. Returns the environment that sets + it (`configured`).""" + 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) + return configured("core.hooksPath", hooks.as_posix()) + + def bootstrap(root: Path): return run_script(root / "scripts" / "bootstrap.py", cwd=root) @@ -161,10 +190,9 @@ def root(bootstrapped, tmp_path) -> Path: 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. +def advance(remotes: Path, tmp_path: Path, branch: str, + touch: str | None = None) -> str: + """A new commit on the spec leg's REMOTE. Returns it. 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 @@ -181,13 +209,27 @@ def bumped(root: Path, remotes: Path, tmp_path: Path, branch: str, 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) + return rev(work) + + +def bump_spec(root: Path, remotes: Path, new: str): + """`bump-leg.py` to `new` from a branch of the root, exactly as + AGENTS.md has it. Returns the run, whose output is what a person reads.""" 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 result + + +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 (`advance`), then `bump-leg.py` + to it (`bump_spec`). Returns the commit.""" + new = advance(remotes, tmp_path, branch, touch) + bump_spec(root, remotes, new) return new @@ -244,6 +286,8 @@ def test_a_branch_ahead_of_the_pin_is_not_moved(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 not_moving(pin, ahead, "on main at its tip") in result.stdout, ( + "HEAD is on `main`, which is where the checkout stays") assert "fast-forwarded" not in result.stdout assert rev(leg, MAIN) == ahead assert head_ref(leg) == MAIN @@ -266,6 +310,7 @@ def test_a_branch_that_diverged_from_the_pin_is_not_moved( 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 not_moving(new, mine) in result.stdout, "detached at the pin" assert rev(leg, MAIN) == mine, "the local commit is still on main" assert head_ref(leg) is None and rev(leg) == new @@ -628,20 +673,22 @@ def pinned_beside_main(leg: Path) -> None: 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"]) +@pytest.mark.parametrize( + "make, stays", + [(without_upstream, "at the pin"), (upstream_ref_missing, "at the pin"), + (upstream_local, "at the pin"), (on_main, "on main at its tip"), + (pinned_beside_main, "at the pin")], + 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): + fresh, leg_main_ahead, make, stays): """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.""" + past it. Each refusal is main's, byte for byte, and says where the + checkout stays: at the pin, or on `main` where HEAD is on it (#533).""" leg = fresh / "spec" ahead = leg_main_ahead["ahead"] make(leg) @@ -651,7 +698,7 @@ def test_a_fresh_leg_main_nothing_vouches_for_is_not_moved( 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 not_moving(pin, ahead, stays) 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 @@ -663,20 +710,13 @@ def test_a_failing_post_checkout_hook_is_not_called_a_refused_checkout( 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) + hook's words follow it.""" leg = fresh / "spec" pin = rev(leg) result = run_script(fresh / "scripts" / "bootstrap.py", "--skip-validators", cwd=fresh, - env=configured("core.hooksPath", hooks.as_posix())) + env=failing_post_checkout(tmp_path)) assert result.returncode == 0, result.stderr + result.stdout assert moved_back(leg_main_ahead["ahead"], pin) + ( "\n checked out, and then git said:\n" @@ -713,3 +753,349 @@ def test_the_command_that_brings_the_branch_forward_is_shell_quoted( assert command[:3] == ["git", "-C", "my spec"], command git(*command[1:], cwd=fresh) assert rev(leg, MAIN) == ahead + + +# --- the branch IS the pin: a leg detached off it is left where it is (#533) - + +def neither(pin: str, head: str, place: str = "detached") -> str: + """The two lines step (a) prints for a leg whose branch IS the pin and + whose checkout is somewhere else, byte for byte as main prints them.""" + return (f" [spec] spec: branch main is at the pin {pin[:12]}, and the " + f"checkout is at {head[:12]} ({place}), which is neither\n" + " 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.\n") + + +def branch_at_the_new_pin(root: Path, remotes: Path, tmp_path: Path, + branch: str) -> tuple[Path, str]: + """A bump to a commit, and the tracking branch brought to it as a pull + in the leg would: the leg DETACHED at the pin over a `main` that IS the + pin, which is what `bump-leg.py --to` a branch's own tip leaves.""" + new = bumped(root, remotes, tmp_path, branch) + leg = root / "spec" + git("branch", "-q", "-f", "main", new, cwd=leg) + assert head_ref(leg) is None and rev(leg, MAIN) == new == rev(leg) + return leg, new + + +def detached_at_a_held_commit(leg: Path, old: str, where: str) -> str: + """Detach the leg at a commit that is not the pin but that something + holds, and return it: an ANCESTOR of the pin (`old`, which the branch + held), or `origin/main` past the pin (a remote-tracking ref made to hold + it, as `git submodule update --remote` leaves a leg).""" + if where == "ancestor": + git("checkout", "-q", "--detach", old, cwd=leg) + return old + git("commit", "-q", "--allow-empty", "-m", "the remote moved on", cwd=leg) + here = rev(leg) + git("update-ref", "refs/remotes/origin/main", here, cwd=leg) + return here + + +def no_branch(pin: str, head: str) -> str: + """The two lines step (a) prints for a leg detached at `head`, which is + not the pin, when the tracking branch is not there, byte for byte as main + prints them.""" + return (f" [spec] spec: branch main does not exist, and the checkout is " + f"at {head[:12]} (detached), which is not the pin {pin[:12]}\n" + " not moving the checkout off it. Keep any commit made " + "there on a branch, check out the pin in the leg, and run " + "bootstrap again.\n") + + +def has_no_branch(leg: Path) -> bool: + return git("rev-parse", "--verify", "--quiet", MAIN, cwd=leg, + check=False).returncode != 0 + + +def test_a_commit_made_on_a_leg_detached_at_its_branch_tip_is_not_stranded( + root, bootstrapped, tmp_path): + """THE ISSUE'S CASE: `bump-leg.py --to` the branch's own tip, a commit + made on the leg while it is detached there, then bootstrap. It checked + `main` out and printed `on main at `, and the commit was on no + branch, held by HEAD's reflog alone, with nothing said. The leg is left + where it is, said, and exits 0 as every refusal in step (a) does.""" + leg, new = branch_at_the_new_pin(root, bootstrapped["remotes"], tmp_path, + "advance-branch-tip") + 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 neither(new, made) in result.stdout, result.stdout + assert "[spec] spec: on main at" not in result.stdout + assert head_ref(leg) is None and rev(leg) == made, ( + "the leg is still detached at the commit") + assert rev(leg, MAIN) == new, "`main` is not moved" + assert git("rev-list", "--count", f"{new}..{made}", + cwd=leg).stdout.strip() == "1", "the commit is held there" + + git("checkout", "-q", "--detach", new, cwd=leg) + again = skip_validators(root) + assert f" [spec] spec: on main at {new[:12]} (branch tip == pin)" in ( + again.stdout), "the way out the lines name works" + assert head_ref(leg) == MAIN + + +def test_a_leg_detached_at_its_branch_tip_with_nothing_made_is_put_on_it( + root, bootstrapped, tmp_path): + """THE CONTROL: the same state with no commit made. Detached AT the pin + is a checkout of the branch that loses nothing, and it is made.""" + leg, new = branch_at_the_new_pin(root, bootstrapped["remotes"], tmp_path, + "advance-control") + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert f" [spec] spec: on main at {new[:12]} (branch tip == pin)\n" in ( + result.stdout), result.stdout + assert "neither" not in result.stdout + assert head_ref(leg) == MAIN and rev(leg) == new + + +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.""" + leg, new = branch_at_the_new_pin(root, bootstrapped["remotes"], tmp_path, + "advance-other-branch") + git("checkout", "-q", "-b", "elsewhere", cwd=leg) + git("commit", "-q", "--allow-empty", "-m", "work on elsewhere", cwd=leg) + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert f" [spec] spec: on main at {new[:12]} (branch tip == pin)\n" in ( + result.stdout), result.stdout + assert head_ref(leg) == MAIN and rev(leg) == new + assert rev(leg, "refs/heads/elsewhere") != new, "its commit is on it" + + +def test_a_stale_lock_over_a_leg_on_its_branch_says_nothing_to_check_out( + root): + """HEAD already on `main` at the pin, and a stale `index.lock`: git + exits non-zero over a checkout that would have changed nothing. The line + said `checked out, and then git said:` over a checkout that never + happened. Git's words still follow, because the same lock blocks the + person's next commit.""" + leg = root / "spec" + pin = rev(leg) + assert head_ref(leg) == MAIN and rev(leg, MAIN) == pin + lock = lock_index(leg) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert (f" [spec] spec: on main at {pin[:12]} (branch tip == pin)\n" + " nothing to check out, and then git said:\n" + ) in result.stdout, result.stdout + assert "checked out, and then git said" not in result.stdout + assert "refused" not in result.stdout, "not a refusal of anything" + assert "index.lock" in result.stdout, "git's own words" + assert head_ref(leg) == MAIN and rev(leg) == pin + + lock.unlink() + again = skip_validators(root) + assert f" [spec] spec: on main at {pin[:12]} (branch tip == pin)\n" in ( + again.stdout), "once the lock is gone, nothing more is said" + assert "then git said" not in again.stdout + + +def test_a_failing_hook_after_a_real_checkout_still_says_checked_out( + root, tmp_path): + """The ride-along changes only a checkout that moved nothing. A + post-checkout hook that fails after HEAD moved from the pin onto the + branch keeps `checked out, and then git said:`: it was checked out.""" + leg = root / "spec" + pin = rev(leg) + git("checkout", "-q", "--detach", pin, cwd=leg) + + 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 (f" [spec] spec: on main at {pin[:12]} (branch tip == pin)\n" + " checked out, and then git said:\n" + " post-checkout hook failed\n") in result.stdout, ( + result.stdout) + assert head_ref(leg) == MAIN and rev(leg) == pin + + +def test_a_failing_hook_over_a_leg_already_on_its_branch_is_not_a_refusal( + root, tmp_path): + """HEAD already on `main` at the pin, and a post-checkout hook that + fails: git runs it for a checkout that changes nothing, and exits + non-zero. The line said `git refused`, which a failing hook is not, and + before that `checked out`, which nothing was. Every cause of a non-zero + exit over that state gets the one line that is true of all of them.""" + leg = root / "spec" + pin = rev(leg) + assert head_ref(leg) == MAIN and rev(leg, MAIN) == pin + + 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 (f" [spec] spec: on main at {pin[:12]} (branch tip == pin)\n" + " nothing to check out, and then git said:\n" + " post-checkout hook failed\n") in result.stdout, ( + result.stdout) + assert "refused" not in result.stdout + assert "checked out, and then git said" not in result.stdout + assert head_ref(leg) == MAIN and rev(leg) == pin + + +# --- a leg detached off the pin is left where it is, whatever holds it ------ + +@pytest.mark.parametrize( + "where", ["ancestor", "remote"], + ids=["ancestor of the pin", "origin/main past the pin"]) +def test_a_leg_detached_at_a_commit_its_branch_or_remote_holds_is_not_moved( + root, bootstrapped, tmp_path, where): + """Detached at an ANCESTOR of the pin (the branch holds it), or at + `origin/main` past the pin (a remote-tracking ref holds it, as `git + submodule update --remote` leaves a leg), with `main` AT the pin. + Bootstrap used to check `main` out from both. Neither loses a commit, + but a leg detached at a commit that is not the pin is left where it is + and said, as the behind branch's is (`fast_forward_to_pin`), so the rule + has no exception to explain.""" + old = rev(root / "spec") + leg, new = branch_at_the_new_pin(root, bootstrapped["remotes"], tmp_path, + f"advance-held-{where}") + here = detached_at_a_held_commit(leg, old, where) + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert neither(new, here) in result.stdout, result.stdout + assert "[spec] spec: on main at" not in result.stdout + assert head_ref(leg) is None and rev(leg) == here, "HEAD stays" + assert rev(leg, MAIN) == new, "`main` is not moved" + + +def test_a_missing_tracking_branch_is_not_made_over_a_detached_commit( + root): + """THE SAME STRAND ON THE CREATE PATH: no `main` in the leg (a tracking + branch that is not the remote's default, or one deleted), a commit made + on the leg detached at the pin, then bootstrap. `checkout -b main ` + left the commit on no branch under `branch main created at `. The + leg is left where it is, and said, in words true of a branch that is not + there. Detached AT the pin the branch is created, as on a fresh clone.""" + leg = root / "spec" + pin = rev(leg) + git("checkout", "-q", "--detach", pin, cwd=leg) + git("branch", "-q", "-D", "main", cwd=leg) + git("commit", "-q", "--allow-empty", "-m", "made on the detached leg", + cwd=leg) + made = rev(leg) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert no_branch(pin, made) in result.stdout, result.stdout + assert "created at" not in result.stdout + assert head_ref(leg) is None and rev(leg) == made, "HEAD stays" + assert has_no_branch(leg), "no branch is made" + + git("checkout", "-q", "--detach", pin, cwd=leg) + again = skip_validators(root) + assert f" [spec] spec: branch main created at {pin[:12]}" in ( + again.stdout), again.stdout + assert head_ref(leg) == MAIN and rev(leg) == pin + + +@pytest.mark.parametrize( + "where", ["ancestor", "remote"], + ids=["ancestor of the pin", "origin/main past the pin"]) +def test_a_missing_branch_is_not_made_over_a_leg_detached_at_a_held_commit( + root, bootstrapped, tmp_path, where): + """The create path's twins of the two cases above: NO `main` in the leg, + and the leg detached at an ancestor of the pin, or at `origin/main` past + it. `checkout -b main ` moved the leg onto the pin and made the + branch there, as bootstrap once did for a branch that exists. Nothing is + lost either way, and the rule is one: a leg detached at a commit that is + not the pin is left where it is, with no branch made.""" + old = rev(root / "spec") + new = bumped(root, bootstrapped["remotes"], tmp_path, + f"advance-missing-{where}") + leg = root / "spec" + here = detached_at_a_held_commit(leg, old, where) + git("branch", "-q", "-D", "main", cwd=leg) + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert no_branch(new, here) in result.stdout, result.stdout + assert "created at" not in result.stdout + assert head_ref(leg) is None and rev(leg) == here, "HEAD stays" + assert has_no_branch(leg), "no branch is made" + + +def test_a_leg_whose_gitdir_is_gone_is_not_said_to_be_detached_off_the_pin( + root): + """A leg whose `.git` file names a gitdir that is not there: git can read + no HEAD, so there is no commit to keep and no place to say the checkout + is at. The guard read that as `?` and a detached HEAD, and told the + person to check out the pin. The branch cannot be made, and the line says + it could not, as it always did.""" + leg = root / "spec" + pin = rev(leg) + # Delete the gitfile before rewriting it: Git for Windows marks `.git` + # HIDDEN (`core.hideDotFiles=dotGitOnly`), a gitfile as much as a + # directory, and Windows refuses to open an existing hidden file for + # overwrite (`CreateFile` with `CREATE_ALWAYS` is ACCESS_DENIED), so + # `write_bytes` over it raises `PermissionError` there. `unlink` works. + (leg / ".git").unlink() + (leg / ".git").write_bytes(b"gitdir: ../.git/modules/gone\n") + + result = skip_validators(root) + assert result.returncode == 0, result.stderr + result.stdout + assert (f" [spec] spec: could not create branch 'main' at the pin " + f"{pin[:12]}; leaving detached\n") in result.stdout, result.stdout + assert "does not exist" not in result.stdout + assert "which is not the pin" not in result.stdout + assert "(detached)" not in result.stdout + + +# --- `not moving an existing branch` names where the checkout stays (#533) -- + +@pytest.mark.parametrize("make, place", [("commit", "detached"), + ("branch", "on elsewhere")], + ids=["detached off the pin", "on another branch"]) +def test_the_not_moving_line_names_a_checkout_that_is_neither( + root, bootstrapped, tmp_path, make, place): + """`main` holds a commit the pin lacks, and the leg stands neither on it + nor detached at the pin: detached at a commit made after the bump, or on + another branch. The line said `The checkout stays at the pin` over a + checkout that was not there; it names the commit and the place.""" + leg = root / "spec" + pin = rev(root, "HEAD:spec") + git("commit", "-q", "--allow-empty", "-m", "local work", cwd=leg) + mine = rev(leg) + git("checkout", "-q", "--detach", pin, cwd=leg) + new = bumped(root, bootstrapped["remotes"], tmp_path, + f"advance-stays-{make}") + if make == "branch": + git("checkout", "-q", "-b", "elsewhere", cwd=leg) + git("commit", "-q", "--allow-empty", "-m", "made after the bump", cwd=leg) + head = rev(leg) + + result = bootstrap(root) + assert result.returncode == 0, result.stderr + result.stdout + assert not_moving(new, mine, f"at {head[:12]} ({place})") in ( + result.stdout), result.stdout + assert "stays at the pin" not in result.stdout + assert rev(leg, MAIN) == mine and rev(leg) == head + + +def test_the_bump_prints_that_bootstrap_places_the_leg_or_says_why_not( + root, bootstrapped, tmp_path): + """`bump-leg.py` said `make bootstrap in the root re-places it on its + tracking branch AT the new pin` after every bump. Since #490 bootstrap + leaves a leg alone where placing it would lose a commit, and says why; + the line says the same.""" + new = advance(bootstrapped["remotes"], tmp_path, "advance-printed") + + result = bump_spec(root, bootstrapped["remotes"], new) + assert (f" spec/ is left DETACHED at {new[:12]}; `make bootstrap` in the " + "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