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 == ""