From 35ab2293070ecefbf4f9d74df05e0ff0dfec4457 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 17:30:13 +0000 Subject: [PATCH 1/4] bootstrap.py: a leg detached off the pin is left where it is and said, so a commit made there is no longer stranded when its branch is already at the pin, the `not moving an existing branch` line names where the checkout is, and bump-leg.py and AGENTS-shape.md say "or says why not" (#533) `place_at_pin`, step (a) for a leg whose tracking branch IS the pin, checked the branch out without looking at where HEAD was. `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; the next `make bootstrap` checked `main` out and printed `on main at (branch tip == pin)`, and the commit was held by HEAD's reflog alone with nothing said. `fast_forward_to_pin` refuses the same state, "which is neither", and so does every other branch of step (a). `place_at_pin` now reads `_head_of(sub)` first. HEAD detached at a commit other than the pin is refused with the same two lines `fast_forward_to_pin` prints (now one helper, `_say_neither`, byte for byte for the behind branch) and nothing moves. Detached AT the pin, or already on the branch, is as it was. On ANOTHER branch is as it was too: that branch holds its commits, and `fast_forward_to_pin` refusing it is a difference left to #458's checklist, not changed here. Ride-along: `_say_after` said `checked out, and then git said:` after a checkout git refused while HEAD was already on the branch at the pin (a stale `.git/index.lock`), so nothing was checked out. That path now says `git refused a checkout that would have changed nothing:` and prints git's lines as before, because the same lock blocks the person's next commit. A post-checkout hook that fails after a real move keeps the old wording. A hook that fails on a checkout that moved nothing gets the new line too: the exit status is all bootstrap can read. The `not moving an existing branch` line, whose phrase stays (the scaffold e2e test and shape-doctor.py cite it), said "The checkout stays at the pin" over a HEAD on the branch itself. `_stays` names where the checkout is: `on at its tip`, `at the pin`, or `at (detached)` / `at (on )`, the place read as `fast_forward_to_pin` builds it. bump-leg.py printed "`make bootstrap` in the root re-places it on its tracking branch AT the new pin" after every bump, and AGENTS-shape.md promised placement "AT the pinned commit ... not a detached HEAD" in two places. All three now say what AGENTS.md:355-356 and README.md:993-994 say since #505: the leg is put on its tracking branch AT the pin where that loses nothing, and otherwise it is left where it is and bootstrap says why. README.md, docs/handbook.html, AGENTS.md and shape-doctor.py are not touched; the doctor's two docstrings that still say bootstrap "never moves an existing branch whose tip is not the pin" belong to #413. bootstrap.py and AGENTS-shape.md are pinned copies: every project takes this as one `upstream-changed` re-pin of both. tests/test_bootstrap_after_bump.py gains the issue's case, its control, HEAD on another branch, the ride-along (with a failing hook after a real move as the counter-case), the `not moving` line for each place the checkout can be, and one bump-leg run carrying the new sentence; `not_moving()` takes where the checkout stays, and `bumped` is split into `advance` and `bump_spec` so the bump's output can be read. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- scripts/bump-leg.py | 3 +- templates/assembly-root/AGENTS-shape.md | 10 +- templates/assembly-root/scripts/bootstrap.py | 100 ++++++-- tests/test_bootstrap_after_bump.py | 256 +++++++++++++++++-- 4 files changed, 323 insertions(+), 46 deletions(-) diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py index 06102f60..446a4e04 100755 --- a/scripts/bump-leg.py +++ b/scripts/bump-leg.py @@ -1512,7 +1512,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..0b4c247a 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 the legs onto 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 the tracking branch AT its pinned commit +where that loses nothing, and otherwise leaves it where it is and says 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..b77aacdf 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -23,11 +23,15 @@ 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, whatever + its branch holds: a commit made there is on no branch, and checking + one out 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 +247,47 @@ 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, nothing was checked out, so `checked out, and + then git said:` over a stale `.git/index.lock` was false; it is said + for what it was, a checkout git refused that would have changed nothing + (#533). 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 + " git refused a checkout that would have changed " + "nothing:") _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_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") + 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.") + + 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 +297,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 +360,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 +433,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 "") @@ -443,9 +501,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 78063c4c..7115c8ea 100644 --- a/tests/test_bootstrap_after_bump.py +++ b/tests/test_bootstrap_after_bump.py @@ -35,6 +35,16 @@ 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 git refuses with HEAD already on the branch at the pin changes +nothing, and is said so rather than as `checked out, and then git said:`. +`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 +82,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", @@ -162,10 +174,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 @@ -183,13 +194,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 @@ -246,6 +271,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 @@ -268,6 +295,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 @@ -633,20 +661,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) @@ -656,7 +686,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 @@ -718,3 +748,191 @@ 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 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 where it was" + + 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_refused_checkout_over_a_leg_already_on_its_branch_is_said_so( + root): + """HEAD already on `main` at the pin, and a stale `index.lock`: git + refuses the checkout, which 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" + " git refused a checkout that would have changed " + "nothing:\n") in result.stdout, result.stdout + assert "checked out, and then git said" not in result.stdout + 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 "git refused a checkout" not in again.stdout + 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.""" + 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 = root / "spec" + pin = rev(leg) + git("checkout", "-q", "--detach", pin, cwd=leg) + + result = run_script(root / "scripts" / "bootstrap.py", + "--skip-validators", cwd=root, + env=configured("core.hooksPath", hooks.as_posix())) + 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 + + +# --- `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 From 8885a29818c5c921606e8f5d252cf6e637c9db04 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 17:55:54 +0000 Subject: [PATCH 2/4] bootstrap.py: a leg detached off the pin is no longer given a branch created at the pin over its commit, a non-zero exit over a checkout that had nothing to do says so and never "refused", and AGENTS-shape.md states the rule exactly (#533) Fix round 1 for #556, after an independent review (R556) of 35ab229. A non-zero exit with HEAD already on the tracking branch at the pin printed `git refused a checkout that would have changed nothing:`. True of a stale `.git/index.lock`, false of a failing post-checkout hook (a git-lfs hook with git-lfs missing from PATH is one real cause), which main called `checked out, and then git said:` and the file's own rule calls "not a refusal". The line is now `nothing to check out, and then git said:`, true of a lock, a hook and an unmerged index alike, and git's own lines follow as before. The create path had the strand place_at_pin was fixed for. With the tracking branch missing, `checkout -b ` ran from a leg detached at a commit made after the pin and printed `branch created at `, leaving that commit on no branch. checkout_tracking_branch now reads HEAD there too: detached at a commit that is not the pin, no branch is made, HEAD does not move, and the line says the branch does not exist, where the checkout is, and that the way out is to check out the pin (the branch is not there to check out). Detached AT the pin the branch is created, as on a fresh clone. `_say_keep` is the second line both refusals share. A behaviour change, now pinned by tests and named in the PR: a leg detached at ANY commit other than the pin is left where it is, even one its branch or `origin/main` holds (an ancestor of the pin; the tip a `git submodule update --remote` leaves, past the pin). Bootstrap used to check the branch out from both, losing nothing; `fast_forward_to_pin` already left the same checkout alone for a branch behind the pin, so the rule has no exception to explain. The module docstring says the same, for every state of the branch. AGENTS-shape.md :86-87 had a noun phrase and a verb phrase in one entry ("puts the legs on ..., or says why not"), and :95-96 said "where that loses nothing" as if that were the whole rule: a leg detached at the ancestor of the pin loses nothing and is still left. It now says: placed AT the pin where that loses nothing, and a leg detached at any other commit is left, saying why. Same lines, the file still 162. Tests (tests/test_bootstrap_after_bump.py, 31 -> 35): HEAD on main at the pin with a failing post-checkout hook (the new line, never "refused"); the stale-lock test takes the new line; a leg detached at an ancestor of the pin and at `origin/main` past it; a missing tracking branch over a detached commit, and the create when detached at the pin. The hook's fixture is one helper, `failing_post_checkout`. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- templates/assembly-root/AGENTS-shape.md | 6 +- templates/assembly-root/scripts/bootstrap.py | 55 ++++--- tests/test_bootstrap_after_bump.py | 154 +++++++++++++++---- 3 files changed, 166 insertions(+), 49 deletions(-) diff --git a/templates/assembly-root/AGENTS-shape.md b/templates/assembly-root/AGENTS-shape.md index 0b4c247a..e4192140 100644 --- a/templates/assembly-root/AGENTS-shape.md +++ b/templates/assembly-root/AGENTS-shape.md @@ -83,7 +83,7 @@ script guessing at `../`. ## The five commands ``` -make bootstrap the legs onto their tracking branch AT the pin, or says why +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 @@ -92,8 +92,8 @@ 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 -where that loses nothing, and otherwise leaves it where it is and says why. The +`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, diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index b77aacdf..065206fe 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -23,15 +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. A leg DETACHED at - a commit that is not the pin is never moved off it either, whatever - its branch holds: a commit made there is on no branch, and checking - one out 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. + 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 @@ -253,16 +254,17 @@ def _say_after(why: list[str] | None, moved: bool = True) -> None: 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, nothing was checked out, so `checked out, and - then git said:` over a stale `.git/index.lock` was false; it is said - for what it was, a checkout git refused that would have changed nothing - (#533). Git's lines follow either way: the same lock blocks the + 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:" if moved else - " git refused a checkout that would have changed " - "nothing:") + " nothing to check out, and then git said:") _say_lines(why) @@ -274,6 +276,14 @@ def _place_of(current: str | None) -> str: 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 @@ -283,9 +293,7 @@ def _say_neither(label: str, state: str, head: str | None, 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") - 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_keep() def place_at_pin(sub: Path, label: str, branch: str, pin: str) -> None: @@ -471,6 +479,17 @@ 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 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. + 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") diff --git a/tests/test_bootstrap_after_bump.py b/tests/test_bootstrap_after_bump.py index 7115c8ea..126cbaf9 100644 --- a/tests/test_bootstrap_after_bump.py +++ b/tests/test_bootstrap_after_bump.py @@ -40,10 +40,14 @@ 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 git refuses with HEAD already on the branch at the pin changes -nothing, and is said so rather than as `checked out, and then git said:`. -`not moving an existing branch` names where the checkout stays, which is not -always the pin: HEAD may be on the branch itself. +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 @@ -131,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) @@ -698,20 +715,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" @@ -795,7 +805,7 @@ def test_a_commit_made_on_a_leg_detached_at_its_branch_tip_is_not_stranded( "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 where it was" + cwd=leg).stdout.strip() == "1", "the commit is held there" git("checkout", "-q", "--detach", new, cwd=leg) again = skip_validators(root) @@ -838,13 +848,13 @@ def test_a_leg_on_another_branch_with_its_own_branch_at_the_pin_is_as_it_was( assert rev(leg, "refs/heads/elsewhere") != new, "its commit is on it" -def test_a_refused_checkout_over_a_leg_already_on_its_branch_is_said_so( +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 - refuses the checkout, which 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.""" + 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 @@ -853,9 +863,10 @@ def test_a_refused_checkout_over_a_leg_already_on_its_branch_is_said_so( 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" - " git refused a checkout that would have changed " - "nothing:\n") in result.stdout, result.stdout + " 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 @@ -863,7 +874,6 @@ def test_a_refused_checkout_over_a_leg_already_on_its_branch_is_said_so( 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 "git refused a checkout" not in again.stdout assert "then git said" not in again.stdout @@ -872,18 +882,13 @@ def test_a_failing_hook_after_a_real_checkout_still_says_checked_out( """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.""" - 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 = root / "spec" pin = rev(leg) git("checkout", "-q", "--detach", pin, cwd=leg) result = run_script(root / "scripts" / "bootstrap.py", "--skip-validators", cwd=root, - env=configured("core.hooksPath", hooks.as_posix())) + 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" @@ -892,6 +897,99 @@ def test_a_failing_hook_after_a_real_checkout_still_says_checked_out( 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}") + if where == "ancestor": + here = old + git("checkout", "-q", "--detach", old, cwd=leg) + else: + 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) + + 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 (f" [spec] spec: branch main does not exist, and the checkout is " + f"at {made[: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") 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 git("rev-parse", "--verify", "--quiet", MAIN, cwd=leg, + check=False).returncode != 0, "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 + + # --- `not moving an existing branch` names where the checkout stays (#533) -- @pytest.mark.parametrize("make, place", [("commit", "detached"), From f9b49bfdf831b408516cbc425c6f6fb951f71df6 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 18:12:04 +0000 Subject: [PATCH 3/4] bootstrap.py: the create-path guard needs a HEAD it can read, and tests pin the create path's twins of the held-commit cases (#533) Fix round 2 for #556: R556's two NITs on 8885a29, folded before the PR leaves draft so the pinned copy needs one re-pin. NV2. The create guard in checkout_tracking_branch read `current is None and head != pin`. A leg whose `.git` file names a gitdir that is gone makes `_head_of` answer (None, None); that satisfied the guard and printed `the checkout is at ? (detached), which is not the pin`, advising a checkout of the pin in a leg git cannot read. It is `head is not None and ...` now: with no HEAD there is no commit to keep, the `checkout -b` below fails, and the line is main's own, `could not create branch 'main' at the pin ; leaving detached`. place_at_pin needs no such guard: it runs only for a branch git just read. NV1. Tests for the create path's twins of the held-commit cases: no tracking branch, and the leg detached at an ancestor of the pin (V4) or at `origin/main` past it (V5). Each asserts that no branch is made and HEAD stays. The helpers `detached_at_a_held_commit`, `no_branch` and `has_no_branch` are shared with the earlier tests, and the gitdir-gone leg has a test of its own. Mutants, all killed: `and not _is_ancestor(sub, head, pin)` on the create guard (V4 fails), the guard letting `origin/main` through (V4 and V5 fail), the guard without `head is not None` (the gitdir test fails), the guard off (the three create-path tests fail). Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- templates/assembly-root/scripts/bootstrap.py | 6 +- tests/test_bootstrap_after_bump.py | 94 ++++++++++++++++---- 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index 065206fe..c2f83263 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -480,11 +480,13 @@ def checkout_tracking_branch(root: Path, leg: dict, branch: str) -> None: print(f" {where}: no gitlink recorded; leaving as is") return head, current = _head_of(sub) - if current is None and head != pin: + 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. + # 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)}") diff --git a/tests/test_bootstrap_after_bump.py b/tests/test_bootstrap_after_bump.py index 126cbaf9..f6a8f44b 100644 --- a/tests/test_bootstrap_after_bump.py +++ b/tests/test_bootstrap_after_bump.py @@ -784,6 +784,36 @@ def branch_at_the_new_pin(root: Path, remotes: Path, tmp_path: Path, 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 @@ -938,14 +968,7 @@ def test_a_leg_detached_at_a_commit_its_branch_or_remote_holds_is_not_moved( old = rev(root / "spec") leg, new = branch_at_the_new_pin(root, bootstrapped["remotes"], tmp_path, f"advance-held-{where}") - if where == "ancestor": - here = old - git("checkout", "-q", "--detach", old, cwd=leg) - else: - 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) + here = detached_at_a_held_commit(leg, old, where) result = bootstrap(root) assert result.returncode == 0, result.stderr + result.stdout @@ -973,15 +996,10 @@ def test_a_missing_tracking_branch_is_not_made_over_a_detached_commit( result = skip_validators(root) assert result.returncode == 0, result.stderr + result.stdout - assert (f" [spec] spec: branch main does not exist, and the checkout is " - f"at {made[: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") in result.stdout, 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 git("rev-parse", "--verify", "--quiet", MAIN, cwd=leg, - check=False).returncode != 0, "no branch is made" + assert has_no_branch(leg), "no branch is made" git("checkout", "-q", "--detach", pin, cwd=leg) again = skip_validators(root) @@ -990,6 +1008,52 @@ def test_a_missing_tracking_branch_is_not_made_over_a_detached_commit( 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) + (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"), From e5da3c549bd810654d21ead8187d5bdc5d8034dd Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 23:08:06 +0000 Subject: [PATCH 4/4] tests/test_bootstrap_after_bump.py: the test for a leg whose gitdir is gone now deletes the leg's gitfile before rewriting it, because Git for Windows marks `.git` hidden and Windows refuses to overwrite a hidden file, which failed #556's tests-windows job with `PermissionError` (#533) Run 38613137751's tests-windows job failed one test, `test_a_leg_whose_gitdir_is_gone_is_not_said_to_be_detached_off_the_pin`, at `(leg / ".git").write_bytes(b"gitdir: ../.git/modules/gone\n")`: `PermissionError: [Errno 13] Permission denied: '...\Atlas\spec\.git'`. The mechanism: the leg's `.git` is a gitfile that git itself wrote when the fixture's clone put the submodule in place, and Git for Windows marks `.git` HIDDEN (`core.hideDotFiles=dotGitOnly`), a gitfile as much as a directory. `Path.write_bytes` opens `"wb"`, which the C runtime turns into `CreateFile` with `CREATE_ALWAYS`, and that call fails with ACCESS_DENIED over an existing hidden file. The `root` fixture's `copytree` copies the attribute with the file, so the private copy is hidden too. Linux and macOS have no such attribute and passed. The test now `unlink()`s the gitfile first, which works on a hidden file (`tests/test_doctor_gitlink_repairs.py` and `tests/test_pin_validator.py` already unlink a leg's `.git` the same way), and writes the same bytes into the new file. The state the test builds, and everything it asserts, are unchanged. It is the only write in this pull request's tests over an existing `.git`; production code only asks whether `.git` exists. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_bootstrap_after_bump.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_bootstrap_after_bump.py b/tests/test_bootstrap_after_bump.py index f6a8f44b..b078dc02 100644 --- a/tests/test_bootstrap_after_bump.py +++ b/tests/test_bootstrap_after_bump.py @@ -1043,6 +1043,12 @@ def test_a_leg_whose_gitdir_is_gone_is_not_said_to_be_detached_off_the_pin( 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)