diff --git a/AGENTS.md b/AGENTS.md index d661c883..dea10dfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -331,6 +331,7 @@ checkout of this standard, pointed at the project. ```sh python3 scripts/bump-leg.py --root --leg spec --to <40 hex> --dry-run # 1. show the human the printed `old -> new` line +git -C switch -c # 3. never commit on the tracking branch python3 scripts/bump-leg.py --root --leg spec --to <40 hex> git -C push -u origin # then open a pull request ``` @@ -342,22 +343,21 @@ git -C push -u origin # then open a pull request agree.** The digest is RECOMPUTED from the leg's own objects, never adjusted to fit; a hand-written one turns the check into a formality and hides exactly the drift it exists to find. -3. **The commit is made on a BRANCH and lands as a pull request.** The tool - refuses to commit onto the tracking branch for that reason. Never suggest - a direct push to the default branch, and never `--admin`. +3. **The commit is made on a BRANCH, created BEFORE the real run, and lands + as a pull request.** A new project stands on its tracking branch; the dry + run runs there and prints the `git switch -c`, but the real run is refused. + Never suggest a direct push to the default branch, and never `--admin`. 4. **A refusal is not something to work around.** A commit no branch of the leg's remote contains must be PUSHED, not pinned — a pin the rest of the world cannot fetch is a root it cannot bootstrap. A red validator has already been rolled back; the finding above it is the thing to fix. 5. The leg is left DETACHED at the new commit. `make bootstrap` in the root - re-places it on its tracking branch AT the new pin; say so when you relay - the result. + re-places it on its tracking branch AT the new pin; say so in your relay. 6. **`--trailer ": "` puts a line on the commit this writes.** - Repeatable, kept in the order given — the same flag `update-shape.py - apply` and `family.py bump` already take. `--dry-run` prints the - trailers it would write, after the `old -> new` line, so the human reads - them before they land rather than after. A lane passes its `Lane:` line - and this estate's `Co-Authored-By:` there, not by amending afterwards. + Repeatable, kept in the order given, as `update-shape.py apply` and + `family.py bump` take it. `--dry-run` prints them after the `old -> new` + line, so the human reads them before they land. A lane passes its `Lane:` + line and this estate's `Co-Authored-By:` there, not by amending. ## Creating a family, and adding a member diff --git a/README.md b/README.md index 5465d135..3a4f26ad 100644 --- a/README.md +++ b/README.md @@ -500,12 +500,12 @@ there does not advance the project. Advancing it is one lockstep commit in the root, written by the tool from a checkout of this standard: ```sh +git -C ../Atlas switch -c # the tool will not commit on main python3 scripts/bump-leg.py --root ../Atlas --leg code --to <40-hex commit> git -C ../Atlas push -u origin # then open a pull request ``` -Editing the pin file by hand to make the validator agree is what the -validator exists to refuse. +Editing the pin by hand to make the validator agree is what it exists to refuse. **Variants.** An existing repository becomes the root in place with `adopt-project.py plan`, then `check`, then `execute`, the human answering @@ -972,8 +972,7 @@ Moving those three facts by hand is how they come apart, so one command moves them together, run from a checkout of this standard: ```sh -python3 scripts/bump-leg.py --root --leg spec \ - --to <40 hex commit> # --dry-run prints the move and writes nothing +python3 scripts/bump-leg.py --root --leg spec --to <40 hex> # --dry-run prints the move and writes nothing ``` It fetches the commit into the leg, checks the leg out AT it, recomputes the @@ -985,14 +984,15 @@ included — then runs the project's own `validate-pins.py` and `validate-manifest.py` and commits ONCE with explicit pathspecs. It REFUSES a `--to` that is not exactly 40 hex, a commit no branch of the leg's -remote contains (including one committed inside the mount and never pushed — a -pin the rest of the world cannot fetch is a root it cannot bootstrap), a dirty -root, an untracked or ignored workflow file it would rewrite, a root standing on -its own tracking branch (the next step is a pull request, never a push to the -default branch), and a validator that goes red — rolling the pin, the workflow -files, the leg's checkout and the index back first, so a refusal leaves the tree -exactly as it was found. The leg is left DETACHED at the new commit; `make -bootstrap` re-places it on its tracking branch AT the new pin. +remote contains (including one committed in the mount and never pushed — a pin +the rest of the world cannot fetch is a root it cannot bootstrap), a dirty root, +an untracked or ignored workflow file it would rewrite, a root standing on its +own tracking branch (the next step is a pull request, never a push to the +default branch: `git switch -c ` first; `--dry-run` writes nothing, so +it runs there and prints that command), and a validator that goes red — rolling +the pin, the workflow files, the leg's checkout and the index back first, so a +refusal leaves the tree as it was found. The leg is left DETACHED at the new +commit; `make bootstrap` re-places it on its tracking branch AT the new pin. ## Bootstrap is COPIED into the project, not fetched diff --git a/docs/handbook.html b/docs/handbook.html index 1baf4391..9cf97a32 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1321,7 +1321,9 @@

