From 119d8c4c9b7f814e5fe2b854b9cf62fb79f8bc75 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 20:16:12 +0000 Subject: [PATCH] scaffold-project.py and family.py init: `--reuse-empty-repo` reuses a leg that is empty or whose one commit is the scaffold's own seed, and the ruleset hint offers that re-run, by an operator holding the bypass right, as its one exit, so a project a ruleset refused part-way is finished by one re-run, not refused `scaffold-remote-exists` for its own legs or sent to a pull request with no base (#462) A run whose push an organisation's ruleset refused left its repositories behind: all three empty when the first push, the spec leg's, was refused, and a leg at its one seed commit for each leg pushed before a later push was. `RULESET_HINT` offered two exits and neither finished. The pull request had no base, because the refused push was the first into a repository `gh repo create` had made without a README. The re-run was refused `scaffold-remote-exists` for the scaffold's own legs, with the flag or without it, and after an operator had seeded a leg by hand. Brett Heap ruled on 2026-10-12: "Offer only exits that work", "Re-run reuses empty legs" and "Accept the scaffold's own seed". - `--reuse-empty-repo` reuses each leg as it reuses the root: when it exists with zero commits, asked as before of `remote_is_empty` or `local_remote_is_empty`. - It also reuses a leg whose ONLY commit has exactly the tree this run would seed it with. `only_commit` requires one object name across every ref `git ls-remote` lists, fetches that commit alone (`--depth=1`) into a scratch bare repository, and requires that it has no parent. `seed_tree` builds the leg's template again in a scratch directory and takes `git write-tree`. The commit's message, author and date are not compared; the leg templates take no date, login or commit. - A leg reused at its seed is not given a new commit: `_take_held_seed` fetches that commit into the work clone and resets to it, so the pin and the gitlink name the commit the remote already has, and the push that follows changes nothing or creates the tracking branch at it (a seed pushed to `seed/scaffold` alone). - Any other leg with commits is still `scaffold-remote-exists`; its remedy says it holds commits other than the one seed this scaffold would push, and that there is no `--force`. An empty or seeded leg met without the flag is told to re-run with `--reuse-empty-repo`. - A reused leg keeps its GitHub visibility, and a run whose `--visibility` disagrees is warned, as a reused root already is. - `RULESET_HINT`, the scaffold's, and the new `FAMILY_RULESET_HINT`, `family.py init`'s, each offer ONE exit: an operator holding the bypass right re-runs the same command with `--reuse-empty-repo` and a new `--work-dir` (for the family, `--into` or `--work-dir`), pushing nothing first, since a repository pushed into by hand is no longer empty. The seed branch and the pull request are named only as what cannot finish the run. Both hints take the two fields `{repo}` and `{tracking}`. - AGENTS.md's ruleset rule, README's paragraph on a partway scaffold, the handbook's matching sections, both `--help` texts and docs/cli.md say the same. README and AGENTS.md keep their line counts. Unchanged: the root is reused only when empty, and a root with commits still names `adopt-project.py`; a leg that does not exist is created as before; nothing deletes, rewrites or force-pushes a remote; `family.py init --reuse-empty-repo` reuses an empty holder as before. adopt-project.py is byte-identical to the base: #445 gave adoption its own `ADOPT_RULESET_HINT`, whose one exit is a re-run once the legs its notice names are deleted, and tests/test_ruleset_hint_tracking_branch.py now pins that text beside the other two and takes each tool's exit through to a finished run. Left out: a family holder that holds its own seed is still refused, because the ruling covers the scaffold's legs; and adoption's hint still calls the pull request "the scaffold's other exit", which this change retires. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- README.md | 18 +- docs/cli.md | 18 +- docs/handbook.html | 33 +- scaffold-project.py | 307 +++++++++++--- scripts/family.py | 8 +- scripts/shape_materialize.py | 72 +++- tests/test_ruleset_hint_tracking_branch.py | 472 ++++++++++----------- tests/test_scaffold_pin_and_reuse.py | 18 +- tests/test_scaffold_reuse_seeded_legs.py | 359 ++++++++++++++++ 10 files changed, 935 insertions(+), 376 deletions(-) create mode 100644 tests/test_scaffold_reuse_seeded_legs.py diff --git a/AGENTS.md b/AGENTS.md index dea10dfc..85eb226c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,9 +35,9 @@ almost always an explicit `--org `. Pass `--allow-upstream-org` only if the human asks for it in those words. **Never bypass an organisation ruleset.** A ruleset that refuses a direct push -to `main` is doing its job; the exits are in the scaffold's own failure message -(seed by pull request, or ask an operator with the bypass right). `--admin` is -not a remedy. +to `main` is doing its job; the exit is in the tool's own failure message (for +a scaffold or `family.py init`: an operator holding the bypass right re-runs +it with `--reuse-empty-repo`). `--admin` is not a remedy. ## 1. Confirm what you are about to do diff --git a/README.md b/README.md index 3a4f26ad..b3f33df3 100644 --- a/README.md +++ b/README.md @@ -456,15 +456,15 @@ says), and runs `scripts/bootstrap.py` (what `make bootstrap` runs). > created. A failed push says so in as many words — *NOTHING has been rolled > back. What already exists is listed above; delete it by hand if you want a > clean re-run* — and it is no less true of a failure anywhere else. A second -> run is refused while the legs exist, and there is no `--force`, because -> re-running over a live project is not a scaffold: delete what was created — -> `gh repo delete /`, which needs the `delete_repo` scope, and -> `gh repo delete --help` names `gh auth refresh -s delete_repo` as how to get -> it — or scaffold under a different `--project`. `--reuse-empty-repo` covers -> exactly one case: an assembly ROOT that already exists with ZERO commits, -> which is a reserved name rather than a project. It applies to the root alone -> — an existing leg is refused whatever it holds, and a root that HAS commits -> is a live repository, which `adopt-project.py` converts in place instead. +> run is refused while they exist, and there is no `--force`: delete what was +> created — `gh repo delete /`, which needs the `delete_repo` scope +> (`gh auth refresh -s delete_repo`) — or scaffold under a different +> `--project`, or re-run with `--reuse-empty-repo`. That reuses each of the +> three that has ZERO commits, a reserved name rather than a project, and a +> leg whose one commit is the seed this scaffold pushes, so a run a ruleset +> refused is finished by an operator holding the bypass right re-running it. +> A leg with other commits is refused, and a root that HAS commits is a live +> repository, which `adopt-project.py` converts in place instead. > `--visibility internal` is the third value every tool here accepts; only a > GitHub Enterprise Cloud organisation offers it at all, and one that does not > is refused by GitHub rather than by this tool. diff --git a/docs/cli.md b/docs/cli.md index 79cdbec8..3e24cb45 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -255,10 +255,10 @@ options: --name NAME display name --visibility {private,public,internal} default: private for a fresh scaffold; with --reuse- - empty-repo and an existing assembly root, the two new - legs INHERIT that root's own visibility instead (a + empty-repo and an existing assembly root, the legs it + creates INHERIT that root's own visibility instead (a mismatch with an explicit --visibility is a warning, - not a refusal) + not a refusal, and so is a reused leg's) --pin-owner PIN_OWNER the organisation an UNQUALIFIED --pin name resolves under (default: opensoft, where every neutral @@ -302,10 +302,14 @@ options: create bare repositories here instead of calling `gh repo create` (the TEST path; no network) --no-push - --reuse-empty-repo if / already exists and has ZERO - commits, use it as the assembly root instead of - refusing. A repository with commits is still refused: - that is a live project, not a slot. + --reuse-empty-repo if /, -spec or -code + already exists and has ZERO commits, use it instead of + refusing, and a leg whose one commit is the seed this + scaffold pushes too: an operator holding the bypass + right finishes a run a ruleset refused by re-running + it with this. A root with commits, and a leg with any + other, is still refused: that is a live repository, + not a slot. --pin openProduct@COMMIT declare a neutral-product pin, e.g. --pin openGlass@<40 hex>. It writes diff --git a/docs/handbook.html b/docs/handbook.html index 9cf97a32..0d900474 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1000,17 +1000,17 @@

If it fails part-way

NOTHING has been rolled back. What already exists is listed above; delete it by hand if you want a clean re-run

