From 52f4bff6b926c478e3aca338c5db7721bfb97a09 Mon Sep 17 00:00:00 2001
From: openRepoShape CI
Date: Sun, 11 Oct 2026 16:37:22 +0000
Subject: [PATCH 1/2] bump-leg.py rewrites and commits only the workflow files
git tracks, so an untracked draft workflow that names the leg is no longer
added whole to the lockstep commit and an ignored one no longer dies at `git
add` after the rewrite: both are refused by name as
`bump-leg-workflow-untracked`, before anything is written and under
`--dry-run` as well (#207)
`plan_workflows` walked `.github/workflows/` on disk and the commit then named every rewritten file as an explicit pathspec, so an untracked draft that referenced the leg was staged into "Bump spec leg" as a new file, while the root-clean check had skipped it on the ground that an untracked file cannot reach that commit. The dry run listed the file without saying it was untracked. An ignored draft was no better, only later: the dry run exited 0 listing it, then `git add` refused it after the rewrite ("The following paths are ignored by one of your .gitignore files") and the bump was rolled back with no word about the cause.
- **Tracked files only.** A row is made for a workflow file only when `git ls-files -z -- .github/workflows` holds it. The directory is still walked on disk, because that is the set `validate-pins.py` reads, and `.yml` and `.yaml` are both read.
- **A file git does not track that has a reference to move is a refusal, not a skip.** Skipped, the validator reads its old `@` off the disk and the bump goes red after the rewrite (rolled back), with a finding that never says the file is not in the repository, after the plan had said `no @ reference names this leg`. The refusal names every such file with its reference count and gives both exits: commit it first (it is then tracked and moves with the bump) or move it out of the directory until the bump has landed.
- **Ignored counts as untracked.** The validator reads an ignored file and the commit's `git add` refuses it, so on main the bump could only fail late; it is now refused before anything is written, under the same code.
- **A file with nothing to move is left alone, unnamed.** No reference to the leg, or the leg already at `--to`: not rewritten, not staged, not refused.
- **Unchanged:** a bump with no such file is byte-identical to main, the tracked-workflow rewrite included (same output, same commit sha, same files). `--help` is unchanged, so `docs/cli.md` is too. The README's paragraph on what the command rewrites and refuses now says "tracked" and names the refusal (README.md stays at its 1416-line cap, so the refusal's two exits are left to its own message), and `docs/handbook.html` says the same, its footer's `README.md blob` moved with the README.
- **Left out:** `validate-pins.py` still reads every file on disk, untracked included. It is a digest-pinned copy in every project, so it is not this change's to move; the refusal is what keeps the two readers agreeing.
Closes #207
Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5
---
README.md | 28 ++---
docs/handbook.html | 19 ++--
scripts/bump-leg.py | 78 ++++++++++++--
tests/test_bump_leg.py | 230 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 326 insertions(+), 29 deletions(-)
diff --git a/README.md b/README.md
index 095a1b9e..4aef38fd 100644
--- a/README.md
+++ b/README.md
@@ -973,20 +973,20 @@ python3 scripts/bump-leg.py --root --leg spec \
It fetches the commit into the leg, checks the leg out AT it, recomputes the
`sorted-ls-tree-r-v1` digest from the leg's own objects, rewrites `commit:`
and `digests.tree_sha256` in `contracts/-pin.yaml`, rewrites every
-`.github/workflows/*.yml` `@` naming THAT leg's repository — and none
-naming anything else, `actions/checkout@` and the sibling leg 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, 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.
+TRACKED `.github/workflows/*.yml` `@` naming THAT leg's repository — and
+none naming anything else, `actions/checkout@` and the sibling leg
+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 that names the leg, 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.
## Bootstrap is COPIED into the project, not fetched
diff --git a/docs/handbook.html b/docs/handbook.html
index ce3e2616..a8d944d0 100644
--- a/docs/handbook.html
+++ b/docs/handbook.html
@@ -1304,17 +1304,18 @@ Advancing a leg
--to <40 hex commit> # --dry-run prints the move and writes nothing
It fetches the commit into the leg, checks the leg out AT it, recomputes the
sorted-ls-tree-r-v1 digest from the leg’s own objects, rewrites
- commit: and digests.tree_sha256, rewrites every workflow
+ commit: and digests.tree_sha256, rewrites every TRACKED workflow
@<sha> naming THAT leg’s repository — and none naming anything
else, actions/checkout@<sha> and the sibling leg included — then runs
the project’s own validate-pins.py and validate-manifest.py and
commits ONCE with explicit pathspecs.
Refuses a --to that is not exactly 40 hex; a
commit no branch of the leg’s remote contains (a pin the rest of the world cannot fetch
- is a root it cannot bootstrap); a dirty root; 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.
+ is a root it cannot bootstrap); a dirty root; an untracked or ignored workflow file that names
+ the leg; 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. Editing the pin file by hand to make the validator agree is
what the validator exists to refuse.
@@ -1777,9 +1778,11 @@ For AI assistants
templates/workspace-root/ row, the fifth template, added (#154); the Triad as
the preferred shape, where the advisory is said and where it is silent, the optional
single-repository.yaml, the doctor’s advisory row and the
- assistant’s “what you do tell them” added (#163) — a page
- cannot name the commit that contains it, and the blob below is the fact a test checks.
- README.md blob 095a1b9e60743fc8f8d26bd11247572956a3f144. Every command, flag, exit code and
+ assistant’s “what you do tell them” added (#163); bump-leg.py
+ rewriting tracked workflow files only, and refusing an untracked or ignored one that names
+ the leg, added (#207) — a page cannot name the commit that contains it, and the blob
+ below is the fact a test checks.
+ README.md blob 4aef38fdc041dfbda497481e4c0dce0df3906cf2. 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 1e9aac2e..495f5aa3 100755
--- a/scripts/bump-leg.py
+++ b/scripts/bump-leg.py
@@ -14,7 +14,7 @@
1. the GITLINK the assembly root records at the leg's path,
2. `commit:` and `digests.tree_sha256` in `contracts/-pin.yaml`,
- 3. every `.github/workflows/*.yml` reference of the form
+ 3. every TRACKED `.github/workflows/*.yml` reference of the form
`//...@<40 hex>` naming THAT leg's repository.
`family.py bump` already did exactly this for a family's MEMBER pins. Nothing
@@ -33,6 +33,15 @@
* a dirty root. The commit is made with explicit pathspecs, so unrelated
edits would not be swept in — but a lockstep commit that cannot be read as
exactly one change is the thing the rule exists to keep readable.
+ * a workflow file git does not TRACK — untracked, or ignored — that names
+ the leg at any other commit. The lockstep commit's `git add` names every
+ file this rewrites: an untracked one would be added to the commit whole,
+ as a new file nobody committed, and an ignored one would stop that
+ `git add` after the rewrite, in git's own words. Leaving either alone
+ would turn the bump red, because `validate-pins.py` reads every file on
+ disk and would still see its old `@`. Refused by name, before
+ anything is written, under `--dry-run` as well. One that names no leg,
+ or this leg already at `--to`, is left alone.
* 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.
@@ -228,9 +237,24 @@ def rewrite_pin(text: str, commit: str, digest: str) -> str:
return "\n".join(out) + "\n"
+def _tracked_workflows(root: Path, workflows: Path) -> set[str]:
+ """The paths git TRACKS under `workflows`, as POSIX paths relative to
+ `root` — the spelling `plan_workflows` and the commit's pathspecs use.
+
+ The index, by `git ls-files`: `_ensure_root_clean` has already refused a
+ root whose tracked files differ from it, so it is also what HEAD holds.
+ NUL-separated, so a name git would otherwise quote comes back as the
+ bytes it is.
+ """
+ raw = git_out(["ls-files", "-z", "--",
+ workflows.relative_to(root).as_posix()],
+ cwd=root, binary=True)
+ return {os.fsdecode(name) for name in raw.split(b"\0") if name}
+
+
def plan_workflows(root: Path, repository: str,
commit: str) -> list[tuple[Path, str, int]]:
- """Every workflow file whose `@` refs to THIS leg have to move.
+ """Every TRACKED workflow file whose `@` refs to THIS leg have to move.
Returns `(path, rewritten text, references moved)`, one row per file that
actually changes. A reference to any OTHER repository is left exactly as
@@ -240,11 +264,29 @@ def plan_workflows(root: Path, repository: str,
`.yaml` is read as well as `.yml` because `validate-pins.py` reads both,
and a file this rewrote nothing in is a file the gate still calls red.
+
+ TRACKED FILES ONLY (#207). The directory is walked on disk, because that
+ is the set `validate-pins.py` reads, but a row is made for a file only
+ when git tracks it: the lockstep commit's `git add` names every file it
+ rewrote, so a draft that was never committed would be added to "Bump spec
+ leg" whole, as a new file, while `_ensure_root_clean` — which skips
+ untracked files on exactly the ground that they cannot reach this commit
+ — said nothing. (An IGNORED one is no better, only later: `git add`
+ refuses it after the rewrite, with its own hint and no word about this
+ tool.) An untracked or ignored file that has a reference to move is
+ REFUSED by name rather than skipped: skipped, `validate-pins.py` would
+ still read its old `@` off the disk and turn the bump red after the
+ rewrite, with a finding that never says the file is not in the
+ repository. One with nothing to move — no reference to this leg, or this
+ leg already at `commit` — is not this tool's business and is left alone,
+ unnamed.
"""
plans: list[tuple[Path, str, int]] = []
workflows = root / ".github" / "workflows"
if not workflows.is_dir():
return plans
+ tracked = _tracked_workflows(root, workflows)
+ untracked: list[tuple[str, int]] = []
for path in sorted(workflows.iterdir()):
if path.suffix not in (".yml", ".yaml") or not path.is_file():
continue
@@ -262,8 +304,28 @@ def swap(match: re.Match) -> str:
text = path.read_text(encoding="utf-8")
rewritten = WORKFLOW_REF_RE.sub(swap, text)
- if moved:
+ if not moved:
+ continue
+ relative = path.relative_to(root).as_posix()
+ if relative in tracked:
plans.append((path, rewritten, moved))
+ else:
+ untracked.append((relative, moved))
+ if untracked:
+ raise Refusal(
+ "bump-leg-workflow-untracked",
+ f"{len(untracked)} workflow file(s) that git does not track "
+ f"(untracked or ignored) name {repository} at a commit other "
+ f"than {commit[:12]}:\n"
+ + "\n".join(f" {relative} {count} reference(s)"
+ for relative, count in untracked),
+ "Remediation: the lockstep commit adds no file the repository "
+ "does not already hold, and `scripts/validate-pins.py` reads "
+ "every workflow file on disk, so one left where it is would turn "
+ "the bump red over its old `@`. Either commit it first — it "
+ "is then tracked and moves with the bump, and the dry run lists "
+ "it — or move it out of .github/workflows/ until the bump has "
+ "landed, then re-run.")
return plans
@@ -473,10 +535,12 @@ def _ensure_root_clean(root: Path) -> None:
# THE ROOT MUST BE CLEAN. Tracked changes only: an untracked scratch file
# cannot reach a commit made with explicit pathspecs, and refusing one
# would be this tool having an opinion about somebody's working
- # directory. Untracked content INSIDE the leg is ignored for the same
- # reason; the leg sitting at a different commit than the gitlink is NOT,
- # because that is a half-finished bump and this one would land on top of
- # it.
+ # directory. The one kind that could reach it, a workflow file the rewrite
+ # would pick up, is never picked up: `plan_workflows` rewrites tracked
+ # files only and refuses an untracked one by name (#207). Untracked
+ # content INSIDE the leg is ignored for the same reason; the leg sitting
+ # at a different commit than the gitlink is NOT, because that is a
+ # half-finished bump and this one would land on top of it.
dirt = git_out(["status", "--porcelain", "--untracked-files=no",
"--ignore-submodules=untracked"], cwd=root)
if dirt:
diff --git a/tests/test_bump_leg.py b/tests/test_bump_leg.py
index 608da2c0..2e26c7b2 100644
--- a/tests/test_bump_leg.py
+++ b/tests/test_bump_leg.py
@@ -122,6 +122,54 @@ def reference_both_legs(project: Path) -> tuple[str, str]:
return spec, code
+#: A workflow nobody has committed yet (#207): `{uses}` is its one job.
+DRAFT = """\
+name: draft
+on: workflow_dispatch
+jobs:
+ d:
+ uses: {uses}
+"""
+
+
+def spec_ref(sha: str) -> str:
+ return f"{ORG}/{PROJECT}-spec/.github/workflows/x.yml@{sha}"
+
+
+def leave_untracked(project: Path, name: str, uses: str) -> Path:
+ """A draft workflow written into the working tree and committed nowhere."""
+ path = project / ".github" / "workflows" / name
+ path.write_text(DRAFT.format(uses=uses), encoding="utf-8")
+ return path
+
+
+def ignore_locally(project: Path, pattern: str) -> None:
+ """`.gitignore` is tracked, and editing it would dirty the root; the
+ clone's own `info/exclude` ignores a file without touching the tree."""
+ exclude = Path(git("rev-parse", "--git-path", "info/exclude",
+ cwd=project).stdout.strip())
+ if not exclude.is_absolute():
+ exclude = project / exclude
+ with exclude.open("a", encoding="utf-8", newline="\n") as handle:
+ handle.write(pattern + "\n")
+
+
+def found(project: Path) -> dict:
+ """Everything a refusal promises not to have moved: the branch tip, the
+ gitlink in the index, the leg's own checkout, the pin, the working-tree
+ status and the bytes of every workflow file."""
+ workflows = project / ".github" / "workflows"
+ return {
+ "head": git("rev-parse", "HEAD", cwd=project).stdout.strip(),
+ "gitlink": git("ls-files", "-s", "--", "spec", cwd=project).stdout,
+ "leg": git("rev-parse", "HEAD", cwd=project / "spec").stdout.strip(),
+ "pin": (project / "contracts" / "spec-pin.yaml").read_bytes(),
+ "status": git("status", "--porcelain", cwd=project).stdout,
+ "workflows": {path.name: path.read_bytes()
+ for path in sorted(workflows.iterdir())},
+ }
+
+
# --- the bump itself --------------------------------------------------------
def test_bump_moves_the_gitlink_and_the_pin_in_one_commit(project, scaffolded,
@@ -242,6 +290,188 @@ def expression(path: Path) -> str:
REPO / "templates" / "assembly-root" / "scripts" / "validate-pins.py")
+# --- only TRACKED workflows are rewritten and committed (#207) --------------
+
+@pytest.mark.parametrize("name", ["draft.yml", "draft.yaml"])
+def test_an_untracked_draft_naming_the_leg_is_refused_by_name_not_committed(
+ project, scaffolded, advanced, name):
+ """THE BUG: `plan_workflows` walked the directory on disk, so an untracked
+ draft that referenced the leg was rewritten and then staged by the commit's
+ explicit pathspec — added WHOLE to "Bump spec leg", as a new file. The
+ root-clean check says untracked files are skipped because they cannot
+ reach that commit, and this one did.
+
+ It is refused rather than skipped: `validate-pins.py` reads every file on
+ disk, so a skipped draft would leave its old `@` to turn the bump red
+ after the rewrite. Refused BEFORE anything is written, and under
+ `--dry-run` as well, so the dry run is never a promise the real run
+ breaks. `.yaml` is read by the validator too, so it counts too.
+ """
+ was = git("rev-parse", "HEAD:spec", cwd=project).stdout.strip()
+ branched(project)
+ leave_untracked(project, name, spec_ref(was))
+ before = found(project)
+ assert before["status"] == f"?? .github/workflows/{name}\n"
+
+ result = bump(project, scaffolded, advanced)
+ assert result.returncode == 2, result.stderr + result.stdout
+ assert "bump-leg-workflow-untracked" in result.stderr
+ assert f".github/workflows/{name} 1 reference(s)" in result.stderr
+ assert f"{ORG}/{PROJECT}-spec" in result.stderr
+ assert "committed" not in result.stdout
+ assert found(project) == before, "a refusal leaves the tree as it was"
+
+ dry = bump(project, scaffolded, advanced, "--dry-run")
+ assert dry.returncode == 2, dry.stderr + dry.stdout
+ assert "bump-leg-workflow-untracked" in dry.stderr
+ assert found(project) == before
+
+
+def test_an_untracked_draft_refuses_the_whole_bump_and_rewrites_nothing(
+ project, scaffolded, advanced):
+ """A TRACKED workflow that has to move does not move on its own once an
+ untracked one is in the way: half a bump is the failure the lockstep
+ commit exists to prevent."""
+ was, _ = reference_both_legs(project)
+ branched(project)
+ leave_untracked(project, "draft.yml", spec_ref(was))
+ before = found(project)
+
+ result = bump(project, scaffolded, advanced)
+ assert result.returncode == 2, result.stderr + result.stdout
+ assert "bump-leg-workflow-untracked" in result.stderr
+ assert ".github/workflows/legs.yml" not in result.stderr, (
+ "only the file git does not hold is named")
+ assert found(project) == before
+ assert was in (project / ".github" / "workflows" / "legs.yml").read_text()
+
+
+def test_every_untracked_draft_is_named_once_with_its_count(
+ project, scaffolded, advanced):
+ was = git("rev-parse", "HEAD:spec", cwd=project).stdout.strip()
+ branched(project)
+ leave_untracked(project, "a-draft.yml", spec_ref(was))
+ twice = project / ".github" / "workflows" / "b-draft.yml"
+ twice.write_text(
+ DRAFT.format(uses=spec_ref(was)) + f" e:\n uses: {spec_ref(was)}\n",
+ encoding="utf-8")
+
+ result = bump(project, scaffolded, advanced)
+ assert result.returncode == 2, result.stderr + result.stdout
+ assert "2 workflow file(s) that git does not track" in result.stderr
+ assert ".github/workflows/a-draft.yml 1 reference(s)" in result.stderr
+ assert ".github/workflows/b-draft.yml 2 reference(s)" in result.stderr
+
+
+def test_moving_the_draft_out_of_the_directory_lets_the_bump_through(
+ project, scaffolded, advanced, tmp_path):
+ """The first of the two exits the refusal names."""
+ was = git("rev-parse", "HEAD:spec", cwd=project).stdout.strip()
+ branched(project)
+ draft = leave_untracked(project, "draft.yml", spec_ref(was))
+ assert bump(project, scaffolded, advanced).returncode == 2
+
+ shutil.move(str(draft), str(tmp_path / "draft.yml"))
+ result = bump(project, scaffolded, advanced)
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert committed(project) == {"spec", "contracts/spec-pin.yaml"}
+ assert validate(project).returncode == 0
+
+
+def test_committing_the_draft_first_makes_it_move_with_the_bump(
+ project, scaffolded, advanced):
+ """The other exit: a COMMITTED workflow is a tracked file, so it is the
+ ordinary case — rewritten and staged with the pin, the dry run having
+ listed it."""
+ was = git("rev-parse", "HEAD:spec", cwd=project).stdout.strip()
+ branched(project)
+ draft = leave_untracked(project, "draft.yml", spec_ref(was))
+ assert bump(project, scaffolded, advanced).returncode == 2
+
+ git("add", "--", ".github/workflows/draft.yml", cwd=project)
+ git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm",
+ "Add the draft workflow", cwd=project)
+ dry = bump(project, scaffolded, advanced, "--dry-run")
+ assert dry.returncode == 0, dry.stderr + dry.stdout
+ assert ".github/workflows/draft.yml 1 reference(s)" in dry.stdout
+
+ result = bump(project, scaffolded, advanced)
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert committed(project) == {"spec", "contracts/spec-pin.yaml",
+ ".github/workflows/draft.yml"}
+ assert f"@{advanced}" in draft.read_text()
+ assert was not in draft.read_text()
+ assert validate(project).returncode == 0
+
+
+def test_an_untracked_workflow_with_nothing_to_move_is_left_alone(
+ project, scaffolded, advanced):
+ """Refusing is for a file the bump WOULD have rewritten. One that names no
+ leg of this project, or this leg already at `--to`, is not the bump's
+ business: not rewritten, not staged, not named, not refused — and the
+ project's own gate agrees with it afterwards."""
+ _, code = reference_both_legs(project)
+ branched(project)
+ other = leave_untracked(
+ project, "other.yml",
+ f"{ORG}/{PROJECT}-code/.github/workflows/y.yml@{code}")
+ already = leave_untracked(project, "already.yml", spec_ref(advanced))
+ kept = {path: path.read_bytes() for path in (other, already)}
+
+ result = bump(project, scaffolded, advanced)
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert committed(project) == {"spec", "contracts/spec-pin.yaml",
+ ".github/workflows/legs.yml"}
+ assert "1 reference(s) in 1 file(s)" in result.stdout
+ assert "other.yml" not in result.stdout + result.stderr
+ assert "already.yml" not in result.stdout + result.stderr
+ assert {path: path.read_bytes() for path in kept} == kept
+ status = git("status", "--porcelain", cwd=project).stdout.splitlines()
+ assert sorted(status) == ["?? .github/workflows/already.yml",
+ "?? .github/workflows/other.yml"]
+ assert validate(project).returncode == 0
+
+
+def test_an_ignored_workflow_naming_the_leg_is_refused_untouched(
+ project, scaffolded, advanced):
+ """Ignored is untracked: `git status` does not show it, but the validator
+ reads it, and the commit's `git add` would refuse it after the rewrite,
+ in git's words and not this tool's. It is named before anything is
+ written, and its bytes are exactly what they were."""
+ was = git("rev-parse", "HEAD:spec", cwd=project).stdout.strip()
+ branched(project)
+ ignore_locally(project, ".github/workflows/local-*.yml")
+ leave_untracked(project, "local-draft.yml", spec_ref(was))
+ before = found(project)
+ assert before["status"] == "", "an ignored file is not in the status"
+
+ result = bump(project, scaffolded, advanced)
+ assert result.returncode == 2, result.stderr + result.stdout
+ assert "bump-leg-workflow-untracked" in result.stderr
+ assert ".github/workflows/local-draft.yml 1 reference(s)" in result.stderr
+ assert found(project) == before
+
+
+def test_an_ignored_workflow_with_nothing_to_move_is_untouched(
+ project, scaffolded, advanced):
+ _, code = reference_both_legs(project)
+ branched(project)
+ ignore_locally(project, ".github/workflows/local-*.yml")
+ local = leave_untracked(
+ project, "local-other.yml",
+ f"{ORG}/{PROJECT}-code/.github/workflows/y.yml@{code}")
+ kept = local.read_bytes()
+
+ result = bump(project, scaffolded, advanced)
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert committed(project) == {"spec", "contracts/spec-pin.yaml",
+ ".github/workflows/legs.yml"}
+ assert local.read_bytes() == kept
+ assert "local-other.yml" not in result.stdout + result.stderr
+ assert git("status", "--porcelain", cwd=project).stdout == ""
+ assert validate(project).returncode == 0
+
+
# --- --dry-run --------------------------------------------------------------
def test_dry_run_prints_the_move_and_changes_nothing(project, scaffolded,
From 995fcb1a3a9270a34830d0a420fc580ba3e5676d Mon Sep 17 00:00:00 2001
From: Brett Heap <1513478+brettheap@users.noreply.github.com>
Date: Sun, 11 Oct 2026 19:18:46 +0000
Subject: [PATCH 2/2] bump-leg.py: the README and handbook say the refusal is
for an untracked or ignored workflow file the bump would rewrite, and a test
pins the NUL-separated read, so a tracked workflow with a non-ASCII name is
never refused as untracked (#207)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fix round 1 on #222, from the independent review.
- **The docs say what the code refuses.** README.md and `docs/handbook.html` said the tool refuses an untracked or ignored workflow file "that names the leg". It refuses only one it would REWRITE: one naming the leg at a commit other than `--to`. One already at `--to`, or naming no leg of the project, is left alone and unnamed (`test_an_untracked_workflow_with_nothing_to_move_is_left_alone`). Both now say "it would rewrite", which is shorter, so README.md stays at 1416 lines; the handbook footer's prose follows, and its `README.md blob` is re-taken from `git hash-object README.md`.
- **`-z` has a test.** `_tracked_workflows` reads `git ls-files -z` because git quotes a name with a byte outside ASCII in the plain listing (`core.quotePath`). `test_a_tracked_workflow_with_a_non_ascii_name_is_not_taken_for_untracked` commits `.github/workflows/légs.yml` naming the leg and asserts the bump exits 0, moves it and commits it; it also asserts that git does quote the name in the plain listing, so it cannot pass for want of a quoted name. Measured: with `-z` dropped and the split on `\n`, that test fails (`REFUSED bump-leg-workflow-untracked ... légs.yml 1 reference(s)`) and the other 32 in the file pass; on the head it passes.
- **Unchanged:** `scripts/bump-leg.py` is not touched.
- **Left out:** a tracked file renamed by case alone outside git, on a case-insensitive filesystem, is read as untracked (the tracked set is compared by exact spelling) and is refused with an exit, "commit it first", that cannot clear it. Asking git which files are untracked (`git ls-files -o -z`, which applies `core.ignorecase`) would fix it; named under Left out in the PR.
Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5
---
README.md | 4 ++--
docs/handbook.html | 10 +++++-----
tests/test_bump_leg.py | 39 +++++++++++++++++++++++++++++++++++++++
3 files changed, 46 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 4aef38fd..491b9f9b 100644
--- a/README.md
+++ b/README.md
@@ -981,8 +981,8 @@ included — then runs the project's own `validate-pins.py` and
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 that names the leg, a root standing
-on its own tracking branch (the next step is a pull request, never a push to the
+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
diff --git a/docs/handbook.html b/docs/handbook.html
index a8d944d0..fc8742a6 100644
--- a/docs/handbook.html
+++ b/docs/handbook.html
@@ -1311,8 +1311,8 @@ Advancing a leg
commits ONCE with explicit pathspecs.
Refuses a --to that is not exactly 40 hex; a
commit no branch of the leg’s remote contains (a pin the rest of the world cannot fetch
- is a root it cannot bootstrap); a dirty root; an untracked or ignored workflow file that names
- the leg; a root standing on its own tracking branch (the next step is a pull request, never a
+ 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.
@@ -1779,10 +1779,10 @@ For AI assistants
the preferred shape, where the advisory is said and where it is silent, the optional
single-repository.yaml, the doctor’s advisory row and the
assistant’s “what you do tell them” added (#163); bump-leg.py
- rewriting tracked workflow files only, and refusing an untracked or ignored one that names
- the leg, added (#207) — a page cannot name the commit that contains it, and the blob
+ rewriting tracked workflow files only, and refusing an untracked or ignored one it would
+ rewrite, added (#207) — a page cannot name the commit that contains it, and the blob
below is the fact a test checks.
- README.md blob 4aef38fdc041dfbda497481e4c0dce0df3906cf2. Every command, flag, exit code and
+ README.md blob 491b9f9b492e9f3ad144b94e336772bbb59f7b32. Every command, flag, exit code and
file name on this page is quoted from that README and from AGENTS.md.
diff --git a/tests/test_bump_leg.py b/tests/test_bump_leg.py
index 2e26c7b2..05d0a34d 100644
--- a/tests/test_bump_leg.py
+++ b/tests/test_bump_leg.py
@@ -472,6 +472,45 @@ def test_an_ignored_workflow_with_nothing_to_move_is_untouched(
assert validate(project).returncode == 0
+def test_a_tracked_workflow_with_a_non_ascii_name_is_not_taken_for_untracked(
+ project, scaffolded, advanced):
+ """The tracked set is read NUL-separated (`git ls-files -z`), and this is
+ what that buys. Asked in the plain form, git QUOTES a name holding a byte
+ outside ASCII (`core.quotePath`, on by default) as
+ `".github/workflows/l\\303\\251gs.yml"`, which is not the path the
+ directory walk yields; a tracked `l\u00e9gs.yml` that names the leg would
+ then be refused as untracked, and "commit it first" could never clear it,
+ because it is already committed. Non-ASCII is the one quoted character
+ every platform this suite runs on can spell in a file name.
+ """
+ was = git("rev-parse", "HEAD:spec", cwd=project).stdout.strip()
+ name = "l\u00e9gs.yml"
+ relative = f".github/workflows/{name}"
+ path = project / ".github" / "workflows" / name
+ path.write_text(DRAFT.format(uses=spec_ref(was)), encoding="utf-8")
+ git("add", "--", relative, cwd=project)
+ git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm",
+ "Add a workflow with an accented name", cwd=project)
+ branched(project)
+ plain = git("-c", "core.quotePath=true", "ls-files", "--",
+ ".github/workflows", cwd=project).stdout
+ assert "\\303\\251" in plain, (
+ "this test only means something while git quotes the name in the "
+ f"plain listing: {plain!r}")
+
+ result = bump(project, scaffolded, advanced)
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert "bump-leg-workflow-untracked" not in result.stderr
+ assert f"@{advanced}" in path.read_text(encoding="utf-8")
+ assert was not in path.read_text(encoding="utf-8")
+ # Bytes, not text: the names come back from `-z` as git stores them.
+ names = subprocess.run(
+ ["git", "show", "--name-only", "-z", "--format=", "HEAD"],
+ cwd=str(project), capture_output=True, check=True).stdout.split(b"\0")
+ assert relative.encode("utf-8") in names
+ assert validate(project).returncode == 0
+
+
# --- --dry-run --------------------------------------------------------------
def test_dry_run_prints_the_move_and_changes_nothing(project, scaffolded,