Advancing a leg

that this user cannot open, naming the path and the system’s reason, with the same ids and the remedy of making it readable and running the command again; a root standing on its own tracking branch (the next step is a pull request, never a - push to the default branch); and a validator that goes red — rolling the pin, the + push to the default branch: git switch -c <branch> first; + --dry-run writes nothing, so it runs there and prints that command); and a + validator that goes red — rolling the pin, the workflow files, the leg’s checkout and the index back first, so a refusal leaves the tree exactly as it was found.

The leg is left DETACHED at the new commit; make bootstrap re-places it on its @@ -1799,9 +1801,10 @@

For AI assistants

name, added (#289); bump-leg.py refusing a workflow file, the workflows directory or the leg’s pin that this user cannot open, by name, added (#383); what setup.sh --help does from a download, which clones nothing, and which commands - list every flag, added (#341) — a page cannot name the commit that contains it, and - the blob below is the fact a test checks. - README.md blob 5465d135ca7263010ee354424041f90c4553d2e7. Every command, flag, exit code + list every flag, added (#341); bump-leg.py --dry-run running on the tracking + branch and naming the branch the real run needs, added (#477) — a page cannot name + the commit that contains it, and the blob below is the fact a test checks. + README.md blob 3a4f26ade02d2086157f1bd30c533444101710f8. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py index 0ab27b7d..4d10ed55 100755 --- a/scripts/bump-leg.py +++ b/scripts/bump-leg.py @@ -47,7 +47,8 @@ git's spelling, never refused as untracked. * the root standing on its own tracking branch. The next step this prints would then be a push to the default branch, and these organisations are - pull-request only. + pull-request only. `--dry-run` commits nothing, so it runs there, and ends + by naming the `git switch -c` the real run needs (#477). * a workflow file under `.github/workflows/`, or the leg's `contracts/-pin.yaml`, that is not UTF-8 (a Latin-1 comment, a UTF-16 file with a byte-order mark): `bump-leg-workflow-unreadable` and @@ -96,6 +97,7 @@ import errno import os import re +import shlex import stat import subprocess import sys @@ -1000,13 +1002,16 @@ def _ensure_root_clean(root: Path) -> None: def _branch_to_bump_from(root: Path, project: "Project", role: str, - commit: str) -> str: + commit: str, *, dry_run: bool = False) -> str: """The branch on which the bump commits, refused if there is none or it is the project's own tracking branch. Split from `cmd_bump` for #136: these organisations are pull-request only, so a bump is refused before it ever makes a commit whose only - next step would be a push to the default branch. + next step would be a push to the default branch. A `--dry-run` makes no + commit, so on the tracking branch it is let through and ends by naming + the branch the real run needs (#477); a root on no branch at all is + refused either way. """ branch = current_branch(root) if branch is None: @@ -1016,7 +1021,7 @@ def _branch_to_bump_from(root: Path, project: "Project", role: str, "open a pull request from", "Remediation: `git -C " + str(root) + " switch -c bump/" + f"{role}-{commit[:12]}` and re-run.") - if branch == project.tracking: + if branch == project.tracking and not dry_run: raise Refusal( "bump-leg-on-tracking-branch", f"{root} is on {branch!r}, which {MANIFEST} declares as this " @@ -1432,7 +1437,8 @@ def cmd_bump(args) -> int: commit = _checked_target_commit(args.to) _ensure_root_clean(root) - branch = _branch_to_bump_from(root, project, role, commit) + branch = _branch_to_bump_from(root, project, role, commit, + dry_run=args.dry_run) submodule = _initialized_submodule(root, path) was = _recorded_leg_commit(root, path) @@ -1463,6 +1469,14 @@ def cmd_bump(args) -> int: "fetched into, which is how the digest above was computed; the " "working tree, the pin, the workflows and the index are " "untouched.") + if branch == project.tracking: + # LAST, after everything a dry run on a branch prints, so that + # line is the whole of the difference between the two (#477). + # The command is quoted, so a root under `My Projects/` pastes. + print(f"\nthe real run needs a branch: {root} is on {branch!r}, " + "the tracking branch. Create one first with: " + + shlex.join(["git", "-C", str(root), "switch", "-c", + f"bump/{role}-{commit[:12]}"])) return 0 # ---- the rewrite and the commit, remembered byte for byte so a failure diff --git a/tests/test_bump_leg_dry_run_tracking.py b/tests/test_bump_leg_dry_run_tracking.py new file mode 100644 index 00000000..220cf761 --- /dev/null +++ b/tests/test_bump_leg_dry_run_tracking.py @@ -0,0 +1,232 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`scripts/bump-leg.py --dry-run` runs on the project's tracking branch +(#477). + +STEP 1 OF "Advancing a leg" IS A DRY RUN, and every fresh project stands on +its tracking branch after setup. The tool refused to commit onto that branch +and refused the dry run for it as well, with `bump-leg-on-tracking-branch`, +before it printed the `old -> new` line the human is meant to read. A dry run +commits nothing, so it now runs there, prints everything it prints on a +branch, and ends with ONE line saying the real run needs a branch and naming +the `git switch -c` that makes it, quoted so that it pastes for a root with a +space in it. + +WHAT DID NOT MOVE is pinned here too, because each is the other half of the +rule: the real run on the tracking branch is refused with the same words, a +dry run on a branch prints what it always printed, a root on no branch at all +is refused under `--dry-run` as before, and the checks that come AFTER the +branch (the remote having the commit) still run for a dry run on the tracking +branch, so lifting the refusal did not lift them. + +NO NETWORK, NO `gh`, NO BASH: the bare remotes are the session's own, so this +runs on every OS the suite runs on. +""" + +from __future__ import annotations + +import shlex +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import PROJECT, git +from test_bump_leg import branched, bump, reference_both_legs +from test_bump_leg_rollback import assert_nothing_changed, snapshot +from test_commit_trailers import TRAILERS + +#: Where the advanced spec commit is pushed. Not another module's branch: the +#: bare remotes are session-scoped, and a second, different commit pushed to +#: a name already taken is refused as non-fast-forward. +ADVANCE_BRANCH = "bump-leg-dry-run-tracking-advance" + +DRY_RUN = "--dry-run" + +#: Said once, by the last line of a dry run on the tracking branch, and by no +#: other run. +NEEDS_A_BRANCH = "needs a branch" + +#: What the hint says directly before the command a person pastes. +PASTE_AFTER = "Create one first with: " + + +@pytest.fixture(scope="module") +def advanced(scaffolded, tmp_path_factory) -> str: + """A real new commit on the spec leg's bare remote; returns its sha.""" + work = (tmp_path_factory.mktemp("spec-advance-dry-run-tracking") + / f"{PROJECT}-spec") + proc = subprocess.run( + ["git", "clone", "-q", + str(scaffolded["remotes"] / f"{PROJECT}-spec.git"), str(work)], + capture_output=True, text=True, check=False) + assert proc.returncode == 0, proc.stderr + (work / "DRY-RUN-TRACKING.md").write_text("# a contract gained\n") + git("add", "--", "DRY-RUN-TRACKING.md", cwd=work) + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", + "Advance the spec leg for the dry-run-on-tracking-branch tests", + cwd=work) + git("push", "-q", "origin", f"HEAD:refs/heads/{ADVANCE_BRANCH}", cwd=work) + return git("rev-parse", "HEAD", cwd=work).stdout.strip() + + +def tracking_branch(project: Path) -> str: + return git("symbolic-ref", "--short", "HEAD", cwd=project).stdout.strip() + + +def switch_command(project: Path, to: str) -> str: + """The command the tool prints, spelled as it prints it: the root + resolved, which is what `Project.open` does to `--root`, and the words + joined by `shlex.join`. Joined here too, because `shlex.quote` quotes + every path that holds a backslash, which is every Windows root.""" + return shlex.join(["git", "-C", str(project.resolve()), "switch", "-c", + f"bump/spec-{to[:12]}"]) + + +# --- a dry run on the tracking branch --------------------------------------- + +def test_a_dry_run_on_the_tracking_branch_prints_the_move_and_names_a_branch( + project, scaffolded, advanced): + """THE FIX: exit 0, the whole report, and a last line that says what the + real run needs. The workflows and the trailers are printed too, so the + run is shown to reach every part of the report a dry run on a branch + reaches, not only the first line.""" + was, _ = reference_both_legs(project) + assert tracking_branch(project) == "main", "a fresh project's branch" + before = snapshot(project) + + result = bump(project, scaffolded, advanced, DRY_RUN, + "--trailer", TRAILERS[0], "--trailer", TRAILERS[1]) + assert result.returncode == 0, result.stderr + result.stdout + assert result.stderr == "", "a dry run there refuses nothing" + + out = result.stdout + old_to_new = f"{was[:12]} -> {advanced[:12]}" + assert old_to_new in out + assert ".github/workflows/legs.yml" in out + for line in TRAILERS: + assert line in out + assert "--dry-run: nothing was changed." in out + assert "bump-leg-on-tracking-branch" not in out + + hint = out.splitlines()[-1] + assert f"the real run {NEEDS_A_BRANCH}" in hint + assert hint.endswith(switch_command(project, advanced)), hint + assert out.index(old_to_new) < out.index(TRAILERS[0]), ( + "the line the human reads comes first") + assert out.index(TRAILERS[0]) < out.index(hint), ( + "the branch hint comes last") + assert out.count(NEEDS_A_BRANCH) == 1, "ONE line, said once" + + assert_nothing_changed(project, before) + assert tracking_branch(project) == "main", ( + "the dry run does not switch the root onto a branch itself") + + +def test_the_branch_hint_is_the_only_difference_from_a_dry_run_on_a_branch( + project, scaffolded, advanced): + """The same root, the same arguments, first on `main` and then on a + branch: the second report is the first minus its last two lines, a blank + one and the hint, byte for byte. So "a dry run on a branch is unchanged" + is a comparison, not a pasted copy of today's wording.""" + reference_both_legs(project) + on_tracking = bump(project, scaffolded, advanced, DRY_RUN) + branched(project) + on_a_branch = bump(project, scaffolded, advanced, DRY_RUN) + assert on_tracking.returncode == 0, on_tracking.stderr + on_tracking.stdout + assert on_a_branch.returncode == 0, on_a_branch.stderr + on_a_branch.stdout + + assert NEEDS_A_BRANCH not in on_a_branch.stdout, ( + "a root already on a branch is told nothing about one") + assert on_tracking.stdout.startswith(on_a_branch.stdout) + tail = on_tracking.stdout[len(on_a_branch.stdout):] + assert tail.splitlines() == ["", on_tracking.stdout.splitlines()[-1]] + assert tail.endswith(switch_command(project, advanced) + "\n") + assert on_tracking.stderr == "" + assert on_a_branch.stderr == "" + + +def test_the_printed_switch_command_pastes_for_a_root_with_a_space( + scaffolded, advanced, tmp_path): + """`My Projects/` and `C:\\Users\\Jane Doe\\` are ordinary roots, and + the printed line is the step "Advancing a leg" in AGENTS.md has the + assistant paste. A shell splits an unquoted root at its space and hands + git `Projects/Atlas` as the subcommand, so the line is quoted: read back + the way a shell reads it, it is exactly the argv that makes the branch, + and running that argv makes it.""" + root = tmp_path / "My Projects" / PROJECT + shutil.copytree(scaffolded["clone"], root, symlinks=True) + assert " " in str(root.resolve()) + + result = bump(root, scaffolded, advanced, DRY_RUN) + assert result.returncode == 0, result.stderr + result.stdout + _, found, command = result.stdout.splitlines()[-1].partition(PASTE_AFTER) + assert found, result.stdout + + branch = f"bump/spec-{advanced[:12]}" + argv = shlex.split(command) + assert argv == ["git", "-C", str(root.resolve()), "switch", "-c", branch] + + proc = subprocess.run(argv, capture_output=True, text=True, check=False) + assert proc.returncode == 0, proc.stderr + assert tracking_branch(root) == branch + + +def test_a_dry_run_already_pinned_on_the_tracking_branch_says_nothing_to_do( + project, scaffolded): + """Nothing to move means nothing to advise: exit 1 and no branch hint, + where it used to be refused before the tool read the pin at all.""" + pinned = git("rev-parse", "HEAD:spec", cwd=project).stdout.strip() + result = bump(project, scaffolded, pinned, DRY_RUN) + assert result.returncode == 1, result.stderr + result.stdout + assert "nothing to do" in result.stdout + assert NEEDS_A_BRANCH not in result.stdout + + +# --- what is still refused -------------------------------------------------- + +def test_the_real_run_on_the_tracking_branch_is_refused_byte_for_byte( + project, scaffolded, advanced): + """The refusal's every byte, spelled out, and nothing on stdout: this is + the text the run had before #477, and a change to it is a change to what + a lane that scripts around it reads.""" + root = project.resolve() + before = snapshot(project) + + result = bump(project, scaffolded, advanced) + assert result.returncode == 2, result.stderr + result.stdout + assert result.stdout == "" + assert result.stderr == ( + f"REFUSED bump-leg-on-tracking-branch: {root} is on 'main', which " + "project.yaml declares as this project's tracking branch\n" + "Remediation: these organisations are pull-request only, so this " + "will not make a commit whose only next step is a push to the " + f"default branch. Run:\n git -C {root} switch -c " + f"bump/spec-{advanced[:12]}\nand re-run.\n") + assert_nothing_changed(project, before) + + +def test_a_dry_run_on_a_root_with_no_branch_is_still_refused( + project, scaffolded, advanced): + """Control: the issue is the tracking branch. A detached root has no + branch to push from under either mode, and `--dry-run` did not change + that.""" + git("checkout", "-q", "--detach", cwd=project) + result = bump(project, scaffolded, advanced, DRY_RUN) + assert result.returncode == 2, result.stderr + result.stdout + assert "REFUSED bump-leg-root-detached" in result.stderr + assert result.stdout == "" + + +def test_a_dry_run_on_the_tracking_branch_still_checks_the_remote( + project, scaffolded): + """The refusal that stood in front of every other check is gone for a dry + run, so the checks behind it are what a dry run on the tracking branch now + meets: a commit the leg's remote does not have is refused, and no branch + hint is printed for a move that could not be made.""" + result = bump(project, scaffolded, "0123456789abcdef" * 2 + "01234567", + DRY_RUN) + assert result.returncode == 2, result.stderr + result.stdout + assert "REFUSED bump-leg-commit-not-on-remote" in result.stderr + assert NEEDS_A_BRANCH not in result.stdout + assert git("status", "--porcelain", cwd=project).stdout == ""