— and it is no less true of a failure anywhere else.

    -
  • A second run is refused while the legs exist, and there is no --force, - because re-running over a live project is not a scaffold.
  • +
  • A second run is refused while they exist, and there is no --force.
  • Delete what was created with gh repo delete <org>/<name>, which - needs the delete_repo scope; gh repo delete --help names - gh auth refresh -s delete_repo as how to get it. Or scaffold under a different - --project.
  • -
  • --reuse-empty-repo covers exactly one case: an assembly ROOT that already - exists with ZERO commits, which is a reserved name rather than a project. It applies to the - root alone — an existing leg is refused whatever it holds, and a root that HAS commits - is a live repository, which adopt-project.py converts in - place instead.
  • + needs the delete_repo scope (gh auth refresh -s delete_repo). Or + scaffold under a different --project, or re-run with + --reuse-empty-repo. +
  • --reuse-empty-repo reuses each of the three that has ZERO commits, a + reserved name rather than a project, and a leg whose one commit is the seed this scaffold + pushes, so a run a ruleset refused is finished by an operator holding the bypass right + re-running it. A leg with other commits is refused, and a root that HAS commits is a live + repository, which adopt-project.py converts in place + instead.
  • --visibility internal is the third value every tool here accepts; only a GitHub Enterprise Cloud organisation offers it at all, and one that does not is refused by GitHub rather than by this tool.
  • @@ -1742,8 +1742,9 @@

    For AI assistants

    setup.sh refuses because the organisation you were given resolves to opensoft, STOP and relay what it said; the fix is almost always an explicit --org <their-org>.
  • Never bypass an organisation ruleset. A ruleset that refuses a direct - push to main is doing its job; the exits are in the scaffold’s own failure - message. --admin is not a remedy.
  • + push to main is doing its job; the exit is in the tool’s own failure + message (for a scaffold or family.py init: an operator holding the bypass right + re-runs it with --reuse-empty-repo). --admin is not a remedy.
  • Never reset, stash or force-push to make a refused make park or make resume succeed. A parked WIP commit is a real pushed commit, and resume refusing on divergence @@ -1802,9 +1803,11 @@

    For AI assistants

    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); 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 + branch and naming the branch the real run needs, added (#477); --reuse-empty-repo + reusing an empty leg and a leg holding the scaffold’s own seed, and the one ruleset exit + that re-run is, added (#462) — a page cannot name the commit that contains it, and the + blob below is the fact a test checks. + README.md blob b3f33df3ecdfacf256a549e242533aa921402cbf. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

    diff --git a/scaffold-project.py b/scaffold-project.py index 7a489bee..584290cf 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -49,7 +49,12 @@ IDEMPOTENCY. It refuses to write into a remote or a working directory that already exists. There is no --force: re-running over a live project is not a -scaffold, and the failure mode of getting it wrong is silent data loss. +scaffold, and the failure mode of getting it wrong is silent data loss. The +one exception is `--reuse-empty-repo`, which reuses each of the three remotes +while it has ZERO commits, and a leg whose one commit is the seed this +scaffold pushes (#462): a reserved name, or what a run whose later push an +organisation's ruleset refused left behind. It never deletes, rewrites or +force-pushes a remote. EXIT CODES: 0 done · 1 nothing (dry run prints and exits 0) · 2 refusal. """ @@ -308,9 +313,10 @@ def main(argv: list[str] | None = None) -> int: default=None, help="default: private for a fresh scaffold; with " "--reuse-empty-repo and an existing assembly " - "root, the two new legs INHERIT that root's own " - "visibility instead (a mismatch with an explicit " - "--visibility is a warning, not a refusal)") + "root, the legs it creates INHERIT that root's " + "own visibility instead (a mismatch with an " + "explicit --visibility is a warning, not a " + "refusal, and so is a reused leg's)") parser.add_argument("--pin-owner", default=NEUTRAL_PRODUCT_OWNER, help="the organisation an UNQUALIFIED --pin name " f"resolves under (default: {NEUTRAL_PRODUCT_OWNER}, " @@ -358,10 +364,15 @@ def main(argv: list[str] | None = None) -> int: "`gh repo create` (the TEST path; no network)") parser.add_argument("--no-push", action="store_true") parser.add_argument("--reuse-empty-repo", action="store_true", - help="if / already exists and has ZERO " - "commits, use it as the assembly root instead of " - "refusing. A repository with commits is still " - "refused: that is a live project, not a slot.") + help="if /, -spec or " + "-code already exists and has ZERO " + "commits, use it instead of refusing, and a leg " + "whose one commit is the seed this scaffold " + "pushes too: an operator holding the bypass " + "right finishes a run a ruleset refused by " + "re-running it with this. A root with commits, " + "and a leg with any other, is still refused: " + "that is a live repository, not a slot.") parser.add_argument("--pin", action="append", default=[], metavar="openProduct@COMMIT", help="declare a neutral-product pin, e.g. " @@ -446,47 +457,159 @@ def declared_pin_products(args) -> dict[str, None]: return dict.fromkeys(parse_pin(raw, args.pin_owner)[0] for raw in args.pin) +#: The two legs and the template each is seeded from: what `_seed_legs` +#: builds, and what `seed_tree` builds again to know a leg's seed (#462). +LEG_TEMPLATES = {"spec": "spec-root", "code": "code-root"} + + +def seed_tree(role: str, values: dict, tracking: str, scratch: Path) -> str: + """The tree of the one commit `_seed_legs` makes for the `role` leg + (#462), built as `_seed_legs` builds it and in `scratch`, so nothing is + written into the work directory before every remote has been asked. + + `copy_tree` from the same template with the same values, `git init` on + the tracking branch and `stage_written`: the index `_seed_legs` commits. + Git names a tree by its content, so a run given the same values builds + the same tree whoever commits it and on whatever day. The values a leg's + template takes are the project's names, its id, display name and topic, + the two leg paths and the root's clone URL; no date and no commit. + """ + work = scratch / "seed" + written = copy_tree(SHAPE_ROOT / "templates" / LEG_TEMPLATES[role], work, + values) + run(["git", "init", "-q", "-b", tracking, str(work)]) + stage_written(work, written) + return run(["git", "write-tree"], cwd=work).lower() + + +def only_commit(url: str, scratch: Path) -> tuple[str, str] | None: + """`(commit, tree)` when the repository at `url` holds exactly ONE commit + and every ref it has names it, else None (#462). + + `git ls-remote` lists every ref, `HEAD`, a `seed/*` branch and a pull + request's included, and one object name across all of them is the first + half. That object is then fetched ALONE (`--depth=1`) into a bare + repository in `scratch`, never the history behind it, and a commit with + no parent is the second half. So a repository somebody works in is told + apart by its listing or by one commit, and is never cloned whole to be + refused. The same question on disk and on GitHub: both are a git URL. + """ + tips = {line.split()[0].lower() + for line in run(["git", "ls-remote", url]).splitlines() + if line.strip()} + if len(tips) != 1: + return None + commit = tips.pop() + probe = scratch / "probe.git" + run(["git", "init", "-q", "--bare", "--template=", str(probe)]) + run(["git", "fetch", "-q", "--depth=1", url, commit], cwd=probe) + if run(["git", "cat-file", "-t", commit], cwd=probe) != "commit": + return None + header = run(["git", "cat-file", "commit", commit], + cwd=probe).split("\n\n", 1)[0].splitlines() + if any(line.startswith("parent ") for line in header): + return None + tree = next(line.split()[1] for line in header if line.startswith("tree ")) + return commit, tree.lower() + + +def _held_seed(role: str, url: str, values: dict, tracking: str, + scratch: Path) -> str | None: + """The commit a leg that is not empty holds, when it is the ONLY one and + its tree is the one this run would seed the leg with (#462); else None. + + That is a leg a run of this scaffold pushed before a later push was + refused, by an organisation's ruleset or anything else, or one an + operator seeded by pushing that run's own clone: what Brett Heap ruled + the re-run accepts on 2026-10-12, "Accept the scaffold's own seed". The + commit's message, author and date are not asked: two runs never make + the same commit, and they make the same tree. + """ + held = only_commit(url, scratch) + if held is None: + return None + commit, tree = held + return commit if tree == seed_tree(role, values, tracking, scratch) else None + + def _reusable_remotes(names: dict, urls: dict, repositories: dict, - local: bool, reuse_flag: bool) -> set[str]: - """Which remotes already exist, and whether that is allowed. - - THE ONE REPOSITORY THAT MAY ALREADY EXIST is the assembly root, and only - with `--reuse-empty-repo`, and only with ZERO commits. An organisation - that creates the repository first and asks for the shape second is the - ordinary case, not a mistake; an EMPTY repository is a name that has been - reserved, and reserving a name is not starting a project. A repository - with commits is refused: that is a live project, and `adopt-project.py` - is the tool for one of those. + local: bool, reuse_flag: bool, values: dict, + tracking: str) -> dict[str, str | None]: + """Which remotes already exist, and whether that is allowed: each one + `--reuse-empty-repo` reuses, mapped to the commit it holds, or to None + when it is empty. + + THE ASSEMBLY ROOT may already exist only with `--reuse-empty-repo`, and + only with ZERO commits. An organisation that creates the repository + first and asks for the shape second is the ordinary case, not a mistake; + an EMPTY repository is a name that has been reserved, and reserving a + name is not starting a project. A root with commits is refused: that is + a live project, and `adopt-project.py` is the tool for one of those. + + EACH LEG is reused on the same terms, and also when its one commit is + the seed this scaffold pushes (#462; Brett Heap, 2026-10-12, "Re-run + reuses empty legs" and "Accept the scaffold's own seed"). A run whose + push an organisation's ruleset refused left all three repositories + behind, empty or, for a leg pushed before the refusal, holding its seed, + and its re-run was refused as `scaffold-remote-exists` for them, with or + without the flag, so no exit finished it. Re-run with the flag by an + operator holding the bypass right, it reuses them and pushes the seed + itself. Any other leg with commits is still refused, and nothing here + deletes, rewrites or force-pushes a remote. "Empty" is asked as it + always was, of `remote_is_empty` or `local_remote_is_empty`; the seed is + asked only of a leg that is not empty. """ - reused: set[str] = set() - for role in names: - target = Path(urls[role]) if local else repositories[role] - empty = (local_remote_is_empty(target) if local - else remote_is_empty(target)) - if empty is None: - continue - if role == "assembly" and reuse_flag and empty: - reused.add(role) - print(f" reuse {target} (zero commits)") - continue - raise Refusal( - "scaffold-remote-exists", - f"{target} already exists" + ("" if local else " on GitHub") - + ("" if empty else " and has commits"), - _exists_remedy(role, empty, reuse_flag)) + reused: dict[str, str | None] = {} + with tempfile.TemporaryDirectory(prefix="openreposhape-seed-") as scratch: + for role in names: + target = Path(urls[role]) if local else repositories[role] + empty = (local_remote_is_empty(target) if local + else remote_is_empty(target)) + if empty is None: + continue + seed = (None if role == "assembly" or empty else + _held_seed(role, urls[role], values, tracking, + Path(scratch) / role)) + if reuse_flag and (empty or seed): + reused[role] = seed + print(f" reuse {target} " + ( + "(zero commits)" if empty else + f"(one commit, {seed[:12]}: the seed this scaffold " + "pushes)")) + continue + raise Refusal( + "scaffold-remote-exists", + f"{target} already exists" + ("" if local else " on GitHub") + + ("" if empty else " and has commits"), + _exists_remedy(role, empty, reuse_flag, seed is not None)) return reused -def _exists_remedy(role: str, empty: bool, reuse_flag: bool) -> str: +def _exists_remedy(role: str, empty: bool, reuse_flag: bool, + seeded: bool = False) -> str: """The remedy line for a remote that is already there. - Three different situations answer to three different exits, and a refusal - that named only one of them would send a human to the wrong one. + Each situation answers to its own exit, and a refusal that named only + one of them would send a human to the wrong one. `seeded` is a leg whose + one commit is this scaffold's seed (#462). """ if role != "assembly": - return ("Remediation: the two legs must not exist yet — they are " - "created and seeded here. Delete them, or scaffold under a " - "different --project. There is no --force.") + if empty or seeded: + return ("Remediation: " + + ("it has ZERO commits" if empty else + "its one commit is the seed this scaffold pushes, " + "left by a run that stopped after pushing it") + + f", so re-run with --reuse-empty-repo to use it as the " + f"{role} leg. A run an organisation's ruleset refused is " + "finished that way, by an operator holding the bypass " + "right.") + return ("Remediation: it holds commits other than the one seed this " + "scaffold would push to it, so it is not a slot this scaffold " + "may fill. Delete it, or scaffold under a different " + "--project. A leg a run of this scaffold seeded is reused " + "only when this run would seed it with the same tree, which " + "takes the same flags and the same openRepoShape. There is " + "no --force.") if empty and not reuse_flag: return ("Remediation: it has ZERO commits, so it is a reserved name " "rather than a live project — re-run with --reuse-empty-repo " @@ -1101,8 +1224,8 @@ def _print_plan_actions(args, topic: str, local: bool) -> None: print("remotes " + ("bare repositories on disk (no network)" if local else f"gh repo create --{args.visibility}")) if args.reuse_empty_repo: - print("reuse an EXISTING with zero commits is used as " - "the assembly root") + print("reuse each of the three that EXISTS with zero commits " + "is used, and a leg whose one commit is this scaffold's seed") print("push " + ("SKIPPED (--no-push)" if args.no_push else "yes")) print("topics " + ("skipped for local remotes" if local else f"gh repo edit --add-topic {topic}")) @@ -1124,21 +1247,25 @@ def _refuse_existing_trees(work_root: Path, names: dict) -> None: def _create_remotes(args, values: dict, repositories: dict, urls: dict, - local: bool, reused: set) -> None: + local: bool, reused: dict) -> None: """(k) The three remotes, spec and code first so a failure costs the cheapest thing. Split out of `_scaffold` for #138. It takes no `names`: every line here addresses a repository by the `/` the forge knows it as, or by its clone URL, and the bare - name is not one of the things it needs. + name is not one of the things it needs. A reused one (`reused`, from + `_reusable_remotes`) is not created, and keeps the visibility it has. """ display = values["PROJECT_NAME"] project_id = values["PROJECT_ID"] print("\ncreating remotes") for role in ("spec", "code", "assembly"): if role in reused: - print(f" reused {repositories[role]} (created by somebody else, " - "zero commits)") + print(f" reused {repositories[role]} (already there, " + + ("zero commits)" if reused[role] is None else + f"holding this scaffold's seed {reused[role][:12]})")) + if role != "assembly" and not local: + _reused_leg_visibility(args, repositories[role]) continue if local: run(["git", "init", "-q", "--bare", "-b", args.tracking_branch, @@ -1155,6 +1282,21 @@ def _create_remotes(args, values: dict, repositories: dict, urls: dict, print(f" gh {repositories[role]} ({args.visibility})") +def _reused_leg_visibility(args, repository: str) -> None: + """(k″) A reused leg keeps the visibility the run that created it gave it + (#462), and a re-run whose visibility disagrees is told so, as + `_leg_visibility` tells it about a reused root. Nothing here changes a + repository's visibility. Never asked of a `--local-remote-dir` leg, + which has none. + """ + seen = remote_visibility(repository) + if seen and seen != args.visibility: + print(f"WARNING {repository} is reused and is {seen} on GitHub, " + f"where this run's visibility is {args.visibility}. It keeps " + "the visibility it has: this run changes no repository's " + "visibility.", file=sys.stderr) + + def _private_leg_note(args, repositories: dict, local: bool) -> None: """(k′) A private or internal leg is unreadable to the `validate` workflow's default GITHUB_TOKEN — the defect on the first real adoption @@ -1176,37 +1318,61 @@ def _private_leg_note(args, repositories: dict, local: bool) -> None: print(hint) +def _take_held_seed(work: Path, url: str, seed: str, tracking: str) -> str: + """(l′) A leg whose remote already holds this scaffold's seed (#462): the + work clone, its files just written, is put AT that commit rather than + given a new one. + + A new commit would carry the same tree under another name, and pushing + it over the seed would be the rewrite a reuse never makes. So the commit + `_reusable_remotes` found is fetched, by name and alone, and the + tracking branch and the index are set to it; its tree is the one + `seed_tree` built from these values, which is what was just written. The + pin, the gitlink and the push that follows are then about the commit the + remote already has, and that push changes nothing where the tracking + branch already holds it. + """ + run(["git", "init", "-q", "-b", tracking, str(work)]) + run(["git", "remote", "add", "origin", url], cwd=work) + run(["git", "fetch", "-q", "origin", seed], cwd=work) + run(["git", "reset", "-q", seed], cwd=work) + return seed + + def _seed_legs(args, values: dict, names: dict, repositories: dict, - urls: dict, work_root: Path) -> dict | None: + urls: dict, work_root: Path, reused: dict) -> dict | None: """(l) The two legs: template, one commit, push. Returns the four template values the assembly root's own templates name — or None when a push was REFUSED, having already said so on stderr and - printed the ruleset hint. Split out of `_scaffold` for #138. + printed the ruleset hint. Split out of `_scaffold` for #138. A leg + `reused` maps to its seed is put at that commit (`_take_held_seed`). """ display = values["PROJECT_NAME"] shape_commit = values["SHAPE_COMMIT"] leg_commits: dict[str, str] = {} leg_digests: dict[str, str] = {} - for role, template in (("spec", "spec-root"), ("code", "code-root")): + for role, template in LEG_TEMPLATES.items(): work = work_root / names[role] written = copy_tree(SHAPE_ROOT / "templates" / template, work, values) - commit = git_init_commit( - work, f"Seed the {role} leg of {display}\n\n" - f"Scaffolded from {SHAPE_REPOSITORY} @ {shape_commit}.", - args.tracking_branch, written) + if reused.get(role): + commit = _take_held_seed(work, urls[role], reused[role], + args.tracking_branch) + else: + commit = git_init_commit( + work, f"Seed the {role} leg of {display}\n\n" + f"Scaffolded from {SHAPE_REPOSITORY} @ {shape_commit}.", + args.tracking_branch, written) + run(["git", "remote", "add", "origin", urls[role]], cwd=work) leg_commits[role] = commit.lower() leg_digests[role] = tree_digest(work, commit) - run(["git", "remote", "add", "origin", urls[role]], cwd=work) if not args.no_push: try: run(["git", "push", "-q", "-u", "origin", args.tracking_branch], cwd=work) except CommandFailed as exc: print(exc.loudly(f"pushing the {role} leg"), file=sys.stderr) - print(RULESET_HINT.format(work=work, repo=repositories[role], - role=role, - local=args.tracking_branch, + print(RULESET_HINT.format(repo=repositories[role], tracking=args.tracking_branch), file=sys.stderr) return None @@ -1322,10 +1488,7 @@ def _build_assembly_root(args, values: dict, names: dict, repositories: dict, cwd=assembly) except CommandFailed as exc: print(exc.loudly("pushing the assembly root"), file=sys.stderr) - print(RULESET_HINT.format(work=assembly, - repo=repositories["assembly"], - role="assembly", - local=args.tracking_branch, + print(RULESET_HINT.format(repo=repositories["assembly"], tracking=args.tracking_branch), file=sys.stderr) return False @@ -1503,22 +1666,26 @@ def _scaffold(args) -> int: else Path(tempfile.mkdtemp(prefix="openreposhape-"))) work_root.mkdir(parents=True, exist_ok=True) _refuse_existing_trees(work_root, names) - # THE ONE REPOSITORY THAT MAY ALREADY EXIST is the assembly root, and only - # with `--reuse-empty-repo`, and only with ZERO commits. An organisation - # that creates the repository first and asks for the shape second is the - # ordinary case, not a mistake; an EMPTY repository is a name that has been - # reserved, and reserving a name is not starting a project. A repository - # with commits is refused exactly as before: that is a live project, and - # `adopt-project.py` is the tool for one of those. + # A REPOSITORY MAY ALREADY EXIST only with `--reuse-empty-repo`, and only + # with ZERO commits, or, for a leg, with this scaffold's own seed as its + # one commit (#462). An organisation that creates the repository first and + # asks for the shape second is the ordinary case, not a mistake; an EMPTY + # repository is a name that has been reserved, and reserving a name is not + # starting a project. So is a run a ruleset refused part-way, re-run by an + # operator holding the bypass right. A root with commits is refused + # exactly as before: that is a live project, and `adopt-project.py` is the + # tool for one of those. See `_reusable_remotes`. if local: args.local_remote_dir.resolve().mkdir(parents=True, exist_ok=True) reused = _reusable_remotes(names, urls, repositories, local, - args.reuse_empty_repo) + args.reuse_empty_repo, values, + args.tracking_branch) _create_remotes(args, values, repositories, urls, local, reused) _private_leg_note(args, repositories, local) - leg_values = _seed_legs(args, values, names, repositories, urls, work_root) + leg_values = _seed_legs(args, values, names, repositories, urls, work_root, + reused) if leg_values is None: return 2 values.update(leg_values) diff --git a/scripts/family.py b/scripts/family.py index 98245ee9..7647d57b 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -136,7 +136,7 @@ load_yaml, recorded_gitlink, repo_basename, same_repository, tree_digest, ) from shape_materialize import ( # noqa: E402 - RULESET_HINT, SHAPE_REPOSITORY, CommandFailed, check_program, + FAMILY_RULESET_HINT, SHAPE_REPOSITORY, CommandFailed, check_program, commit_trailers, env_commit, escape_what_stdout_cannot_encode, force_stage, materialize_family_root, refuse_bad_branch, run, stage_written, trailer_line, write_lf, @@ -1012,10 +1012,8 @@ def _create_holder_worktree(args, work: Path, values: dict, shape_commit: str, cwd=work) except CommandFailed as exc: print(exc.loudly("pushing the family holder"), file=sys.stderr) - print(RULESET_HINT.format(work=work, repo=repository, - role="family", - local=args.tracking_branch, - tracking=args.tracking_branch), + print(FAMILY_RULESET_HINT.format(repo=repository, + tracking=args.tracking_branch), file=sys.stderr) return 2 return None diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 651c99b7..f0e73073 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -734,30 +734,60 @@ def loudly(self, what: str) -> str: ) -#: Two fields name a branch, and they are NOT the same thing (#324): -#: `{tracking}` is the run's tracking branch (`--tracking-branch`): the -#: branch of the REMOTE the refused push targeted, and so the base a -#: seeding pull request lands on. -#: `{local}` is the ref in `{work}` that the refused push carried. Each -#: caller -- the scaffold's legs and assembly root, the family holder -- -#: pushed the tracking branch BY NAME from a clone made on it, so each -#: passes the tracking branch for both; a push of `HEAD:` would name `HEAD`. -#: An adoption prints its own `ADOPT_RULESET_HINT` (`adopt-project.py`, -#: #177), which names the tracking branch the same way and no command. -#: Every caller passes both, after `checked_value` has admitted the tracking -#: branch, so each is a safe argument and neither is quoted here. A call that -#: leaves one out fails `tests/test_ruleset_hint_tracking_branch.py`. +#: What a scaffold whose push was refused prints, and `FAMILY_RULESET_HINT` +#: what `family.py init` prints (#462). ONE EXIT, AND IT WORKS: an operator +#: holding the bypass right re-runs the same command with `--reuse-empty-repo`, +#: whose own pushes are the seed. Brett Heap ruled it on 2026-10-12, "Offer +#: only exits that work": the pull request these offered as a second exit had +#: no base, because the refused push was the first into a repository `gh repo +#: create` had just made without a README, and the seed branch it pushed made +#: that repository non-empty, so the re-run refused it. The re-run was +#: refused for the scaffold's own legs too, until "Re-run reuses empty legs" +#: and "Accept the scaffold's own seed": `--reuse-empty-repo` reuses each of +#: the three repositories while it has zero commits, and a leg whose one +#: commit is the seed this scaffold pushes, which is a leg pushed before a +#: later push was refused (`scaffold-project.py`'s `_reusable_remotes`). +#: Pushing a seed first is what the hint must not invite: it makes a +#: repository the re-run refuses. +#: The PR route is still NAMED, as one that cannot finish, so nobody reaches +#: for it again. `{tracking}` is the run's tracking branch, the branch the +#: refused push targeted (#324), and `{repo}` the repository it was refused +#: at; every caller passes both, after `checked_value` has admitted the +#: tracking branch, so neither is quoted here. `adopt-project.py` prints its +#: own, `ADOPT_RULESET_HINT`: a re-run into its legs is `leg-remote-exists`. +#: A call that leaves a field out fails +#: `tests/test_ruleset_hint_tracking_branch.py`. RULESET_HINT = """ If the organisation applies a ruleset requiring changes to arrive by pull request, a direct push to the default branch is refused BY DESIGN and must not -be worked around. Two legitimate exits: - - (1) have an operator holding the bypass right seed the default branch once, - then re-run this scaffold; or - (2) push a seed BRANCH and open a pull request: - git -C {work} push -u origin {local}:seed/scaffold - gh pr create --repo {repo} --base {tracking} --head seed/scaffold \\ - --title 'Seed the {role} leg' --body 'Scaffolded shape.' +be worked around. ONE exit finishes this scaffold: an operator holding the +bypass right re-runs it, the same command with --reuse-empty-repo added (after +`--` through setup.sh) and a new --work-dir or none. That run's own pushes are +the seed, so push nothing first: it reuses each repository this run left +empty, and a leg whose one commit is the seed this scaffold pushes, and it +refuses a repository that holds anything else. A seed branch and a pull +request cannot finish it: {repo} has no {tracking} for that pull request to +target. + +NOTHING has been rolled back. What already exists is listed above; delete it +by hand if you want a clean re-run. +""" + +#: `family.py init`'s, for the same reason and with the same two fields +#: (#462). The holder is the one repository `init` makes, so its refused push +#: leaves it empty, and `family.py init --reuse-empty-repo` reuses an empty +#: holder and refuses one with commits. The re-run lands elsewhere: the first +#: run's holder clone is still where it landed, and `init` refuses a holder +#: directory that is not empty (`family-target-exists`). +FAMILY_RULESET_HINT = """ +If the organisation applies a ruleset requiring changes to arrive by pull +request, a direct push to the default branch is refused BY DESIGN and must not +be worked around. ONE exit finishes this family: an operator holding the +bypass right re-runs `family.py init`, the same command with +--reuse-empty-repo added and a new --into or --work-dir. That run's own push +is the seed, so push nothing first: it reuses the holder while it is empty, +and refuses it once it has a commit. A seed branch and a pull request cannot +finish it: {repo} has no {tracking} for that pull request to target. NOTHING has been rolled back. What already exists is listed above; delete it by hand if you want a clean re-run. diff --git a/tests/test_ruleset_hint_tracking_branch.py b/tests/test_ruleset_hint_tracking_branch.py index d8fca250..221222fd 100644 --- a/tests/test_ruleset_hint_tracking_branch.py +++ b/tests/test_ruleset_hint_tracking_branch.py @@ -1,49 +1,49 @@ # SPDX-License-Identifier: Apache-2.0 -"""The hint a refused push prints names the run's own tracking branch (#324). +"""The hint a refused push prints names the run's own tracking branch (#324), +and offers only the exit that finishes the run (#462). `RULESET_HINT` is what a person reads when an organisation's ruleset has just -refused a direct push, and it says to push a seed BRANCH and open a pull -request. It named `main` twice, as a constant: the local branch in `git -C - push -u origin main:seed/scaffold`, and `gh pr create --base main`. A -run made with `--tracking-branch trunk` creates its work clones with `git init --b trunk` and pushes `trunk`, so the first command died on `error: src refspec -main does not match any` and the second named a base the repository does not -have. - -TWO FIELDS NAME A BRANCH. `{tracking}` is the remote's branch, the base the pull -request lands on. `{local}` is the ref in the work clone that the refused push -CARRIED. The three places that print `RULESET_HINT` (the scaffold's legs, its -assembly root, the family holder) push the tracking branch by name from a clone -made on it, so each passes the tracking branch for both. - -AN ADOPTION PRINTS ITS OWN HINT, `ADOPT_RULESET_HINT` in `adopt-project.py` -(#177), from both of its legs. It prints no command, and names the tracking -branch once: as the branch the new leg has no pull request base on. The rule is -the same there, the run's own tracking branch and never `main`. Its prose is -#177's, pinned by #177's own tests; what is pinned here is the branch. +refused a direct push. It named `main` as a constant, so a run made with +`--tracking-branch trunk` was told about a branch it never had (#324). It also +offered two exits, and neither could finish (#462): a seed branch and a pull +request, whose base did not exist because the refused push was the first into +a repository `gh repo create` had just made without a README; and an +operator's seed of the default branch followed by a re-run, which the +scaffold refused as `scaffold-remote-exists` for its own legs, flag or no +flag. Brett Heap ruled on 2026-10-12: "Offer only exits that work", "Re-run +reuses empty legs" and "Accept the scaffold's own seed". + +ONE EXIT EACH, NAMED BY THE TOOL THAT PRINTS IT. The scaffold's +(`RULESET_HINT`) and `family.py init`'s (`FAMILY_RULESET_HINT`) are the same +command re-run with `--reuse-empty-repo` by an operator holding the bypass +right, whose own pushes are the seed. An adoption's (`ADOPT_RULESET_HINT`, in +`adopt-project.py`, #445's) is a re-run once what its notice names is cleared, +its two legs first: it has no such flag, and a re-run into them is +`leg-remote-exists`. Each names the pull +request only as what cannot finish it, with the run's repository and its +tracking branch: those are the two fields every hint takes. WHAT IS PINNED HERE - * the text itself: byte for byte what it was for `main` (the literal below is - the hint as it stood before this change, with the two branches left as - markers), and `trunk` in exactly the two places `main` stood, for `trunk`; - and the adoption's hint holds no `main` of its own; - * every one of the five places that prints a hint passes every field of it, - run through the real tools against local bare remotes that refuse like a - ruleset. For the scaffold and the family holder, the ref the hint names as - local is AT the commit the refused push carried: the remote's own hook - records it. For both adopt legs, the hint printed is the adoption's, for - that leg's repository and the run's tracking branch, from a source whose - own branch is `master` whatever the tracking branch is; - * where the printed push is runnable (the scaffold's legs and root, a family - holder), it is RUN, and the seed branch arrives. + * each text, byte for byte, as a literal below with the two values as + markers, for `main` and for `trunk`; + * every call that prints one passes both fields, and each tool prints its + own; + * run through the real tools against local bare remotes that refuse like a + ruleset, each of the six places prints its tool's text after a push to + the tracking branch was really refused; + * THE EXIT IT OFFERS IS TAKEN, with the ruleset off standing in for the + operator's bypass, and it FINISHES: the scaffold re-run pushes all three + repositories, a leg the first run had pushed kept at its commit; the + holder is pushed; the adoption verifies. THE RULESET IS A `pre-receive` HOOK in each bare remote the tool makes, put there by `GIT_TEMPLATE_DIR`, which every `git init` the tool runs honours. The hook refuses a push to anything but `seed/*` for the repositories named in `RULESET_REFUSES`: the hook runs in the remote's own directory, so the name -picks which of the three a run sees refuse. It is a shebang script, which Git -for Windows runs through its own `sh` (`test_update_shape_add_ignored.py` does -the same). No `gh`, no network and no real repository is involved. +picks which of the three a run sees refuse, and an empty `RULESET_REFUSES` is +the operator. It is a shebang script, which Git for Windows runs through its +own `sh` (`test_update_shape_add_ignored.py` does the same). No `gh`, no +network and no real repository is involved. """ from __future__ import annotations @@ -51,7 +51,6 @@ import ast import importlib.util import os -import re import shutil import string import subprocess @@ -61,19 +60,17 @@ import pytest from conftest import (ADOPT, ORG, PROJECT, REPO, SCAFFOLD, git, make_source_repo, - resolve, run_script, write_plan) + resolve, rmtree, run_script, write_plan) from test_adopt_spec_only import ANSWERS, EDITS, SPEC_ONLY_TREE from test_git_output_decoding import shipped_python sys.path.insert(0, str(REPO / "scripts")) -from shape_materialize import RULESET_HINT # noqa: E402 +from shape_materialize import FAMILY_RULESET_HINT, RULESET_HINT # noqa: E402 FAMILY = REPO / "scripts" / "family.py" def _adopt_module(): - """`adopt-project.py` as a module, for its hint: its name is not one - `import` takes.""" spec = importlib.util.spec_from_file_location("adopt_ruleset_hint", ADOPT) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) @@ -82,101 +79,115 @@ def _adopt_module(): ADOPT_RULESET_HINT = _adopt_module().ADOPT_RULESET_HINT -#: The hint as it stood on `main` before #324, from its first line to its last, -#: with the five values it carries as markers (the two branches were one `main` -#: then). Raw, because the second command ends in a backslash. It is a literal rather than a copy of the module's own -#: constant so that the test cannot agree with whatever the constant becomes. -HINT = r"""If the organisation applies a ruleset requiring changes to arrive by pull +#: Each tool's hint, from its first line to its last, with the two values it +#: carries as markers. Literals rather than copies of the constants, so that +#: the test cannot agree with whatever a constant becomes. +SCAFFOLD_TEXT = """If the organisation applies a ruleset requiring changes to arrive by pull request, a direct push to the default branch is refused BY DESIGN and must not -be worked around. Two legitimate exits: - - (1) have an operator holding the bypass right seed the default branch once, - then re-run this scaffold; or - (2) push a seed BRANCH and open a pull request: - git -C @WORK@ push -u origin @LOCAL@:seed/scaffold - gh pr create --repo @REPO@ --base @BRANCH@ --head seed/scaffold \ - --title 'Seed the @ROLE@ leg' --body 'Scaffolded shape.' +be worked around. ONE exit finishes this scaffold: an operator holding the +bypass right re-runs it, the same command with --reuse-empty-repo added (after +`--` through setup.sh) and a new --work-dir or none. That run's own pushes are +the seed, so push nothing first: it reuses each repository this run left +empty, and a leg whose one commit is the seed this scaffold pushes, and it +refuses a repository that holds anything else. A seed branch and a pull +request cannot finish it: @REPO@ has no @BRANCH@ for that pull request to +target. NOTHING has been rolled back. What already exists is listed above; delete it by hand if you want a clean re-run.""" +FAMILY_TEXT = """If the organisation applies a ruleset requiring changes to arrive by pull +request, a direct push to the default branch is refused BY DESIGN and must not +be worked around. ONE exit finishes this family: an operator holding the +bypass right re-runs `family.py init`, the same command with +--reuse-empty-repo added and a new --into or --work-dir. That run's own push +is the seed, so push nothing first: it reuses the holder while it is empty, +and refuses it once it has a commit. A seed branch and a pull request cannot +finish it: @REPO@ has no @BRANCH@ for that pull request to target. -def hint_for(work: str, repo: str, role: str, local: str, - tracking: str) -> str: - return (HINT.replace("@WORK@", work).replace("@REPO@", repo) - .replace("@ROLE@", role).replace("@LOCAL@", local) - .replace("@BRANCH@", tracking)) - - -# --- the text --------------------------------------------------------------- - -def test_the_hint_for_main_is_the_text_it_always_was(): - printed = RULESET_HINT.format(work="W", repo="o/r", role="spec", - local="main", tracking="main") - assert printed == "\n" + hint_for("W", "o/r", "spec", "main", "main") + "\n" - +NOTHING has been rolled back. What already exists is listed above; delete it +by hand if you want a clean re-run.""" -def test_the_hint_for_another_branch_changes_that_branch_and_nothing_else(): - printed = RULESET_HINT.format(work="W", repo="o/r", role="spec", - local="trunk", tracking="trunk") - assert printed == "\n" + hint_for("W", "o/r", "spec", "trunk", "trunk") + "\n" - assert "main" not in printed, "no `main` is left in the hint" - assert printed.count("trunk") == 2, "the local branch and `--base`" - # The exits are the ones AGENTS.md allows, and `--admin` is not one. - assert "--admin" not in printed - assert "bypass right" in printed - assert "pull request" in printed +ADOPT_TEXT = """If the organisation applies a ruleset requiring changes to arrive by pull +request, a direct push to the default branch is refused BY DESIGN and must not +be worked around. An adoption has ONE legitimate exit: have an operator +holding the bypass right re-run it, once what the notice below names is +cleared. The scaffold's other exit, a seed branch and a pull request, cannot +finish an adoption: @REPO@ has no @BRANCH@ for that pull request to target, +and nothing would run the rest of this adoption after it was merged. Under +such a ruleset, that bypass is the fix the notice below asks for, never a +change to the ruleset.""" + +#: Each constant, the name a tool prints it by, and its literal. +HINTS = { + "RULESET_HINT": (RULESET_HINT, SCAFFOLD_TEXT), + "FAMILY_RULESET_HINT": (FAMILY_RULESET_HINT, FAMILY_TEXT), + "ADOPT_RULESET_HINT": (ADOPT_RULESET_HINT, ADOPT_TEXT), +} +#: Which hint each shipped tool prints, and only that one. +PRINTED_BY = { + "scaffold-project.py": "RULESET_HINT", + "scripts/family.py": "FAMILY_RULESET_HINT", + "adopt-project.py": "ADOPT_RULESET_HINT", +} -def test_the_local_ref_and_the_base_are_two_fields(): - """A local ref that is not the tracking branch (`HEAD`, for a push of - `HEAD:`) stands in the push alone, and the pull request still lands on - the tracking branch.""" - printed = RULESET_HINT.format(work="W", repo="o/r", role="spec", - local="HEAD", tracking="trunk") - assert printed == "\n" + hint_for("W", "o/r", "spec", "HEAD", "trunk") + "\n" - assert "origin HEAD:seed/scaffold" in printed - assert "--base trunk --head seed/scaffold" in printed - assert "main" not in printed - assert printed.count("trunk") == 1 +def text_for(name: str, repo: str, tracking: str) -> str: + return (HINTS[name][1].replace("@REPO@", repo) + .replace("@BRANCH@", tracking)) -def fields_of(hint: str) -> set: - return {name for _, name, _, _ in string.Formatter().parse(hint) if name} +# --- the text --------------------------------------------------------------- -def test_the_hint_takes_five_fields_two_of_them_a_branch(): - assert fields_of(RULESET_HINT) == {"work", "repo", "role", "local", - "tracking"} +@pytest.mark.parametrize("name", HINTS) +def test_each_hint_for_main_is_its_text(name): + printed = HINTS[name][0].format(repo="o/r", tracking="main") + assert printed == "\n" + text_for(name, "o/r", "main") + "\n" -def test_the_adoption_hint_names_the_tracking_branch_and_no_main(): - """`ADOPT_RULESET_HINT` is #177's, and #324's rule holds of it: its one - branch is the `{tracking}` field, and no `main` is written into it, so - the text for another branch is the text for `main` with that branch in - the one place `main` stood.""" - assert "main" not in ADOPT_RULESET_HINT - assert "tracking" in fields_of(ADOPT_RULESET_HINT) - printed = ADOPT_RULESET_HINT.format(repo="o/r", tracking="trunk") - assert "main" not in printed +@pytest.mark.parametrize("name", HINTS) +def test_each_hint_for_another_branch_changes_that_branch_and_nothing_else( + name): + printed = HINTS[name][0].format(repo="o/r", tracking="trunk") + assert printed == "\n" + text_for(name, "o/r", "trunk") + "\n" + assert "main" not in printed, "no `main` is left in the hint" assert printed.count("trunk") == 1, "the base the pull request lacks" - assert ("o/r has no trunk for that pull request" - in " ".join(printed.split())) - assert (ADOPT_RULESET_HINT.format(repo="o/r", tracking="main") - == printed.replace("trunk", "main")) + # ONE exit, the one AGENTS.md allows, and `--admin` is not it. + assert "--admin" not in printed + assert "ONE" in printed and "bypass right" in printed + # The pull request is named only as what cannot finish the run: no + # command to push a seed branch or open one is printed (#462). + assert "seed/scaffold" not in printed + assert "gh pr create" not in printed + assert "push -u" not in printed + assert "Two legitimate exits" not in printed + + +def test_the_scaffold_and_the_family_re_run_with_the_flag_and_adoption_not(): + """`--reuse-empty-repo` is the scaffold's and `family.py init`'s; an + adoption has no such flag, so its hint must not send anybody to one.""" + scaffold = RULESET_HINT.format(repo="o/r", tracking="main") + family = FAMILY_RULESET_HINT.format(repo="o/r", tracking="main") + adoption = ADOPT_RULESET_HINT.format(repo="o/r", tracking="main") + assert "--reuse-empty-repo" in scaffold + assert "--reuse-empty-repo" in family and "family.py init" in family + assert "--reuse-empty-repo" not in adoption + assert "re-run this scaffold" not in family + adoption + + +@pytest.mark.parametrize("name", HINTS) +def test_each_hint_takes_two_fields_the_repository_and_the_branch(name): + fields = {field for _, field, _, _ in + string.Formatter().parse(HINTS[name][0]) if field} + assert fields == {"repo", "tracking"} # --- every caller passes it -------------------------------------------------- -#: Each hint a shipped tool prints, by the name it prints it by. -HINTS = {"RULESET_HINT": RULESET_HINT, - "ADOPT_RULESET_HINT": ADOPT_RULESET_HINT} - - def hint_calls() -> list[tuple[str, int, str, set]]: - """Every `RULESET_HINT.format(...)` and `ADOPT_RULESET_HINT.format(...)` - in the shipped tools, as `(file, line, the hint, the keywords it - names)`.""" + """Every `.format(...)` in the shipped tools, as `(file, line, the + hint's name, the keywords it names)`.""" found = [] for path in shipped_python(): tree = ast.parse(path.read_text(encoding="utf-8")) @@ -192,19 +203,22 @@ def hint_calls() -> list[tuple[str, int, str, set]]: return found -def test_no_call_that_prints_the_hint_leaves_a_branch_out(): +def test_each_tool_prints_its_own_hint_and_passes_both_fields(): calls = hint_calls() - assert {("scaffold-project.py", "RULESET_HINT"), - ("scripts/family.py", "RULESET_HINT"), - ("adopt-project.py", "ADOPT_RULESET_HINT")} <= { - (path, hint) for path, _, hint, _ in calls}, ( + assert set(PRINTED_BY) <= {path for path, _, _, _ in calls}, ( "the scan no longer finds the tools that print the hint, so it is not " "reading what it claims to") - offenders = [f"{path}:{line} names only {sorted(named)} of {hint}" - for path, line, hint, named in calls - if not fields_of(HINTS[hint]) <= named] + wrong = [f"{path}:{line} prints {name}" for path, line, name, _ in calls + if PRINTED_BY.get(path, name) != name] + assert not wrong, ( + "each tool names its own exit; another tool's hint sends the person " + "to a flag or a command this one does not have (#462):\n " + + "\n ".join(wrong)) + offenders = [f"{path}:{line} names only {sorted(named)}" + for path, line, _, named in calls + if named != {"repo", "tracking"}] assert not offenders, ( - "a hint's `.format` without `local=` or `tracking=` raises KeyError " + "a hint's `.format` without `repo=` or `tracking=` raises KeyError " "inside the failure path that exists to explain a refusal (#324):\n " + "\n ".join(offenders)) @@ -234,7 +248,9 @@ def test_no_call_that_prints_the_hint_leaves_a_branch_out(): def refusing(template, *repositories: str) -> dict: """The environment under which every bare repository a tool makes refuses - a direct push, for the repositories named (`Atlas-spec.git`).""" + a direct push, for the repositories named (`Atlas-spec.git`). Named none, + it is the operator holding the bypass right: the hooks are there and + refuse nothing.""" return {"GIT_TEMPLATE_DIR": str(template), "RULESET_REFUSES": " ".join(repositories)} @@ -283,74 +299,55 @@ def probe(*args, cwd=root): return template -# --- the five places that print one ------------------------------------------ +# --- the six places that print one ------------------------------------------ -#: What each caller refuses, with the role and repository its hint names. -#: `scaffold-spec` is the first leg's push, `scaffold-root` the assembly +#: What each caller refuses, with the hint it prints and the repository that +#: hint names. `scaffold-spec` is the first leg's push, `scaffold-code` the +#: second's after the first went through, `scaffold-root` the assembly #: root's after both legs went through, `family` the holder's. CASES = { - "scaffold-spec": ("Atlas-spec.git", "spec", f"{ORG}/Atlas-spec"), - "scaffold-root": ("Atlas.git", "assembly", f"{ORG}/Atlas"), - "family": ("Contoso.git", "family", f"{ORG}/Contoso"), - "adopt-extracted": ("Redwood-spec.git", "spec", f"{ORG}/Redwood-spec"), - "adopt-seeded": ("Redwood-code.git", "code", f"{ORG}/Redwood-code"), + "scaffold-spec": ("Atlas-spec.git", "RULESET_HINT", f"{ORG}/Atlas-spec"), + "scaffold-code": ("Atlas-code.git", "RULESET_HINT", f"{ORG}/Atlas-code"), + "scaffold-root": ("Atlas.git", "RULESET_HINT", f"{ORG}/Atlas"), + "family": ("Contoso.git", "FAMILY_RULESET_HINT", f"{ORG}/Contoso"), + "adopt-extracted": ("Redwood-spec.git", "ADOPT_RULESET_HINT", + f"{ORG}/Redwood-spec"), + "adopt-seeded": ("Redwood-code.git", "ADOPT_RULESET_HINT", + f"{ORG}/Redwood-code"), } -#: The adoption's two legs: they need `git filter-repo`, and they print -#: `ADOPT_RULESET_HINT`, which names no command (#177). -ADOPTION = ("adopt-extracted", "adopt-seeded") -#: The three that print `RULESET_HINT`, having pushed the tracking branch by -#: name. -PUSHED_BY_NAME = tuple(case for case in CASES if case not in ADOPTION) - - -def refused_run(case: str, tracking: str, tmp_path, template): - """Run the tool that prints `case`'s hint against remotes that refuse its - push, with `--tracking-branch `; returns the process, the - directory the remotes are in and the work directory. An adoption's source - is on `master`, whatever the tracking branch is.""" - refused = CASES[case][0] - env = refusing(template, refused) - remotes, work = tmp_path / "remotes", tmp_path / "work" - where = ("--local-remote-dir", str(remotes), "--work-dir", str(work)) +NEEDS_FILTER_REPO = ("adopt-extracted", "adopt-seeded") +SCAFFOLDED = (f"{PROJECT}.git", f"{PROJECT}-spec.git", f"{PROJECT}-code.git") + + +def run_the_tool(case: str, tracking: str, tmp_path, env: dict, work: Path, + *extra: str): + """Run the tool that prints `case`'s hint into `work`, against the + remotes in `tmp_path / "remotes"`, with `--tracking-branch `. + An adoption's source is on `master` and its plan is written once.""" + where = ("--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(work)) if case.startswith("scaffold"): - result = run_script( + return run_script( SCAFFOLD, "--org", ORG, "--project", PROJECT, "--elected-by", "Test Human", "--elected-on", "2026-09-02", - "--tracking-branch", tracking, *where, env=env) - elif case == "family": - result = run_script( + "--tracking-branch", tracking, *where, *extra, env=env) + if case == "family": + return run_script( FAMILY, "init", "--org", ORG, "--family", "Contoso", "--created-by", "Test Human", "--created-on", "2026-09-04", - "--tracking-branch", tracking, *where, env=env) - else: + "--tracking-branch", tracking, *where, *extra, env=env) + plan = tmp_path / "adoption-plan.yaml" + if not plan.exists(): source = make_source_repo(tmp_path / "IRRS", SPEC_ONLY_TREE, branch="master", edits=EDITS) - plan = tmp_path / "adoption-plan.yaml" written = write_plan(source, plan, project="Redwood", extra=("--tracking-branch", tracking, "--allow-empty-leg", "code")) assert written.returncode == 0, written.stderr + written.stdout for path, leg in ANSWERS: resolve(plan, path, leg) - result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", - "--allow-empty-leg", "code", *where, env=env) - return result, remotes, work - - -def printed_hint(result, case: str, tracking: str) -> tuple[str, str]: - """`(the hint block, the work clone it names)`, after checking the block - is the whole hint, with this case's role and repository, and the tracking - branch as both the local ref and the base.""" - assert result.returncode == 2, result.stderr + result.stdout - start = result.stderr.index("If the organisation applies a ruleset") - block = result.stderr[start:] - named = re.search(r"^ +git -C (.+) push -u origin \S+:seed/scaffold$", - block, re.M) - assert named, block - _, role, repository = CASES[case] - assert block.startswith(hint_for(named[1], repository, role, tracking, - tracking) + "\n"), block - return block, named[1] + return run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--allow-empty-leg", "code", *where, *extra, env=env) def refused_commit(bare: Path, tracking: str) -> str: @@ -363,74 +360,73 @@ def refused_commit(bare: Path, tracking: str) -> str: return commit -def commit_of(ref: str, work: str) -> str: - return git("rev-parse", "--verify", "-q", f"{ref}^{{commit}}", - cwd=Path(work), check=False).stdout.strip() - - -def nothing_reached(bare: Path, tracking: str) -> str: - """The refusal was real: nothing reached the tracking branch, and the one - push the remote refused went to it. Returns the commit it carried.""" - assert git("rev-parse", "--verify", "-q", f"refs/heads/{tracking}", - cwd=bare, check=False).returncode != 0 - return refused_commit(bare, tracking) +def tip(bare: Path, tracking: str) -> str: + return git("rev-parse", "--verify", "-q", f"refs/heads/{tracking}", + cwd=bare, check=False).stdout.strip() @pytest.mark.parametrize("tracking", ["trunk", "main"]) -@pytest.mark.parametrize("case", PUSHED_BY_NAME) -def test_the_hint_names_the_tracking_branch_the_run_was_made_with( +@pytest.mark.parametrize("case", CASES) +def test_the_hint_names_one_exit_and_taking_it_finishes_the_run( case, tracking, tmp_path, template): - result, remotes, _ = refused_run(case, tracking, tmp_path, template) - block, work = printed_hint(result, case, tracking) - assert f"origin {tracking}:seed/scaffold" in block - assert f"--base {tracking} --head seed/scaffold" in block - if tracking != "main": - # Not the work path: a directory may be called anything. - assert "main" not in block.replace(work, ""), ( - "the default branch is not this run's") - - bare = remotes / (CASES[case][0]) - pushed = nothing_reached(bare, tracking) - - # THE LOCAL REF THE HINT NAMES IS AT THE COMMIT THE REFUSED PUSH CARRIED, - # which is what advising it to be pushed to `seed/scaffold` means. - assert commit_of(tracking, work) == pushed, ( - f"`{tracking}` in the work clone is not what was pushed: " - + git("branch", "-a", "-v", cwd=Path(work)).stdout) - - # The printed command can run here, so run it: the seed branch arrives, - # at the commit the work clone holds. - ran = subprocess.run(["git", "-C", work, "push", "-u", "origin", - f"{tracking}:seed/scaffold"], - capture_output=True, text=True, check=False, - env={**os.environ, **refusing(template, - CASES[case][0])}) - assert ran.returncode == 0, ran.stderr - assert git("rev-parse", "refs/heads/seed/scaffold", - cwd=bare).stdout.strip() == pushed - - -@pytest.mark.parametrize("tracking", ["trunk", "main"]) -@pytest.mark.parametrize("case", ADOPTION) -def test_an_adoption_hint_names_the_tracking_branch_the_run_was_made_with( - case, tracking, tmp_path, template): - """Both adopt legs print the adoption's own hint (#177), for the leg's - repository and the run's tracking branch: never `main` for another - branch, and never the source's own branch, which is `master` here.""" - if shutil.which("git-filter-repo") is None: + if case in NEEDS_FILTER_REPO and shutil.which("git-filter-repo") is None: pytest.skip("git filter-repo is not installed: " "`pip install git-filter-repo`") - result, remotes, _ = refused_run(case, tracking, tmp_path, template) + refused, name, repository = CASES[case] + remotes = tmp_path / "remotes" + result = run_the_tool(case, tracking, tmp_path, + refusing(template, refused), tmp_path / "work") + + # The tool's own hint, for this repository and this tracking branch. assert result.returncode == 2, result.stderr + result.stdout - refused, _, repository = CASES[case] - hint = ADOPT_RULESET_HINT.format(repo=repository, - tracking=tracking).lstrip("\n") start = result.stderr.index("If the organisation applies a ruleset") - printed = result.stderr[start:start + len(hint)] - assert printed == hint, result.stderr - assert (f"{repository} has no {tracking} for that pull request" - in " ".join(printed.split())) - assert "master" not in printed, "the source's branch is not the leg's" + block = result.stderr[start:] + assert block.startswith(text_for(name, repository, tracking) + "\n"), block if tracking != "main": - assert "main" not in printed, "the default branch is not this run's" - nothing_reached(remotes / refused, tracking) + assert "main" not in block, "the default branch is not this run's" + + # The refusal was real: the one push the remote refused went to the + # tracking branch, and nothing reached it. + bare = remotes / refused + refused_commit(bare, tracking) + assert tip(bare, tracking) == "" + if case.startswith("scaffold"): + before = {repo: tip(remotes / repo, tracking) for repo in SCAFFOLDED} + + # THE EXIT, TAKEN: the ruleset off is the operator holding the bypass + # right, and the run is made again as the hint says. + operator = refusing(template) + if case.startswith("scaffold") or case == "family": + again = run_the_tool(case, tracking, tmp_path, operator, + tmp_path / "again", "--reuse-empty-repo") + else: + unfinished = run_the_tool(case, tracking, tmp_path, operator, + tmp_path / "unfinished") + assert unfinished.returncode == 2, unfinished.stdout + assert "already exists" in unfinished.stderr, ( + "the hint says a re-run is refused while the legs exist") + for leg in ("Redwood-spec.git", "Redwood-code.git"): + rmtree(remotes / leg) + again = run_the_tool(case, tracking, tmp_path, operator, + tmp_path / "again") + assert again.returncode == 0, again.stderr + again.stdout + + if case == "family": + assert tip(bare, tracking) + return + if case in NEEDS_FILTER_REPO: + assert "adoption verified" in again.stdout + return + # Every repository is pushed, each holds ONE commit, and a leg the first + # run had pushed is where that run left it: reused, never rewritten. + for repo in SCAFFOLDED: + assert tip(remotes / repo, tracking), repo + assert git("rev-list", "--count", "--all", + cwd=remotes / repo).stdout.strip() == "1", repo + if before[repo]: + assert tip(remotes / repo, tracking) == before[repo], repo + root = remotes / f"{PROJECT}.git" + for leg in ("spec", "code"): + mounted = git("rev-parse", f"refs/heads/{tracking}:{leg}", + cwd=root).stdout.strip() + assert mounted == tip(remotes / f"{PROJECT}-{leg}.git", tracking), leg diff --git a/tests/test_scaffold_pin_and_reuse.py b/tests/test_scaffold_pin_and_reuse.py index 5bf5f34d..fef0240d 100644 --- a/tests/test_scaffold_pin_and_reuse.py +++ b/tests/test_scaffold_pin_and_reuse.py @@ -93,19 +93,21 @@ def test_a_root_with_commits_is_refused_and_sent_to_adopt(tmp_path): assert "keeps its name, its identity and its history" in result.stderr -def test_an_existing_LEG_is_still_refused_outright(tmp_path): - """`--reuse-empty-repo` is about the assembly root alone. The legs are - created and seeded here, so one that exists is somebody else's.""" - remotes = tmp_path / "remotes" - remotes.mkdir(parents=True) - bare(remotes / f"{PROJECT}-spec.git") +def test_an_existing_LEG_with_commits_is_still_refused_outright(tmp_path): + """`--reuse-empty-repo` reuses an EMPTY leg, and one holding only this + scaffold's own seed (#462, `test_scaffold_reuse_seeded_legs.py`). A leg + holding anything else is somebody's repository, and is refused whatever + the flag says.""" + spec = seeded_bare(tmp_path, f"{PROJECT}-spec") result = run_script(SCAFFOLD, "--org", ORG, "--project", PROJECT, "--elected-by", "Test Human", "--reuse-empty-repo", - "--local-remote-dir", str(remotes), + "--local-remote-dir", str(spec.parent), "--work-dir", str(tmp_path / "work")) assert result.returncode == 2 assert "scaffold-remote-exists" in result.stderr - assert "the two legs must not exist yet" in result.stderr + assert "has commits" in result.stderr + assert "other than the one seed this scaffold would push" in result.stderr + assert not (spec.parent / f"{PROJECT}-code.git").exists() # --- --pin: a DECLARED descendant that carries legs ------------------------ diff --git a/tests/test_scaffold_reuse_seeded_legs.py b/tests/test_scaffold_reuse_seeded_legs.py new file mode 100644 index 00000000..e3635899 --- /dev/null +++ b/tests/test_scaffold_reuse_seeded_legs.py @@ -0,0 +1,359 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`--reuse-empty-repo` reuses the two legs as it reuses the root, and also a +leg whose one commit is the seed this scaffold pushes (#462). + +WHAT WAS WRONG. A run whose push an organisation's ruleset refused left its +three repositories behind: all three empty when the first push, the spec +leg's, was refused, and a leg at one commit for each leg pushed before a later +push was. The re-run was refused as `scaffold-remote-exists` for the legs with +`--reuse-empty-repo` and without it, before the ruleset came into it at all, +and so was the re-run after an operator seeded a leg by hand. So no exit +finished a new project in a ruleset-protected organisation. + +THE RULINGS (Brett Heap, 2026-10-12). "Re-run reuses empty legs": the flag +reuses each leg exactly as it reuses the root, when it exists with zero +commits. "Accept the scaffold's own seed": it also reuses a leg whose ONLY +commit has exactly the tree this scaffold would push to it. Any other leg +with commits is still refused, a root with commits still names +`adopt-project.py`, and nothing deletes, rewrites or force-pushes a remote. + +Everything here runs against bare repositories on disk (`--local-remote-dir`) +but the one test of a reused leg's GitHub visibility, which runs against a +fake `gh` on `PATH`, with `--no-push`. The ruleset is the `pre-receive` hook +`test_ruleset_hint_tracking_branch.py` builds, and an empty `RULESET_REFUSES` +is the operator holding the bypass right. +""" + +from __future__ import annotations + +import os +import stat +import subprocess +import textwrap +from pathlib import Path + +import pytest + +from conftest import ORG, PROJECT, SCAFFOLD, WINDOWS_SKIP, git, rmtree, run_script +from test_ruleset_hint_tracking_branch import refusing, template # noqa: F401 + +LEGS = ("spec", "code") +REPOS = {"assembly": f"{PROJECT}.git", "spec": f"{PROJECT}-spec.git", + "code": f"{PROJECT}-code.git"} + + +def scaffold(tmp_path, work: str, *extra: str, env: dict | None = None): + return run_script(SCAFFOLD, "--org", ORG, "--project", PROJECT, + "--elected-by", "Test Human", + "--elected-on", "2026-09-02", + "--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(tmp_path / work), *extra, env=env) + + +def bare(tmp_path, role: str) -> Path: + return tmp_path / "remotes" / REPOS[role] + + +def tip(tmp_path, role: str, ref: str = "refs/heads/main") -> str: + """The commit at `ref`, or "" when there is none or no repository.""" + if not bare(tmp_path, role).is_dir(): + return "" + return git("rev-parse", "--verify", "-q", ref, cwd=bare(tmp_path, role), + check=False).stdout.strip() + + +def commits(tmp_path, role: str) -> int: + return int(git("rev-list", "--count", "--all", + cwd=bare(tmp_path, role)).stdout.strip()) + + +def reserve(tmp_path, *roles: str) -> None: + """Each repository named, created EMPTY: a reserved name.""" + for role in roles: + subprocess.run(["git", "init", "-q", "--bare", "-b", "main", + str(bare(tmp_path, role))], check=True) + + +def assert_finished(tmp_path, result) -> None: + """The project is whole: each repository holds ONE commit on `main`, and + the root mounts each leg at that leg's commit.""" + assert result.returncode == 0, result.stderr + result.stdout + for role in REPOS: + assert tip(tmp_path, role), role + assert commits(tmp_path, role) == 1, role + for leg in LEGS: + mounted = git("rev-parse", f"refs/heads/main:{leg}", + cwd=bare(tmp_path, "assembly")).stdout.strip() + assert mounted == tip(tmp_path, leg), leg + + +def refused_at_the_root(tmp_path, template_dir): + """A first run whose ruleset covers the root alone: both legs pushed, one + commit each, and the root empty. Returns the two legs' commits.""" + first = scaffold(tmp_path, "w1", + env=refusing(template_dir, REPOS["assembly"])) + assert first.returncode == 2, first.stderr + first.stdout + assert "REFUSED pushing the assembly root" in first.stderr + seeds = {leg: tip(tmp_path, leg) for leg in LEGS} + assert all(seeds.values()) and commits(tmp_path, "assembly") == 0 + return seeds + + +# --- empty legs ------------------------------------------------------------- + +def test_three_empty_repositories_are_all_reused_with_the_flag(tmp_path): + """Ruling: "Re-run reuses empty legs". An organisation that reserved all + three names first, or a run a ruleset refused at its first push.""" + reserve(tmp_path, "assembly", "spec", "code") + result = scaffold(tmp_path, "work", "--reuse-empty-repo") + for role in REPOS: + assert f"reuse {bare(tmp_path, role)} (zero commits)" in result.stdout + assert_finished(tmp_path, result) + + +def test_an_empty_leg_without_the_flag_is_refused_naming_the_flag(tmp_path): + reserve(tmp_path, "spec") + result = scaffold(tmp_path, "work") + assert result.returncode == 2 + assert "scaffold-remote-exists" in result.stderr + assert "it has ZERO commits" in result.stderr + assert "re-run with --reuse-empty-repo to use it as the spec leg" in ( + result.stderr) + assert not bare(tmp_path, "code").exists(), "nothing was created" + + +def test_the_flag_without_the_operator_is_refused_again_and_changes_nothing( + tmp_path, template): # noqa: F811 + """The flag is not the bypass: under the ruleset the re-run is refused at + the same push, prints the same exit, and leaves the three as they were.""" + everything = refusing(template, *REPOS.values()) + assert scaffold(tmp_path, "w1", env=everything).returncode == 2 + again = scaffold(tmp_path, "w2", "--reuse-empty-repo", env=everything) + assert again.returncode == 2 + assert "REFUSED pushing the spec leg" in again.stderr + assert "ONE exit finishes this scaffold" in again.stderr + assert [commits(tmp_path, role) for role in REPOS] == [0, 0, 0] + + +# --- a leg holding the scaffold's own seed ---------------------------------- + +def test_legs_holding_the_seed_are_reused_at_their_commits( + tmp_path, template): # noqa: F811 + """Ruling: "Accept the scaffold's own seed". The root's push was refused + after both legs were pushed; the operator's re-run takes each leg's + commit as it is, pushes nothing over it, and mounts and pins it.""" + seeds = refused_at_the_root(tmp_path, template) + # A LATER DATE for anything the re-run commits. A commit made in the same + # second as the first run's, by the same identity, with the same message + # and tree, IS the first run's, so without it a re-run that made a new + # leg commit could pass "never rewritten" by the clock. + later = {"GIT_AUTHOR_DATE": "2031-01-01T00:00:00+0000", + "GIT_COMMITTER_DATE": "2031-01-01T00:00:00+0000"} + result = scaffold(tmp_path, "w2", "--reuse-empty-repo", + env={**refusing(template), **later}) + for leg in LEGS: + assert (f"reuse {bare(tmp_path, leg)} (one commit, {seeds[leg][:12]}: " + "the seed this scaffold pushes)") in result.stdout + assert_finished(tmp_path, result) + for leg in LEGS: + assert tip(tmp_path, leg) == seeds[leg], "a reused leg is never rewritten" + pin = git("show", f"main:contracts/{leg}-pin.yaml", + cwd=bare(tmp_path, "assembly")).stdout + assert seeds[leg] in pin, pin + + +def test_a_seed_pushed_to_a_seed_branch_alone_is_taken_too( + tmp_path, template): # noqa: F811 + """The exit the hint used to print: the seed pushed to `seed/scaffold`, + which the ruleset lets through, and `main` absent. Its one commit is the + seed, so the re-run takes it and creates `main` AT it: a new branch, not + a rewrite.""" + everything = refusing(template, *REPOS.values()) + assert scaffold(tmp_path, "w1", env=everything).returncode == 2 + pushed = subprocess.run( + ["git", "-C", str(tmp_path / "w1" / f"{PROJECT}-spec"), "push", "-q", + str(bare(tmp_path, "spec")), "main:refs/heads/seed/scaffold"], + capture_output=True, text=True, check=False, + env={**os.environ, **everything}) + assert pushed.returncode == 0, pushed.stderr + seed = tip(tmp_path, "spec", "refs/heads/seed/scaffold") + assert seed and not tip(tmp_path, "spec") + result = scaffold(tmp_path, "w2", "--reuse-empty-repo", + env=refusing(template)) + assert_finished(tmp_path, result) + assert tip(tmp_path, "spec") == seed + + +def test_a_leg_holding_the_seed_without_the_flag_names_the_flag( + tmp_path, template): # noqa: F811 + refused_at_the_root(tmp_path, template) + rmtree(bare(tmp_path, "assembly")) + result = scaffold(tmp_path, "w2", env=refusing(template)) + assert result.returncode == 2 + assert "scaffold-remote-exists" in result.stderr + assert "and has commits" in result.stderr + assert "its one commit is the seed this scaffold pushes" in result.stderr + assert "re-run with --reuse-empty-repo to use it as the spec leg" in ( + result.stderr) + assert not bare(tmp_path, "assembly").exists(), "nothing was created" + + +# --- what is still refused --------------------------------------------------- + +def assert_refused_as_not_the_seed(tmp_path, result, before: dict) -> None: + assert result.returncode == 2, result.stdout + assert "scaffold-remote-exists" in result.stderr + assert f"{bare(tmp_path, 'spec')} already exists and has commits" in ( + result.stderr) + assert "it holds commits other than the one seed this scaffold would " \ + "push to it" in result.stderr + assert "There is no --force." in result.stderr + assert {role: tip(tmp_path, role) for role in REPOS} == before, ( + "a refused re-run moves nothing") + + +def test_a_leg_with_a_commit_after_the_seed_is_refused( + tmp_path, template): # noqa: F811 + refused_at_the_root(tmp_path, template) + clone = tmp_path / "w1" / f"{PROJECT}-spec" + (clone / "extra.md").write_text("somebody's work\n", encoding="utf-8") + git("add", "extra.md", cwd=clone) + git("commit", "-q", "-m", "Somebody's work", cwd=clone) + git("push", "-q", "origin", "main", cwd=clone) + before = {role: tip(tmp_path, role) for role in REPOS} + result = scaffold(tmp_path, "w2", "--reuse-empty-repo", + env=refusing(template)) + assert_refused_as_not_the_seed(tmp_path, result, before) + + +def test_a_leg_whose_tip_has_the_seed_tree_over_another_commit_is_refused( + tmp_path, template): # noqa: F811 + """The seed must be the leg's ONLY commit, not a tip with its tree: an + empty commit on top of it is a second commit somebody made.""" + refused_at_the_root(tmp_path, template) + clone = tmp_path / "w1" / f"{PROJECT}-spec" + git("commit", "-q", "--allow-empty", "-m", "Somebody's empty commit", + cwd=clone) + git("push", "-q", "origin", "main", cwd=clone) + before = {role: tip(tmp_path, role) for role in REPOS} + result = scaffold(tmp_path, "w2", "--reuse-empty-repo", + env=refusing(template)) + assert_refused_as_not_the_seed(tmp_path, result, before) + + +def test_a_leg_with_another_commit_on_another_ref_is_refused( + tmp_path, template): # noqa: F811 + """EVERY ref counts, as `rev-list --all` counts every ref for "empty": the + seed on `main` and somebody's commit on another branch is two commits.""" + refused_at_the_root(tmp_path, template) + clone = tmp_path / "w1" / f"{PROJECT}-spec" + git("checkout", "-q", "-b", "theirs", cwd=clone) + (clone / "theirs.md").write_text("somebody's branch\n", encoding="utf-8") + git("add", "theirs.md", cwd=clone) + git("commit", "-q", "-m", "Somebody's branch", cwd=clone) + git("push", "-q", "origin", "theirs", cwd=clone) + before = {role: tip(tmp_path, role) for role in REPOS} + result = scaffold(tmp_path, "w2", "--reuse-empty-repo", + env=refusing(template)) + assert_refused_as_not_the_seed(tmp_path, result, before) + assert tip(tmp_path, "spec", "refs/heads/theirs"), "nothing was deleted" + + +def test_a_leg_this_run_would_seed_with_another_tree_is_refused( + tmp_path, template): # noqa: F811 + """The seed is the tree THIS run would push: a re-run with another + `--code-path` writes another spec leg (its AGENTS.md names the path), so + the first run's seed is not its seed.""" + refused_at_the_root(tmp_path, template) + before = {role: tip(tmp_path, role) for role in REPOS} + result = scaffold(tmp_path, "w2", "--reuse-empty-repo", "--code-path", + "src", env=refusing(template)) + assert_refused_as_not_the_seed(tmp_path, result, before) + + +def test_a_leg_somebody_else_made_is_refused(tmp_path): + """One commit, but not a tree this scaffold writes: somebody's + repository that happens to have the name.""" + work = tmp_path / "theirs" + git("init", "-q", "-b", "main", str(work), cwd=tmp_path) + (work / "README.md").write_text("# live\n", encoding="utf-8") + git("add", "README.md", cwd=work) + git("commit", "-q", "-m", "live", cwd=work) + reserve(tmp_path, "spec") + git("push", "-q", str(bare(tmp_path, "spec")), "main", cwd=work) + before = {role: tip(tmp_path, role) for role in REPOS} + result = scaffold(tmp_path, "work", "--reuse-empty-repo") + assert_refused_as_not_the_seed(tmp_path, result, before) + assert not bare(tmp_path, "code").exists(), "nothing was created" + + +# --- a reused leg's visibility (GitHub) ------------------------------------- + +#: A `gh` that knows three EMPTY repositories, each at the visibility +#: `FAKE_GH_REPOS` gives it (`org/name=VISIBILITY,...`), answers `repo create` +#: and `repo edit` with success, and logs every call. +FAKE_GH = textwrap.dedent("""\ + #!/usr/bin/env python3 + import json + import os + import sys + + argv = sys.argv[1:] + with open(os.environ["FAKE_GH_LOG"], "a", encoding="utf-8") as f: + f.write(" ".join(argv) + "\\n") + KNOWN = dict(pair.split("=") for pair in + os.environ["FAKE_GH_REPOS"].split(",")) + + if argv[:2] == ["repo", "view"]: + repo = argv[2] + if repo not in KNOWN: + print("GraphQL: Could not resolve to a Repository", file=sys.stderr) + sys.exit(1) + fields = argv[argv.index("--json") + 1].split(",") + out = {} + if "name" in fields: + out["name"] = repo.rsplit("/", 1)[-1] + if "visibility" in fields: + out["visibility"] = KNOWN[repo] + print(json.dumps(out)) + elif argv[:1] == ["api"] and argv[1].split("/commits")[0][6:] in KNOWN: + print("[]") + elif argv[:2] in (["repo", "create"], ["repo", "edit"]): + pass + else: + print(f"fake gh: unhandled {argv!r}", file=sys.stderr) + sys.exit(1) + """) + + +@WINDOWS_SKIP +def test_a_reused_leg_keeps_its_visibility_and_a_disagreement_is_said( + tmp_path): + """A reused leg is not created, so it keeps the visibility the run that + created it gave it; a run whose visibility disagrees is told so, as + `_leg_visibility` tells it about a reused root. The shebang `gh` is why + this is skipped on Windows, which does not run one; what it pins is a + `gh repo view` read, which is platform-blind.""" + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + gh = fakebin / "gh" + gh.write_text(FAKE_GH, encoding="utf-8") + gh.chmod(gh.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + log = tmp_path / "gh.log" + log.write_text("", encoding="utf-8") + env = {"PATH": f"{fakebin}{os.pathsep}{os.environ.get('PATH', '')}", + "FAKE_GH_LOG": str(log), + "FAKE_GH_REPOS": f"{ORG}/{PROJECT}=PRIVATE," + f"{ORG}/{PROJECT}-spec=PUBLIC," + f"{ORG}/{PROJECT}-code=PRIVATE"} + result = run_script(SCAFFOLD, "--org", ORG, "--project", PROJECT, + "--elected-by", "Test Human", "--reuse-empty-repo", + "--no-push", "--work-dir", str(tmp_path / "work"), + env=env) + assert result.returncode == 0, result.stderr + result.stdout + assert (f"WARNING {ORG}/{PROJECT}-spec is reused and is public on GitHub, " + "where this run's visibility is private.") in result.stderr + assert f"{ORG}/{PROJECT}-code is reused" not in result.stderr, ( + "a reused leg that agrees is not warned about") + assert "repo create" not in log.read_text(encoding="utf-8"), ( + "a reused repository is never created")