From b5e80d5d089e399c0d3b08ea05cc3ba15613e4af Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:09:57 +0000 Subject: [PATCH 1/2] `wip init` refuses an adopted checkout that is ahead of origin's `main` by any commit but its own seed, before a byte is written, and step 6a and step 8 read origin's `main` once between them (#64) Brett Heap ruled on 2026-10-02, verbatim "go with option 1 on #64". Step 6a asked about the worktree and the index and never about history, so a clean adopted checkout holding a person's own unpushed commits had them pushed to the `main` of their workspace repository by step 8's `push origin HEAD:main`. Step 6a now asks `git ls-remote origin main` before step 7 writes anything, lists `..HEAD` (all of HEAD where the remote has no `main`), and refuses with exit 2, naming every commit in that range, unless each one is this command's own seed. A seed is recognised by its subject, its `Written by` line, one parent or none, and a tree that differs from its parent's only at template paths, with at least one changed. The exemption keeps step 8's promise that a re-run after a ruleset-rejected push only has to push. The remote is asked once, and step 8 reads that same answer, so the range checked and the push decided are one reading of `main`. The line it reads is `refs/heads/main` by name. The first line of a `main` pattern can belong to a branch such as `backup/main`. Where the remote cannot be asked, the check is skipped with one line saying so and the push stays the probe, as the ruling keeps it. A range that cannot be listed, usually because `main` has moved and this checkout has not fetched it, is a refusal and never counts as an empty range. The recognizer passes `--no-show-signature` because `log.showSignature` writes the signature report on stdout ahead of the subject. Tests cover a person's commit ahead, a seed left by a refused push on an adopted `main`, a remote that cannot be asked, a commit that copies the seed's message (four ways), an empty `main`, a `main` this checkout has not fetched, a `backup/main` branch, and a signed seed. The README's seed paragraph gains one sentence, and its cap moves 472 -> 474 with a dated entry. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- openRepoTools | 208 ++++++++++++++++- tests/test_repo_hygiene.py | 13 +- tests/test_wip_init_command.py | 400 +++++++++++++++++++++++++++++++++ 4 files changed, 615 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4256a24..3b34278 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,9 @@ and runs `link-estates`. wrote, each by name, and never `git add -A`. A checkout it ADOPTS that carries modified, deleted or untracked files the template does not name is a refusal naming them, asked before a byte is written into it — the repository on the -other end of that push is where your unfinished work lives. +other end of that push is where your unfinished work lives. So is one ahead of +that repository's `main` by any commit but an earlier run's unpushed seed: it +names those commits, and pushing or discarding them is yours. **Idempotent**: a workstation that already has a workspace does nothing at all, decided by a file test and not a network call. Exit 0 is done or already done; diff --git a/openRepoTools b/openRepoTools index d7f07ab..a0d6176 100755 --- a/openRepoTools +++ b/openRepoTools @@ -1717,6 +1717,88 @@ wip_fetch_template_file() { return 1 } +# --- step 6a's history question: is this commit one `wip init` made? -------- +# +# STEP 6a LETS THROUGH, AHEAD OF origin's `main`, THIS COMMAND'S OWN SEED AND +# NOTHING ELSE (#64), so the answer here is ALL of what step 8's own commit is +# and never one part of it a person's commit could happen to share. Four tests, +# and a commit has to pass every one: +# +# * ONE PARENT OR NONE. Step 8 commits on top of HEAD — on an empty `main` +# that is a root commit — and never merges. It is asked first because the +# path test below cannot ask it: `diff-tree` prints NOTHING for a merge +# unless it is asked for a combined diff, so a merge would answer "no path +# outside the template" by naming no path at all. +# * THE SUBJECT step 8 writes, `Seed this workspace repository from …` — a +# PREFIX, because what follows it names the template's repository and +# directory, which are this file's constants and not a part of the promise. +# * THE BODY'S `Written by \`openRepoTools wip init\`` line, which is the +# sentence in that message that says whose commit it is. +# * AND A TREE THAT DIFFERS FROM ITS PARENT'S — the empty tree, for a root +# commit, which is what `--root` compares it with — AT ONE OR MORE TEMPLATE +# PATHS AND AT NO OTHER. Step 8 commits `"${written[@]}"` by pathspec, which +# is template paths and nothing else, and commits only where `diff --cached` +# says there is something to commit, so it has never made an empty one. +# +# A MESSAGE IS SOMETHING ANYBODY CAN TYPE, which is why the tree is asked as +# well as the words: a commit wearing the seed's subject and trailer and +# touching a path the template does not name is somebody's own work in a +# borrowed coat, and it is refused like any other. What this does NOT do is +# compare the bytes at those paths with the template: the seed it is looking +# at was written from the template at whatever commit THAT run pinned, which +# need not be this run's, and Brett Heap's ruling on #64 asks for the subject, +# the trailer and the tree — the parent count is the tree test made sound — +# and not the bytes. A person who writes this command's own message over +# their own edit of a template file has said, in so many words, that it is +# the seed. +# +# `--no-show-signature` ON EVERY `log` HERE, because `log.showSignature` is a +# person's own config and it prints the signature check's report ON STDOUT, +# ahead of the format: a seed this command committed on a machine with +# `commit.gpgSign` set would then read back with that report as its first +# line — `No signature`, `Good "git" signature …` — instead of its subject, +# and be refused as somebody else's. +# +# EVERY READ THAT FAILS IS A NO, so a commit this could not ask about is +# refused and never passed (#49's lesson, asked of history). Paths are +# compared as `diff-tree` prints them: one it had to C-quote is never one of +# `$WIP_TEMPLATE_FILES`, which are nine plain ASCII paths, so it is refused +# rather than misread. +wip_is_own_seed() { # — 0 only for a commit `wip init` itself made + local checkout="$1" commit="$2" parents subject body paths path rel known + parents="$(git -C "$checkout" log -1 --no-show-signature --format=%P "$commit" 2>/dev/null)" || + return 1 + case "$parents" in *' '*) return 1 ;; esac + subject="$(git -C "$checkout" log -1 --no-show-signature --format=%s "$commit" 2>/dev/null)" || + return 1 + case "$subject" in + 'Seed this workspace repository from '*) ;; + *) return 1 ;; + esac + body="$(git -C "$checkout" log -1 --no-show-signature --format=%b "$commit" 2>/dev/null)" || + return 1 + case "$body" in + *'Written by `openRepoTools wip init`'*) ;; + *) return 1 ;; + esac + paths="$(git -C "$checkout" diff-tree --root --no-commit-id --name-only -r "$commit" 2>/dev/null)" || + return 1 + [ -n "$paths" ] || return 1 + while IFS= read -r path; do + known=0 + for rel in "${WIP_TEMPLATE_FILES[@]}"; do + if [ "$path" = "$rel" ]; then + known=1 + break + fi + done + [ "$known" -eq 1 ] || return 1 + done <..HEAD` is a + # refusal naming all of them, seed or not, because what step 8 would push + # is the whole range and the person should see the whole range. + # + # THE REMOTE IS ASKED HERE, ONCE, AND STEP 8 READS THE SAME ANSWER. One + # `ls-remote`, one `remote_head`: the range this gate checked and the push + # step 8 decides on can never be two different readings of `main`. And the + # line read is `refs/heads/main` BY NAME, never the first line: a pattern + # of `main` matches every ref whose name ENDS in it, so a branch such as + # `refs/heads/backup/main` sorts before `refs/heads/main`, and the first + # line was then that branch's tip read as `main` — which would wave through + # every commit the branch carries, and tell step 8 that a `main` it never + # pushed to already had them. + # + # A REMOTE THAT COULD NOT BE ASKED IS STEP 8'S OWN ARM, KEPT, as the ruling + # keeps it: this check is skipped, said in one line, and the run goes on to + # the push, which is the probe. Not a refusal, because there is nothing + # true to refuse with — no `main` to name commits against — and because the + # remote that will not answer `ls-remote` is the remote step 8 pushes to, + # by the same URL, transport and credential, so the push almost always + # meets the same wall and step 8 stops the run before the pointer file is + # written. And NOT an empty `main` either: `remote_asked` is kept apart + # from `remote_head` because an unanswered question read as "no `main` + # yet" would put EVERY local commit ahead — refusing the very seed re-run + # the exemption above exists for. The window this leaves is a remote that + # refuses the question and takes the push a moment later, and that is the + # trade the ruling chose with this arm. + # + # A RANGE THAT CANNOT BE LISTED IS A REFUSAL, NEVER AN EMPTY ONE (#49's + # lesson, asked of history). Its usual cause is a `main` this checkout has + # never fetched — another workstation pushed to it since — and an empty + # list read from that failure is exactly what "nothing ahead, proceed" + # looks like. + local remote_head="" remote_asked=0 remote_refs history_range where ahead ahead_log commit + local seeds_ahead=0 + if remote_refs="$(git -C "$checkout" ls-remote origin main 2>/dev/null)"; then + remote_asked=1 + remote_head="$(printf '%s\n' "$remote_refs" | + awk '$2 == "refs/heads/main" && !seen { print $1; seen = 1 }')" + fi + if [ "$remote_asked" -eq 0 ]; then + say " could not ask origin where $target's main is, so the commits this checkout" + say " carries were not checked against it here; the push below is the probe." + elif git -C "$checkout" rev-parse -q --verify 'HEAD^{commit}' >/dev/null 2>&1; then + if [ -n "$remote_head" ]; then + history_range="$remote_head..HEAD" + where="$target's main ($(printf '%.7s' "$remote_head"))" + else + history_range="HEAD" + where="$target's main (it has none yet)" + fi + wip_quietly git -C "$checkout" rev-list "$history_range" || + die "could not list the commits $checkout carries that are not on $where, + so this run cannot tell whether any of them is somebody's unreviewed work — and a + history this command could not read is never one it may push. Most often that \`main\` + is a commit this checkout has never fetched, because another workstation pushed to it + since. NOTHING was written into that checkout on this run and nothing was staged. + Fetch it, merge or rebase this checkout's own commits onto origin/main, then re-run: + git -C $checkout fetch origin + openRepoTools wip init + What it said: +$WIP_LAST_ERR" + ahead="$(cat -- "$WIP_LAST_OUT")" + for commit in $ahead; do + if wip_is_own_seed "$checkout" "$commit"; then + seeds_ahead=$((seeds_ahead + 1)) + continue + fi + ahead_log="$(git -C "$checkout" log --oneline --no-decorate --no-color \ + --no-show-signature "$history_range" 2>/dev/null)" || ahead_log="$ahead" + die "$checkout carries commits that are not on $where, + and not every one of them is this command's own seed: +$(printf '%s\n' "$ahead_log" | sed -e 's/^/ /') + This command ends by pushing HEAD to $target's main, so it would publish every + commit above to your workspace repository unreviewed — and what it came to push + is $SHAPE_REPOSITORY's $WIP_TEMPLATE_DIR and nothing else. NOTHING was + written into that checkout on this run and nothing was staged. + Push those commits yourself, or discard them, then re-run: + openRepoTools wip init" + done + case "$seeds_ahead" in + 0) ;; + 1) say " one seed an earlier run committed is not on $target main yet; the push below carries it" ;; + *) say " $seeds_ahead seeds earlier runs committed are not on $target main yet; the push below carries them" ;; + esac + fi + # ---- step 7: SEED IT FROM THE TEMPLATE, additively. A repository that # already has a commit on `main` is NOT re-seeded: files the template would # place and that are already there are left exactly as they are, and the @@ -2411,15 +2603,15 @@ $WIP_LAST_ERR" # carries the seed", write the pointer file and exit 0 over an empty # `main`, and step 1's file test would then return on every later run. # That is the failure step 8 exists to prevent, met one step further on. - local local_head remote_head remote_refs + # + # AND THE REMOTE IS THE ONE STEP 6a ASKED (#64). `remote_head` is that one + # `ls-remote`'s answer, so the commits 6a checked against `main` and the + # push decided here are a single reading of it, never two. Where 6a could + # not ask, `remote_head` is empty and the push below runs: the push IS the + # probe, and a question we could not put to the remote is not an answer + # from it. + local local_head local_head="$(git -C "$checkout" rev-parse HEAD 2>/dev/null || :)" - if remote_refs="$(git -C "$checkout" ls-remote origin main 2>/dev/null)"; then - remote_head="$(printf '%s\n' "$remote_refs" | awk 'NR==1 {print $1}')" - else - # Could not ask. Fall through to the push: the push IS the probe, and a - # question we could not put to the remote is not an answer from it. - remote_head="" - fi if [ -z "$local_head" ]; then say " $checkout has no commit to push" elif [ -n "$remote_head" ] && [ "$local_head" = "$remote_head" ]; then diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 6135acd..cc71652 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -2127,9 +2127,20 @@ def test_readme_is_short_enough_to_be_read(): lines it buys in § "The lane tooling" and § "Install" fit inside 472. A cap is a budget and not a target: an entry that raised it by fifteen because fifteen lines were written would make the number mean nothing. + + 472 -> 474 on 2026-10-02, for #64 (Brett Heap's RULING of that day, + verbatim "go with option 1 on #64"). Two lines in `wip init`'s seed + paragraph, and both are a refusal a person MEETS rather than prose about + one: a checkout ADOPTED clean but ahead of its workspace repository's + `main` by any commit other than an earlier run's own unpushed seed is + refused before a byte is written into it, naming those commits, because + step 8's push would publish every one of them — and pushing or discarding + them is the person's. The paragraph already says why the worktree half of + the same rule exists; this is its history half, and a reader of one should + not meet the other as a surprise. Every dated entry above stays. """ lines = (REPO / "README.md").read_text().splitlines() - assert len(lines) <= 472, f"README.md is {len(lines)} lines; the cap is 472" + assert len(lines) <= 474, f"README.md is {len(lines)} lines; the cap is 474" #: A host-absolute path baked into a committed file (the estate's Rule 1): diff --git a/tests/test_wip_init_command.py b/tests/test_wip_init_command.py index a7b81f3..7e32cbb 100644 --- a/tests/test_wip_init_command.py +++ b/tests/test_wip_init_command.py @@ -30,6 +30,7 @@ from __future__ import annotations import os +import shutil import subprocess import textwrap from pathlib import Path @@ -478,6 +479,11 @@ def test_the_rerun_after_the_administrator_acts_has_only_to_push(tmp_path): second = run_wip(home, extra=env) assert second.returncode == 0, second.stdout + second.stderr + # A ROOT commit, so step 6a's history gate (#64) has to know the seed by + # comparing it with the EMPTY tree — `diff-tree` names no path at all for a + # root commit unless it is asked to. + assert ("one seed an earlier run committed is not on " + "opensoft/brettheap-wip main yet") in second.stdout, second.stdout assert "pushed the seed" in second.stdout assert remote_main(tmp_path) == seed, ( "the re-run did not push the commit the first run left behind") @@ -1533,6 +1539,400 @@ def test_a_skip_worktree_template_path_is_named_and_never_seeded(tmp_path): "the run stopped over a path it only had to name") +# --- step 6a's history half: nothing but the seed goes out ahead of main (#64) + +def git_in(where: Path, *args: str, stdin: str | None = None) -> str: + """`git -C …`, checked, its stdout stripped.""" + return subprocess.run(["git", "-C", str(where), *args], input=stdin, + capture_output=True, text=True, + check=True).stdout.strip() + + +def commit_of_their_own(checkout: Path, subject: str, + rel: str = "lanes/log/openRepoTools-3.md") -> str: + """A commit a person made in the workspace checkout and never pushed — + the thing #64 is about — and its sha. It touches a path the template does + not name, so nothing about it could be read as the seed.""" + path = checkout / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{subject}\n", encoding="utf-8") + git_in(checkout, "add", "--", rel) + git_in(checkout, "commit", "-q", "-m", subject) + return head_of(checkout) + + +def ruleset_on(tmp_path: Path, slug: str = "opensoft/brettheap-wip") -> Path: + """The organisation's PR-only ruleset, switched on for a repository that + ALREADY EXISTS — `fake_gh`'s own `install_ruleset`, line for line, for the + repository `refuse_push=True` cannot reach because an earlier run created + it. Returns the marker: unlinking it is the administrator's exclusion.""" + bare = tmp_path / "github" / f"{slug}.git" + hook = bare / "hooks" / "pre-receive" + hook.write_text("#!/bin/sh\n" + '[ -e "$(dirname "$0")/../ruleset-on" ] || exit 0\n' + 'echo "remote: refused by the PR-only ruleset" >&2\n' + "exit 1\n", encoding="utf-8") + hook.chmod(0o755) + marker = bare / "ruleset-on" + marker.touch() + return marker + + +def assert_refused_before_a_byte(result, checkout: Path, home: Path, + tmp_path: Path, head: str, main: str) -> None: + """What every history refusal owes: exit 2, step 6a's "nothing written, + nothing staged" in its own words and TRUE — the template path the run was + about to seed still absent, the index empty, HEAD where the person left it + — nothing pushed, and no pointer file.""" + assert result.returncode == 2, result.stdout + result.stderr + assert "NOTHING was written" in result.stderr and \ + "nothing was staged" in result.stderr, result.stderr + assert not (checkout / "handoffs" / "README.md").exists(), ( + "a template byte was written past the gate that refused") + assert staged_in(checkout) == "", ( + f"the refusal staged {staged_in(checkout)!r}") + assert head_of(checkout) == head, "a commit was made over the refusal" + assert remote_main(tmp_path) == main, "something was pushed to main" + assert not (home / ".agents" / "workspace.yaml").exists(), ( + "the pointer file was written by a run that refused") + + +def test_an_adopted_checkout_ahead_of_main_by_a_persons_commit_is_refused( + tmp_path): + """STEP 6a ASKED ABOUT THE WORKTREE AND THE INDEX, AND NEVER ABOUT + HISTORY (#64, Brett Heap's RULING of 2026-10-02, "go with option 1 on + #64"). + + Step 8 pushes `HEAD:main` wherever HEAD is not what origin's `main` + already carries, and HEAD carries every commit beneath it. So a CLEAN + adopted checkout holding a commit of the person's own that never left this + workstation passed step 6a, had the template seeded on top of it, and had + both pushed to the `main` of the repository that holds their unfinished + work — by a command that came to place a template. Measured before the + fix: exit 0, and the person's commit on the remote's `main`. + + The refusal names the commit — sha and subject, the way `git log + --oneline` prints it — and it comes before step 7 writes a byte: the one + template file this checkout is missing is still missing afterwards. + """ + home = tmp_path / "home" + env = fake_gh(tmp_path) + checkout, _, main = adopted_checkout_without(tmp_path, home, env) + ahead = commit_of_their_own(checkout, "Record a diary entry nobody pushed") + assert git_in(checkout, "status", "--porcelain") == "", ( + "the checkout is supposed to be clean by the worktree-and-index gate") + + result = run_wip(home, extra=env) + + assert_refused_before_a_byte(result, checkout, home, tmp_path, ahead, main) + assert "not every one of them is this command's own seed" in result.stderr, ( + result.stderr) + assert f"{ahead[:7]} Record a diary entry nobody pushed" in result.stderr, ( + result.stderr) + assert f"opensoft/brettheap-wip's main ({main[:7]})" in result.stderr + assert "workspace repository unreviewed" in result.stderr + assert "Push those commits yourself, or discard them" in result.stderr + + +def test_a_seed_a_refused_push_left_on_an_adopted_main_is_pushed_by_the_rerun( + tmp_path): + """THE EXEMPTION, AND THE PROMISE IT KEEPS: "a re-run after that has only + to push" (step 8's own refusal). + + A run whose push the PR-only ruleset refused leaves a clean worktree and + exactly one commit ahead of `main`: its seed. That commit is recognised — + its subject, its `Written by` line, and a tree that differs from its + parent's at a template path and nowhere else — so step 6a lets it through + and the re-run, once an administrator has acted, pushes it. This is the + ADOPTED shape, whose seed has a parent; the root-commit seed of a fresh + repository is `test_the_rerun_after_the_administrator_acts_has_only_to_push`. + """ + home = tmp_path / "home" + env = fake_gh(tmp_path) + checkout, _, main = adopted_checkout_without(tmp_path, home, env) + marker = ruleset_on(tmp_path) + + first = run_wip(home, extra=env) + assert first.returncode == 2, first.stdout + first.stderr + assert "THAT IS THE RULESET" in first.stderr, first.stderr + seed = head_of(checkout) + assert seed != main and git_in(checkout, "rev-parse", f"{seed}^") == main, ( + "the first run did not leave exactly one seed commit on top of main") + assert remote_main(tmp_path) == main + + marker.unlink() + second = run_wip(home, extra=env) + + assert second.returncode == 0, second.stdout + second.stderr + assert ("one seed an earlier run committed is not on " + "opensoft/brettheap-wip main yet") in second.stdout, second.stdout + assert "pushed the seed" in second.stdout + assert remote_main(tmp_path) == seed, ( + "the re-run did not push the seed the first run left behind") + assert (home / ".agents" / "workspace.yaml").is_file() + + +def test_a_remote_that_cannot_be_asked_skips_the_history_check_for_the_probe( + tmp_path): + """STEP 8'S OWN "COULD NOT ASK" ARM, KEPT AT STEP 6a, as the ruling keeps + it. + + A remote that does not answer `ls-remote` has no `main` to name commits + against, so the history check is SKIPPED — said in one line — and the + run goes on to the push, which is the probe: here it meets the same wall + and step 8 refuses, as it always did. It is not refused at step 6a, and it + is not read as an EMPTY `main` either, which would put every local commit + ahead and refuse a seed re-run. A person's own commit is ahead here so the + two readings come apart: the run reaching step 8's refusal, and not step + 6a's, is the skip. + """ + home = tmp_path / "home" + env = fake_gh(tmp_path) + checkout = adopted_checkout(tmp_path, home, env) + main = head_of(checkout) + commit_of_their_own(checkout, "Record a diary entry nobody pushed") + # Still `…/opensoft/brettheap-wip`, so step 6 adopts it as before, and + # nothing at all at that path to answer. + nowhere = tmp_path / "unreachable" / "opensoft" / "brettheap-wip.git" + git_in(checkout, "remote", "set-url", "origin", str(nowhere)) + + result = run_wip(home, extra=env) + + assert result.returncode == 2, result.stdout + result.stderr + assert ("could not ask origin where opensoft/brettheap-wip's main is" + in result.stdout), result.stdout + assert "this command's own seed" not in result.stderr, ( + f"an unanswered question was refused as history:\n{result.stderr}") + assert "THAT IS THE RULESET" in result.stderr, ( + f"the run did not reach step 8's push:\n{result.stderr}") + assert remote_main(tmp_path) == main + assert not (home / ".agents" / "workspace.yaml").exists() + + +@pytest.mark.parametrize("disguise", ["non-template-path", "no-trailer", + "merge", "empty"]) +def test_a_commit_wearing_the_seeds_message_is_not_taken_for_the_seed( + tmp_path, disguise): + """A MESSAGE IS SOMETHING ANYBODY CAN TYPE, so the seed is known by all of + what makes it step 8's own commit, and each of these lacks one part: + + * `non-template-path` — the seed's own message, byte for byte, on a commit + that touches a path the template does not name. This is the one the + ruling names: the TREE test is what gives the exemption its teeth. + * `no-trailer` — the seed's subject and the template's own bytes at a + template path, without the `Written by` line. + * `merge` — the seed's message on a merge whose tree adds a path the + template does not name. `diff-tree` names NO path for a merge unless asked + for a combined diff, so without the parent count a merge would pass the + tree test by naming nothing. + * `empty` — the seed's message on a commit that changes nothing. Step 8 + commits only where `diff --cached` has something, so it never makes one. + + Every one of them would have been pushed to `main` before the fix. + """ + home = tmp_path / "home" + env = fake_gh(tmp_path) + checkout, template_bytes, main = adopted_checkout_without(tmp_path, home, env) + # THE SEED'S OWN MESSAGE, read from the seed the first run made — the + # root commit — rather than retyped here, so it is step 8's to the byte. + root = git_in(checkout, "rev-list", "--max-parents=0", "HEAD") + seed_message = git_in(checkout, "log", "-1", "--format=%B", root) + "\n" + assert seed_message.startswith("Seed this workspace repository from ") + assert "Written by `openRepoTools wip init`" in seed_message + + notes = checkout / "notes.md" + if disguise == "non-template-path": + notes.write_text("somebody's own notes\n", encoding="utf-8") + git_in(checkout, "add", "--", "notes.md") + git_in(checkout, "commit", "-q", "-F", "-", stdin=seed_message) + elif disguise == "no-trailer": + readme = checkout / "handoffs" / "README.md" + readme.parent.mkdir(parents=True, exist_ok=True) + readme.write_text(template_bytes, encoding="utf-8") + git_in(checkout, "add", "--", "handoffs/README.md") + git_in(checkout, "commit", "-q", "-m", seed_message.splitlines()[0]) + elif disguise == "merge": + notes.write_text("somebody's own notes\n", encoding="utf-8") + git_in(checkout, "add", "--", "notes.md") + tree = git_in(checkout, "write-tree") + # Both parents are already on `main`, so the merge is the ONE commit + # ahead of it and nothing else in the range could be what refuses. + merge = git_in(checkout, "commit-tree", tree, "-p", "HEAD", + "-p", "HEAD~1", "-F", "-", stdin=seed_message) + git_in(checkout, "update-ref", "HEAD", merge) + assert git_in(checkout, "diff-tree", "--no-commit-id", "--name-only", + "-r", merge) == "", ( + "diff-tree was supposed to name no path for a merge") + else: + git_in(checkout, "commit", "-q", "--allow-empty", "-F", "-", + stdin=seed_message) + ahead = head_of(checkout) + assert git_in(checkout, "rev-list", f"{main}..HEAD") == ahead + assert git_in(checkout, "status", "--porcelain") == "" + + result = run_wip(home, extra=env) + + if disguise == "no-trailer": + # It put `handoffs/README.md` back into HEAD, so the "nothing written" + # check below has a different path to look at. + assert result.returncode == 2, result.stdout + result.stderr + assert "NOTHING was written" in result.stderr and \ + "nothing was staged" in result.stderr, result.stderr + assert head_of(checkout) == ahead and remote_main(tmp_path) == main + assert staged_in(checkout) == "" + assert not (home / ".agents" / "workspace.yaml").exists() + else: + assert_refused_before_a_byte(result, checkout, home, tmp_path, + ahead, main) + assert "not every one of them is this command's own seed" in result.stderr, ( + result.stderr) + assert f"{ahead[:7]} Seed this workspace repository from" in result.stderr, ( + result.stderr) + + +def git_at_least(major: int, minor: int) -> bool: + words = subprocess.run(["git", "--version"], capture_output=True, + text=True, check=False).stdout.split() + try: + have = tuple(int(n) for n in words[2].split(".")[:2]) + except (IndexError, ValueError): + return False + return have >= (major, minor) + + +@pytest.mark.skipif(shutil.which("ssh-keygen") is None or not git_at_least(2, 34), + reason="ssh commit signing needs ssh-keygen and git 2.34") +def test_a_signed_seed_is_still_known_where_log_shows_signatures(tmp_path): + """`log.showSignature` IS A PERSON'S OWN CONFIG, AND IT WRITES ON STDOUT. + + With it set, `git log --format=%s` prints the signature check's report + ahead of the subject, so a seed this command committed on a machine that + signs every commit read back as a commit whose first line is that report + — and step 6a would refuse the very seed its exemption is for. The + recognizer passes `--no-show-signature` on every `log` it runs. SSH + signing stands in for gpg because it needs no keyring: the report lands + on stdout either way. + """ + home = tmp_path / "home" + env = fake_gh(tmp_path) + checkout, _, main = adopted_checkout_without(tmp_path, home, env) + key = tmp_path / "signing-key" + subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", + str(key)], check=True, capture_output=True) + for name, value in (("gpg.format", "ssh"), + ("user.signingkey", f"{key}.pub"), + ("commit.gpgSign", "true"), + ("log.showSignature", "true")): + git_in(checkout, "config", name, value) + marker = ruleset_on(tmp_path) + + first = run_wip(home, extra=env) + assert first.returncode == 2, first.stdout + first.stderr + assert "THAT IS THE RULESET" in first.stderr, first.stderr + seed = head_of(checkout) + assert "BEGIN SSH SIGNATURE" in git_in(checkout, "cat-file", "commit", seed), ( + "the seed was supposed to be signed") + + marker.unlink() + second = run_wip(home, extra=env) + + assert second.returncode == 0, second.stdout + second.stderr + assert ("one seed an earlier run committed is not on " + "opensoft/brettheap-wip main yet") in second.stdout, second.stdout + assert remote_main(tmp_path) == seed + + +def test_an_empty_main_with_a_persons_local_commit_is_refused(tmp_path): + """WITH NO `main` ON THE REMOTE, EVERY LOCAL COMMIT IS AHEAD OF IT. + + A repository an administrator created and nobody seeded, cloned here, and + a commit of the person's own made in it first: step 8's push would make + that commit the first thing on the workspace repository's `main`. The + range is all of HEAD, the refusal says the remote has no `main` yet, and + not one of the nine template files is written. + """ + home = tmp_path / "home" + env = fake_gh(tmp_path, exists=True) + checkout = home / "projects" / "brettheap-wip" + checkout.parent.mkdir(parents=True) + bare = tmp_path / "github" / "opensoft" / "brettheap-wip.git" + subprocess.run(["git", "clone", "-q", str(bare), str(checkout)], check=True, + capture_output=True) + git_in(checkout, "config", "user.email", "wip@example.invalid") + git_in(checkout, "config", "user.name", "wip init tests") + ahead = commit_of_their_own(checkout, "Start my own notes first", + rel="notes.md") + + result = run_wip(home, extra=env) + + assert_refused_before_a_byte(result, checkout, home, tmp_path, ahead, "") + assert f"{ahead[:7]} Start my own notes first" in result.stderr, ( + result.stderr) + assert "opensoft/brettheap-wip's main (it has none yet)" in result.stderr, ( + result.stderr) + for rel in TEMPLATE_FILES: + assert not (checkout / rel).exists(), f"{rel} was written" + + +def test_a_main_this_checkout_never_fetched_is_refused_not_read_as_nothing_ahead( + tmp_path): + """A RANGE THAT CANNOT BE LISTED IS NEVER AN EMPTY ONE (#49's lesson, + asked of history). + + Another workstation pushed to `main` since this checkout last fetched, so + the commit `ls-remote` names is one this checkout does not have and + `rev-list ..HEAD` fails. An empty list read from that failure is + exactly what "nothing ahead" looks like; it is a refusal instead, with + git's own words and the fetch that settles it, before a byte is written. + """ + home = tmp_path / "home" + env = fake_gh(tmp_path) + checkout, _, main = adopted_checkout_without(tmp_path, home, env) + elsewhere = tmp_path / "another-workstation" + bare = tmp_path / "github" / "opensoft" / "brettheap-wip.git" + subprocess.run(["git", "clone", "-q", str(bare), str(elsewhere)], check=True) + git_in(elsewhere, "config", "user.email", "wip@example.invalid") + git_in(elsewhere, "config", "user.name", "wip init tests") + commit_of_their_own(elsewhere, "A register write from another workstation") + git_in(elsewhere, "push", "-q", "origin", "HEAD:main") + moved = remote_main(tmp_path) + assert moved != main + + result = run_wip(home, extra=env) + + assert_refused_before_a_byte(result, checkout, home, tmp_path, main, moved) + assert "could not list the commits" in result.stderr, result.stderr + assert f"opensoft/brettheap-wip's main ({moved[:7]})" in result.stderr + assert "What it said:" in result.stderr and " | " in result.stderr, ( + result.stderr) + assert f"git -C {checkout} fetch origin" in result.stderr, result.stderr + + +def test_a_branch_whose_name_ends_in_main_is_not_read_as_main(tmp_path): + """`ls-remote origin main` MATCHES EVERY REF WHOSE NAME ENDS IN `main`. + + Step 8 read the FIRST line, and `refs/heads/backup/main` sorts before + `refs/heads/main` — so a person who had pushed their commit to a backup + branch, and not to `main`, had that branch's tip read as `main`: step 6a's + range came out empty, and step 8 said `main` "already carries this + commit" and never pushed, writing the pointer file over a `main` that did + not. The one read both steps share is `refs/heads/main` by name. + """ + home = tmp_path / "home" + env = fake_gh(tmp_path) + checkout, _, main = adopted_checkout_without(tmp_path, home, env) + ahead = commit_of_their_own(checkout, "Record a diary entry kept on a backup") + git_in(checkout, "push", "-q", "origin", "HEAD:refs/heads/backup/main") + listed = git_in(checkout, "ls-remote", "origin", "main").splitlines() + assert listed[0].endswith("refs/heads/backup/main"), listed + + result = run_wip(home, extra=env) + + assert_refused_before_a_byte(result, checkout, home, tmp_path, ahead, main) + assert f"{ahead[:7]} Record a diary entry kept on a backup" in result.stderr + assert f"opensoft/brettheap-wip's main ({main[:7]})" in result.stderr + + # --- one answer to one question, across the seam between two toolsets ------- #: (name, the two yaml lines as a template, whether both sides must ACCEPT). From 73bba015fc94747517c2dc8527038df2cd8316ab Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:33:29 +0000 Subject: [PATCH 2/2] The history refusal keeps "NOTHING was written into that checkout on this run and nothing was staged." on one line, as every other step 6a refusal does Rewrapping the refusal to shorten its lines split that sentence between "NOTHING was" and "written". Every test of the history gate asserts the sentence's own words, so seven of them failed on CI's Linux job at b5e80d5 ("7 failed, 697 passed"), all on that one assertion. The sentence is now a line of its own, so a person reading the refusal, or a test of it, finds it whole. Co-Authored-By: Claude Opus 5.5 --- openRepoTools | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openRepoTools b/openRepoTools index a0d6176..eef3cca 100755 --- a/openRepoTools +++ b/openRepoTools @@ -2124,8 +2124,8 @@ $WIP_LAST_ERR" $(printf '%s\n' "$ahead_log" | sed -e 's/^/ /') This command ends by pushing HEAD to $target's main, so it would publish every commit above to your workspace repository unreviewed — and what it came to push - is $SHAPE_REPOSITORY's $WIP_TEMPLATE_DIR and nothing else. NOTHING was - written into that checkout on this run and nothing was staged. + is $SHAPE_REPOSITORY's $WIP_TEMPLATE_DIR and nothing else. + NOTHING was written into that checkout on this run and nothing was staged. Push those commits yourself, or discard them, then re-run: openRepoTools wip init" done