From c9ffdce04615a196ddea7ce8b154efa52c97adf7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:00:30 +0000 Subject: [PATCH 01/10] WIP: checkpoint before account swap; done: #194 proving test written, not yet run; left: run it, the commit_push fix, both RECOVERY texts, the reset warning, manual lines, PR done: tests/test_commit_push_published_edit_194.py, the proving test for opensoft/openRepoTools#194, written first and not yet run (the run queued behind another lane's full suite on the shared pytest lock and was stopped for the swap). Six cases: the issue's own sequence end to end (exit 3, the peer commits its file, the printed re-run must publish); a clean checkout with nothing ahead stays a no-op with no network call; a checkout behind origin stays a no-op; a publish past a peer's dirty file keeps the dirty-elsewhere guard; the conflict RECOVERY warns that reset --hard drops a peer's unpushed commit; refuse_dirty_checkout names the act to re-run. left: run the test on unmodified lanes-edit.sh to see it fail; the fix in commit_push (when nothing is staged and origin/..HEAD is non-empty, go on to the push loop); the two RECOVERY texts (commit_push's dirty-elsewhere exit 3, refuse_dirty_checkout); the reset --hard warning in the conflict RECOVERY; the matching manual lines in docs/README-lanes.md (about :95-97 and :198-211); two test nits (the pull net-call check should not depend on the -C path spelling; the warning/drops line order is provisional until the text is final); then green, push, PR, CI. Co-Authored-By: Claude Opus 5.5 --- tests/test_commit_push_published_edit_194.py | 316 +++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 tests/test_commit_push_published_edit_194.py diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py new file mode 100644 index 00000000..757e6c1c --- /dev/null +++ b/tests/test_commit_push_published_edit_194.py @@ -0,0 +1,316 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A register edit that is already a local commit is PUBLISHED by the re-run +its own RECOVERY prints (opensoft/openRepoTools#194). + +`commit_push` returned at *"nothing staged … no commit made"* BEFORE its push +loop. So when a peer's uncommitted file had blocked the rebase and the helper +had exited 3 with the edit as a local commit, the RECOVERY it printed — the +peer commits its file, then `LANES_LANE= lanes-edit.sh commit ""` — staged nothing, exited 0 and pushed nothing. The commit stayed local +until some later write from that checkout happened to carry it. + +Every run here is the real `lanes-edit.sh` in a sandbox: a bare origin, the +workstation's shared workspace checkout `wip` that `workspace.yaml` names, and +a second clone `peer` standing in for another workstation. `LANES_GIT` is a +wrapper that logs every NETWORK call the helper makes and runs the real git, +so a case can say whether the push loop was reached at all. Nothing reaches a +real remote. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP + +pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, + reason="lanes-edit.sh is a bash script"), + pytest.mark.skipif(shutil.which("git") is None, + reason="the register is a git repository"), + WINDOWS_SKIP] + +LANE = "repoP-1" +PEER_LANE = "repoQ-1" +REGISTER = "lanes/LANES.md" +PEER_HANDOFF = f"handoffs/{PEER_LANE}.md" +SUBJECT = "row rewritten by hand" + +#: The environment a case must not inherit from the host that runs it. +SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", + "FAKE_", "GIT_", "TMUX") +SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", + "XDG_CACHE_HOME"} + +NET_GIT = """#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$FAKE_NET_LOG" +exec git "$@" +""" + +FAKE_GH = """#!/usr/bin/env bash +printf 'FAKE gh REFUSED: %s -- this suite reaches no GitHub surface\\n' "$*" >&2 +exit 90 +""" + + +def _row(lane: str, state: str) -> str: + return (f"| `{lane}` | `{lane}-uuid` / window `{lane}` | Eagle / WSL2 / tester " + f"| 2026-10-11T00:00Z | — | handoffs/{lane}.md | {state} |") + + +class Box: + """One workstation's shared workspace checkout, its origin and a peer.""" + + def __init__(self, root: Path) -> None: + self.root = root + self.home = root / "home" + self.origin = root / "origin.git" + self.wip = self.home / "projects" / "wip" + self.peer = root / "peer" + self.net_log = root / "net.log" + fakebin = root / "fakebin" + for d in (self.home / ".agents", self.home / "projects", root / "tmp", fakebin): + d.mkdir(parents=True, exist_ok=True) + self.net_log.write_text("", encoding="utf-8") + for name, text in (("net-git", NET_GIT), ("gh", FAKE_GH)): + (fakebin / name).write_text(text, encoding="utf-8") + (fakebin / name).chmod(0o755) + + env = {k: v for k, v in os.environ.items() + if k not in SCRUBBED and not k.startswith(SCRUBBED_PREFIXES)} + env.update({ + "HOME": str(self.home), + "TMPDIR": str(root / "tmp"), + "XDG_CONFIG_HOME": str(self.home / ".config"), + "XDG_CACHE_HOME": str(self.home / ".cache"), + "PATH": f"{fakebin}{os.pathsep}{env.get('PATH', '')}", + "AGENT_PROTOCOL_ROOT": str(self.home / ".agents"), + "LANES_WORKSTATION": "Eagle", + "LANES_NO_GITHUB": "1", + "LANES_INDEX": "off", + "LANES_GIT": str(fakebin / "net-git"), + "FAKE_NET_LOG": str(self.net_log), + "GIT_AUTHOR_NAME": "commit_push tests", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "commit_push tests", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }) + self.env = env + + self.git("init", "-q", "--bare", "-b", "main", str(self.origin)) + self.git("clone", "-q", str(self.origin), str(self.wip)) + self.write(self.wip, REGISTER, + "# LANES.md -- the sandbox register\n\n" + "| lane | session id | workstation / env / user | started (UTC) " + "| objects owned | handoff path | state |\n" + "|---|---|---|---|---|---|---|\n" + f"{_row(LANE, 'LIVE · seeded')}\n" + f"{_row(PEER_LANE, 'LIVE · seeded')}\n") + self.write(self.wip, PEER_HANDOFF, f"# {PEER_LANE}\n") + self.git("-C", str(self.wip), "add", "--", REGISTER, PEER_HANDOFF) + self.git("-C", str(self.wip), "commit", "-q", "-m", "seed") + self.git("-C", str(self.wip), "push", "-q", "origin", "main") + self.git("clone", "-q", str(self.origin), str(self.peer)) + (self.home / ".agents" / "workspace.yaml").write_text( + f"repository: {self.origin}\npath: {self.wip}\n", encoding="utf-8") + + def git(self, *args: str) -> str: + return subprocess.run(["git", *args], env=self.env, check=True, + capture_output=True, text=True).stdout + + def wgit(self, *args: str) -> str: + return self.git("-C", str(self.wip), *args) + + def ogit(self, *args: str) -> str: + return self.git("--git-dir", str(self.origin), *args) + + @staticmethod + def write(checkout: Path, rel: str, text: str) -> None: + path = checkout / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def set_state(self, checkout: Path, lane: str, state: str) -> None: + """A hand edit of one row's state cell, the edit `commit` wraps.""" + path = checkout / REGISTER + text = path.read_text(encoding="utf-8") + old = _row(lane, "LIVE · seeded") + assert old in text, text + path.write_text(text.replace(old, _row(lane, state)), encoding="utf-8") + + def peer_pushes(self, rel: str, text: str, message: str) -> None: + """Origin moves on: a commit from another workstation.""" + self.write(self.peer, rel, text) + self.git("-C", str(self.peer), "add", "--", rel) + self.git("-C", str(self.peer), "commit", "-q", "-m", message) + self.git("-C", str(self.peer), "push", "-q", "origin", "main") + + def peer_edit_in_wip(self, line: str) -> None: + """A peer lane's handoff, mid-write and uncommitted in THIS checkout.""" + with (self.wip / PEER_HANDOFF).open("a", encoding="utf-8") as fh: + fh.write(line + "\n") + + def helper(self, *args: str, lane: str = LANE) -> subprocess.CompletedProcess: + self.net_log.write_text("", encoding="utf-8") + env = dict(self.env, LANES_LANE=lane) + return subprocess.run(["bash", str(REPO / "lanes-edit.sh"), *args], + env=env, cwd=str(self.wip), capture_output=True, + text=True, timeout=180, check=False) + + def net_calls(self) -> list[str]: + return self.net_log.read_text(encoding="utf-8").splitlines() + + def unpublished(self) -> list[str]: + return self.wgit("rev-list", "origin/main..HEAD").split() + + def origin_file(self, rel: str) -> str: + return self.ogit("show", f"main:{rel}") + + def origin_subjects(self) -> list[str]: + return self.ogit("log", "--format=%s", "main").splitlines() + + +@pytest.fixture +def box(tmp_path: Path) -> Box: + return Box(tmp_path) + + +def _line_of(stderr: str, needle: str) -> int: + for number, line in enumerate(stderr.splitlines()): + if needle in line: + return number + raise AssertionError(f"{needle!r} is on no line of:\n{stderr}") + + +def test_the_printed_recovery_publishes_the_edit_it_stranded(box): + """THE ISSUE'S OWN SEQUENCE, END TO END. Origin moves on; a peer's handoff + is uncommitted in the shared checkout; a register write commits, cannot + pull, has its push rejected six times and exits 3 with the RECOVERY. The + peer then commits its file exactly as that RECOVERY says, and the re-run it + names must PUBLISH: one copy of the edit on origin, the peer's commit with + it, and the commit origin already had kept — not exit 0 with nothing sent. + """ + box.peer_pushes("handoffs/repoR-1.md", "# repoR-1\n", + "handoff(repoR-1@Raven): another workstation's write") + box.peer_edit_in_wip("a peer's line, mid-write") + box.set_state(box.wip, LANE, "LIVE · edited by hand") + + stranded = box.helper("commit", SUBJECT) + assert stranded.returncode == 3, stranded.stderr + assert "a peer's uncommitted file blocks the rebase" in stranded.stderr + assert len(box.unpublished()) == 1, "the edit is a local commit and only that" + # The RECOVERY names the re-run and says what it does once nothing is staged. + assert f'LANES_LANE= lanes-edit.sh commit ""' in stranded.stderr + assert "pushes the local commits this lists" in stranded.stderr, stranded.stderr + + box.wgit("commit", "-q", "-m", f"handoff({PEER_LANE}@Eagle): the mid-write line", + "--", PEER_HANDOFF) + rerun = box.helper("commit", SUBJECT) + assert rerun.returncode == 0, rerun.stderr + assert "pushed origin/main" in rerun.stderr, rerun.stderr + assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") + assert _row(LANE, "LIVE · edited by hand") in box.origin_file(REGISTER) + assert "a peer's line, mid-write" in box.origin_file(PEER_HANDOFF) + assert box.origin_file("handoffs/repoR-1.md") == "# repoR-1\n" + subjects = box.origin_subjects() + assert sum(s.endswith(f"): {SUBJECT}") for s in subjects) == 1, subjects + assert len(subjects) == 4, ("seed, the other workstation's write, the edit and " + f"the peer's handoff — no commit of the re-run's own: {subjects}") + + +def test_a_clean_checkout_with_nothing_ahead_is_still_a_no_op(box): + """TODAY'S BEHAVIOUR, KEPT. Nothing staged and nothing on HEAD that origin + lacks: the same note, exit 0, no commit and no network call at all.""" + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + result = box.helper("commit", SUBJECT) + assert result.returncode == 0, result.stderr + assert f"nothing staged for {REGISTER} — no commit made" in result.stderr + assert "push" not in result.stderr, result.stderr + assert box.net_calls() == [] + assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + + +def test_a_checkout_behind_origin_with_nothing_of_its_own_is_still_a_no_op(box): + """Behind is not ahead: origin's newer commits are no local commit to + publish, so this is the no-op above and not a pull.""" + box.peer_pushes("handoffs/repoR-1.md", "# repoR-1\n", "a newer commit elsewhere") + box.wgit("fetch", "-q", "origin") + head = box.wgit("rev-parse", "HEAD") + result = box.helper("commit", SUBJECT) + assert result.returncode == 0, result.stderr + assert f"nothing staged for {REGISTER} — no commit made" in result.stderr + assert box.net_calls() == [] + assert box.wgit("rev-parse", "HEAD") == head + + +def test_publishing_past_a_peers_dirty_file_keeps_the_dirty_elsewhere_guard(box): + """The publish takes the SAME push loop a commit does, guard included: with + a peer's file still uncommitted it warns, names the file, skips the pull, + pushes straight out, and leaves that file exactly as it found it.""" + box.set_state(box.wip, LANE, "LIVE · edited by hand") + box.wgit("commit", "-q", "-m", f"LANES({LANE}@Eagle): {SUBJECT}", "--", REGISTER) + box.peer_edit_in_wip("a peer's line, still mid-write") + + result = box.helper("commit", SUBJECT) + assert result.returncode == 0, result.stderr + assert f"nothing staged for {REGISTER} — no commit made" in result.stderr + assert f"WARNING: this checkout has uncommitted changes to files OTHER than the register" in result.stderr + assert PEER_HANDOFF in result.stderr + assert "pushed origin/main (attempt 1, no pull" in result.stderr, result.stderr + assert not any(c.startswith(f"-C {box.wip} pull") for c in box.net_calls()), box.net_calls() + assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") + assert _row(LANE, "LIVE · edited by hand") in box.origin_file(REGISTER) + assert box.wgit("diff", "--name-only").split() == [PEER_HANDOFF] + assert "still mid-write" not in box.origin_file(PEER_HANDOFF) + + +def test_the_conflict_recovery_warns_that_a_reset_drops_a_peers_commits(box): + """The issue's near-loss of 2026-10-09: the conflict RECOVERY ended in + `reset --hard origin/main`, which in a shared checkout also drops a PEER's + unpushed commit. The RECOVERY lists every unpublished commit by content and + says, before the reset, that it must not run while any of them is not the + reader's own — and the helper itself leaves the peer's commit in place.""" + box.peer_edit_in_wip("a peer's handoff, committed and not yet pushed") + box.wgit("commit", "-q", "-m", f"handoff({PEER_LANE}@Eagle): unpushed", + "--", PEER_HANDOFF) + peer_commit = box.wgit("rev-parse", "HEAD").strip() + box.set_state(box.peer, LANE, "LIVE · the peer rewrote this row") + box.git("-C", str(box.peer), "commit", "-q", "-am", "the same row, elsewhere") + box.git("-C", str(box.peer), "push", "-q", "origin", "main") + box.set_state(box.wip, LANE, "LIVE · edited by hand") + + result = box.helper("commit", SUBJECT) + err = result.stderr + assert result.returncode == 3, err + assert "REBASE CONFLICT on origin/main" in err + assert box.wgit("merge-base", "--is-ancestor", peer_commit, "HEAD") == "" + + count = _line_of(err, "cherry origin/main") + listing = _line_of(err, "cherry -v origin/main") + warning = _line_of(err, "never reset, stash or overwrite a peer's work") + drops = _line_of(err, "drops EVERY '+' commit in this checkout") + reset = _line_of(err, "reset --hard origin/main") + assert count < listing < warning <= drops < reset, err + + # The listing the RECOVERY prints is a command that runs, and it names + # the peer's commit as one origin does not have. + command = err.splitlines()[listing].split("#", 1)[0].split() + listed = subprocess.run(command, env=box.env, capture_output=True, + text=True, check=True).stdout + assert f"+ {peer_commit}" in listed, listed + + +def test_the_dirty_checkout_refusal_names_the_act_to_re_run(box): + """`refuse_dirty_checkout`'s RECOVERY used to end in *"then re-run:"* + followed by two COMMIT commands, which read as if `lanes-edit.sh commit` + were the re-run. The authors commit their files; what is re-run is the + act that was refused, whose own push publishes their commits.""" + box.peer_edit_in_wip("a peer's line, mid-write") + result = box.helper("log", "NOTED", f"lane:{LANE}", "a note") + assert result.returncode == 2, result.stderr + assert "RECOVERY — whoever wrote those files commits them:" in result.stderr + assert "then re-run the refused write NOTED" in result.stderr, result.stderr From 9a29bbba1d636bb0e1a5dd1f439e65735e7a58fd Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 18:22:54 +0000 Subject: [PATCH 02/10] commit_push publishes the local commits a write with nothing staged finds ahead of origin, so the RECOVERY's re-run of `commit` pushes the edit it stranded (#194) `commit_push` returned at "nothing staged ... no commit made" before its push loop. An edit the loop could not get out (a peer's uncommitted file blocking the rebase, six rejected pushes) is already a local commit, so the RECOVERY's re-run of `lanes-edit.sh commit` staged nothing, exited 0 and pushed nothing. Now, with nothing staged, it reads origin/..HEAD: empty is today's no-op with no network call; non-empty takes the same push loop (the caller's lock, the dirty-elsewhere guard, the retries) without a commit of its own. The RECOVERY texts say what works: the dirty-elsewhere exit 3 says the re-run pushes the local commits; refuse_dirty_checkout separates what the authors commit from the refused act that is re-run; the conflict RECOVERY lists every unpublished commit by content (`cherry -v`) and warns that `reset --hard` drops a peer's unpushed commit along with the reader's own. docs/README-lanes.md says the same in both places. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 13 +++++-- lanes-edit.sh | 38 ++++++++++++++++---- tests/test_commit_push_published_edit_194.py | 11 +++--- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 7c0d16ca..cc0511ce 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -94,7 +94,9 @@ files — `pull --rebase` refuses outright on any unstaged tracked change — an pushes straight out instead, warning and naming the files. If the push is also rejected (the remote moved), it stops with exit 3, leaves your edit as a local commit, and prints the recovery: the owner of that file commits it, then you -re-run `lanes-edit.sh commit`. +re-run `lanes-edit.sh commit`. With nothing left to stage, that re-run makes no +commit of its own and pushes the local commits origin does not have yet, through +the same loop, guard and retries (#194); with none, it is the no-op it always was. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -205,7 +207,14 @@ dangling commit by rebasing it onto a newer base, which changes its hash, so actually published. `git -C cherry origin/main | grep -c '^+'` compares by patch instead and still finds it; 0 means it is already there, in which case STOP. Only once fetch succeeded AND this -prints something other than 0 do you read your edit back with +prints something other than 0 do you list WHOSE they are, with +`git -C cherry -v origin/main `: one `+` +line per local commit origin lacks, and in a shared checkout not every one is +yours. If any `+` line is not your own write, STOP and take it to the lane its +subject names. Never reset, stash or overwrite a peer's work: `reset --hard` +drops EVERY `+` commit in the checkout, theirs with yours, and origin holds no +copy of any of them (#194). Only when every `+` line is yours do you read your +edit back with `git -C diff origin/main..HEAD -- lanes/LANES.md`, then `git -C reset --hard origin/main` and redo it on top of the peer's version. diff --git a/lanes-edit.sh b/lanes-edit.sh index aadd2db1..b4acc853 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1797,9 +1797,10 @@ refuse_dirty_checkout() { *) note "an object-log write is ordered against other lanes by landing on main, and 'pull --rebase'" note "refuses on any unstaged tracked change — so this write cannot be ordered safely here." ;; esac - note "RECOVERY — whoever wrote those files commits them, then re-run:" + note "RECOVERY — whoever wrote those files commits them:" note " lanes/LANES.md LANES_LANE= lanes-edit.sh commit \"\"" note " anything else git -C $LANES_REPO commit -m \"handoff(@): \" -- " + note "then re-run the refused $rd_what: its own push publishes their commits with it (#194)." die "refusing to $rd_what on a checkout that cannot be rebased" 2 } @@ -2019,11 +2020,29 @@ commit_push() { [ -z "$cp_byte" ] || die "$(bytecode_refusal_text "$cp_byte")" 2 git -C "$LANES_REPO" -c "$CP_EXACT" add -- ${cp_add[@]+"${cp_add[@]}"} || die "git add failed" 6 fi + # #194 — NOTHING STAGED IS NOT NOTHING TO PUBLISH. A write the push loop + # below could not get out (a peer's uncommitted file blocking the rebase, six + # rejected pushes) leaves its edit as a LOCAL commit, and the RECOVERY printed + # for it is to re-run `commit` once the way is clear. Returning here sent that + # re-run home with exit 0 and the commit still local. So a write with nothing + # of its own to commit still takes the push loop — under the caller's lock, + # with the dirty-elsewhere guard and the retries — whenever HEAD holds commits + # `origin/$LANES_BRANCH` does not, as of the last fetch (no such ref reads as + # none). With none it is the no-op it always was, and reaches no network. + cp_publish=0 if git -C "$LANES_REPO" -c "$CP_EXACT" diff --cached --quiet -- "${CP_PATHS[@]}"; then - note "nothing staged for ${CP_PATHS[*]} — no commit made" - return 0 + cp_ahead="$(git -C "$LANES_REPO" --no-pager log --oneline "origin/$LANES_BRANCH..HEAD" 2>/dev/null || :)" + if [ -z "$cp_ahead" ]; then + note "nothing staged for ${CP_PATHS[*]} — no commit made" + return 0 + fi + note "nothing staged for ${CP_PATHS[*]} — no commit made; pushing the local commits origin/$LANES_BRANCH does not have:" + printf '%s\n' "$cp_ahead" | head -n 10 >&2 || : + cp_publish=1 fi - if [ -n "$cp_ren" ]; then + if [ "$cp_publish" = 1 ]; then + : # nothing of this write's own to commit: straight on to the push + elif [ -n "$cp_ren" ]; then cp_staged="$(git -C "$LANES_REPO" -c "$CP_EXACT" diff --cached --name-only)" cp_extra="" while IFS= read -r cp_s; do @@ -2047,7 +2066,7 @@ EOF else git -C "$LANES_REPO" -c "$CP_EXACT" commit -q -m "$msg" -- "${CP_PATHS[@]}" || die "git commit failed" 6 fi - note "committed: $msg" + [ "$cp_publish" = 1 ] || note "committed: $msg" if ! remote_has_branch; then if ! git_net -C "$LANES_REPO" push -q -u origin "$LANES_BRANCH"; then [ "$GIT_TIMED_OUT" = 1 ] && git_timeout_die "git push -u origin $LANES_BRANCH" @@ -2086,7 +2105,8 @@ EOF if [ "$attempt" -ge 6 ]; then note "RECOVERY: their author commits those files (a handoff: handoff(@): )," note " then re-run: LANES_LANE= lanes-edit.sh commit \"\" — your edit is already" - note " a local commit here: git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" + note " a local commit here, so that re-run stages nothing, makes no commit of its own, and" + note " pushes the local commits this lists (#194): git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" die "could not push origin/$LANES_BRANCH: behind the remote, and a peer's uncommitted file blocks the rebase. Nothing was lost — your commit is local." 3 fi elif git_net -C "$LANES_REPO" pull --rebase -q origin "$LANES_BRANCH"; then @@ -2150,8 +2170,12 @@ EOF note " git -C $LANES_REPO fetch origin $LANES_BRANCH || echo FETCH-FAILED # if that printed FETCH-FAILED, STOP and retry — a stale or unreachable origin proves nothing either way" note " git -C $LANES_REPO cherry origin/$LANES_BRANCH $cp_sha | grep -c '^+' # BY CONTENT, not by sha: a peer's rebase changes the hash but git cherry still finds the same patch" note " if that printed 0, STOP — reset or redo now would write it a second time." + note " git -C $LANES_REPO cherry -v origin/$LANES_BRANCH $cp_sha # WHOSE: one '+' line per local commit origin lacks, and this checkout is shared" + note " if any '+' line is not your own write, STOP and take it to the lane its subject names:" + note " never reset, stash or overwrite a peer's work — the reset below drops EVERY '+' commit in this checkout," + note " theirs with yours, and origin holds no copy of any of them (#194)." note " git -C $LANES_REPO diff origin/$LANES_BRANCH..HEAD -- ${CP_PATHS[*]} # only if it is not there: read back exactly what you wrote" - note " git -C $LANES_REPO reset --hard origin/$LANES_BRANCH # then drop the local commits (NOTE: also drops any" + note " git -C $LANES_REPO reset --hard origin/$LANES_BRANCH # only if every '+' line is yours: drop the local commits (NOTE: also drops any" note " # uncommitted peer edit in this checkout)" note " then re-read LANES.md and redo the edit with lanes-edit.sh, on top of the peer's version." die "rebase conflict on origin/$LANES_BRANCH — not pushed by this attempt; a later write from this checkout may already carry it, so look before you retry." 3 diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 757e6c1c..0c4970d5 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -204,7 +204,7 @@ def test_the_printed_recovery_publishes_the_edit_it_stranded(box): assert "a peer's uncommitted file blocks the rebase" in stranded.stderr assert len(box.unpublished()) == 1, "the edit is a local commit and only that" # The RECOVERY names the re-run and says what it does once nothing is staged. - assert f'LANES_LANE= lanes-edit.sh commit ""' in stranded.stderr + assert 'LANES_LANE= lanes-edit.sh commit ""' in stranded.stderr assert "pushes the local commits this lists" in stranded.stderr, stranded.stderr box.wgit("commit", "-q", "-m", f"handoff({PEER_LANE}@Eagle): the mid-write line", @@ -258,10 +258,13 @@ def test_publishing_past_a_peers_dirty_file_keeps_the_dirty_elsewhere_guard(box) result = box.helper("commit", SUBJECT) assert result.returncode == 0, result.stderr assert f"nothing staged for {REGISTER} — no commit made" in result.stderr - assert f"WARNING: this checkout has uncommitted changes to files OTHER than the register" in result.stderr + assert ("WARNING: this checkout has uncommitted changes to files OTHER than the register" + in result.stderr) assert PEER_HANDOFF in result.stderr assert "pushed origin/main (attempt 1, no pull" in result.stderr, result.stderr - assert not any(c.startswith(f"-C {box.wip} pull") for c in box.net_calls()), box.net_calls() + calls = [f" {c} " for c in box.net_calls()] + assert not any(" pull " in c for c in calls), calls + assert any(" push " in c for c in calls), calls assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") assert _row(LANE, "LIVE · edited by hand") in box.origin_file(REGISTER) assert box.wgit("diff", "--name-only").split() == [PEER_HANDOFF] @@ -298,7 +301,7 @@ def test_the_conflict_recovery_warns_that_a_reset_drops_a_peers_commits(box): # The listing the RECOVERY prints is a command that runs, and it names # the peer's commit as one origin does not have. - command = err.splitlines()[listing].split("#", 1)[0].split() + command = err.splitlines()[listing].split("lanes-edit: ", 1)[1].split("#", 1)[0].split() listed = subprocess.run(command, env=box.env, capture_output=True, text=True, check=True).stdout assert f"+ {peer_commit}" in listed, listed From 352c1b6f094e1c441cf2fd2ec0798e1f74bf5548 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 19:03:11 +0000 Subject: [PATCH 03/10] test_commit_push_published_edit_194: the refusal case names a transcript, so `log` reaches refuse_dirty_checkout instead of the Amendment 7(b) session refusal The first locked run showed case 6 failing on both trees for that reason, before the checkout was ever read. Co-Authored-By: Claude Opus 5.5 --- tests/test_commit_push_published_edit_194.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 0c4970d5..96be311a 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -154,9 +154,9 @@ def peer_edit_in_wip(self, line: str) -> None: with (self.wip / PEER_HANDOFF).open("a", encoding="utf-8") as fh: fh.write(line + "\n") - def helper(self, *args: str, lane: str = LANE) -> subprocess.CompletedProcess: + def helper(self, *args: str, lane: str = LANE, **extra: str) -> subprocess.CompletedProcess: self.net_log.write_text("", encoding="utf-8") - env = dict(self.env, LANES_LANE=lane) + env = dict(self.env, LANES_LANE=lane, **extra) return subprocess.run(["bash", str(REPO / "lanes-edit.sh"), *args], env=env, cwd=str(self.wip), capture_output=True, text=True, timeout=180, check=False) @@ -313,7 +313,10 @@ def test_the_dirty_checkout_refusal_names_the_act_to_re_run(box): were the re-run. The authors commit their files; what is re-run is the act that was refused, whose own push publishes their commits.""" box.peer_edit_in_wip("a peer's line, mid-write") - result = box.helper("log", "NOTED", f"lane:{LANE}", "a note") + # An event names the transcript taking the act (Amendment 7(b)); without + # one the write refuses before it ever reads the checkout. + result = box.helper("log", "NOTED", f"lane:{LANE}", "a note", + LANES_SESSION="0e3c9a52-6d1b-4b8e-9f3a-2a7c5d4e6f81") assert result.returncode == 2, result.stderr assert "RECOVERY — whoever wrote those files commits them:" in result.stderr assert "then re-run the refused write NOTED" in result.stderr, result.stderr From 836e1e1289341fa52678a822817b0faaf08fd1c9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 20:46:19 +0000 Subject: [PATCH 04/10] #214 review: only `commit` publishes the commits a write with nothing staged finds ahead, and only lane tooling's own; the conflict RECOVERY lists HEAD and stops on a moved checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review measured the blocker: with another lane's unpushed commit in the shared checkout deleting `scripts/link-estates`, a no-op `replace-in-row` published it and origin lost the file. Every write with nothing staged took the push loop; now only `commit` does (CP_PUBLISH_AHEAD, set in its arm alone and never read from the environment), and every other no-op write is today's exit 0 with no network call. `commit`'s publish reads every queued commit first and refuses with 2, naming the commit and the path, when one deletes a file or touches a path outside lanes/ and handoffs/. A failed read refuses the same way. Nothing is pushed. The conflict RECOVERY lists `cherry -v origin/ HEAD`, which is what the reset acts on, and adds a STOP for a checkout that moved after the abort. It also says a LANES(concurrent@…) or LANES(pre-existing@…) capture names no lane and is not the reader's. The dirty-elsewhere RECOVERY's claim is qualified ("if lanes/LANES.md is clean") and names the refusal. The publish list counts what it leaves out. replace_line's contract comment and the manual say the same. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 29 +++--- lanes-edit.sh | 69 ++++++++++---- tests/test_commit_push_published_edit_194.py | 94 ++++++++++++++++++-- 3 files changed, 157 insertions(+), 35 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index cc0511ce..eebd1e8e 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -94,9 +94,13 @@ files — `pull --rebase` refuses outright on any unstaged tracked change — an pushes straight out instead, warning and naming the files. If the push is also rejected (the remote moved), it stops with exit 3, leaves your edit as a local commit, and prints the recovery: the owner of that file commits it, then you -re-run `lanes-edit.sh commit`. With nothing left to stage, that re-run makes no -commit of its own and pushes the local commits origin does not have yet, through -the same loop, guard and retries (#194); with none, it is the no-op it always was. +re-run `lanes-edit.sh commit`. If `lanes/LANES.md` is clean, that re-run makes +no commit of its own and pushes the local commits origin does not have yet, +through the same loop, guard and retries (#194). It refuses with 2 and pushes +nothing if any of those commits deletes a file or touches a path outside +`lanes/` and `handoffs/`. Only `commit` publishes that queue: any other write with +nothing to stage leaves it local, and with nothing ahead `commit` is the no-op +it always was. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -207,14 +211,17 @@ dangling commit by rebasing it onto a newer base, which changes its hash, so actually published. `git -C cherry origin/main | grep -c '^+'` compares by patch instead and still finds it; 0 means it is already there, in which case STOP. Only once fetch succeeded AND this -prints something other than 0 do you list WHOSE they are, with -`git -C cherry -v origin/main `: one `+` -line per local commit origin lacks, and in a shared checkout not every one is -yours. If any `+` line is not your own write, STOP and take it to the lane its -subject names. Never reset, stash or overwrite a peer's work: `reset --hard` -drops EVERY `+` commit in the checkout, theirs with yours, and origin holds no -copy of any of them (#194). Only when every `+` line is yours do you read your -edit back with +prints something other than 0 do you check that `git -C rev-parse HEAD` is still the commit the recovery named; if the checkout +has moved since the abort, STOP. Then list WHOSE the commits are, with +`git -C cherry -v origin/main HEAD`: one `+` line per +local commit origin lacks, which is exactly what a reset drops, and in a shared +checkout not every one is yours. If any `+` line is not your own write, STOP and +take it to the lane its subject names. A `LANES(concurrent@…)` or +`LANES(pre-existing@…)` capture names no lane, so it is not yours either. Never +reset, stash or overwrite a peer's work: `reset --hard` drops EVERY `+` commit +in the checkout, theirs with yours, and origin holds no copy of any of them +(#194). Only when every `+` line is yours do you read your edit back with `git -C diff origin/main..HEAD -- lanes/LANES.md`, then `git -C reset --hard origin/main` and redo it on top of the peer's version. diff --git a/lanes-edit.sh b/lanes-edit.sh index b4acc853..20f2ba45 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1098,6 +1098,10 @@ LANES_PATH="${LANES_PATH:-$(git -C "${LANES_DIR:-.}" rev-parse --show-prefix 2>/ # lane's claim landed first. CP_PATHS=("$LANES_PATH") CP_AFTER_REBASE="" +# Set by `commit` alone, and never taken from the environment: with nothing +# staged, commit_push publishes the local commits ahead of origin for that one +# write and for no other (#194). +CP_PUBLISH_AHEAD=0 # die "" [] — the MESSAGE is $1 alone. It used to be "$*", # which joined the exit code on to the end of every refusal that passed one: @@ -1592,7 +1596,9 @@ replace_line() { # nothing staged and says so on its own line, and every one of the four # callers (`set-row-state`, `append-session-id`, `replace-in-row` and # `migrate-state-cells`) wanted the file to SAY something rather than to - # differ from what it said. + # differ from what it said. Nor does it push: a write with nothing staged + # publishes nothing, whatever this shared checkout has queued ahead of + # origin; only `commit` does that, and only lane tooling's own commits (#194). if cmp -s -- "$pre" "$out"; then REPLACE_LINE_NOOP=1 note "line $n of $LANES_FILE is already exactly what this write asked for — nothing rewritten, and nothing to commit" @@ -2020,15 +2026,23 @@ commit_push() { [ -z "$cp_byte" ] || die "$(bytecode_refusal_text "$cp_byte")" 2 git -C "$LANES_REPO" -c "$CP_EXACT" add -- ${cp_add[@]+"${cp_add[@]}"} || die "git add failed" 6 fi - # #194 — NOTHING STAGED IS NOT NOTHING TO PUBLISH. A write the push loop - # below could not get out (a peer's uncommitted file blocking the rebase, six - # rejected pushes) leaves its edit as a LOCAL commit, and the RECOVERY printed - # for it is to re-run `commit` once the way is clear. Returning here sent that - # re-run home with exit 0 and the commit still local. So a write with nothing - # of its own to commit still takes the push loop — under the caller's lock, - # with the dirty-elsewhere guard and the retries — whenever HEAD holds commits - # `origin/$LANES_BRANCH` does not, as of the last fetch (no such ref reads as - # none). With none it is the no-op it always was, and reaches no network. + # #194 — NOTHING STAGED IS NOT NOTHING TO PUBLISH, FOR `commit` ALONE. A + # write the push loop below could not get out (a peer's uncommitted file + # blocking the rebase, six rejected pushes) leaves its edit as a LOCAL commit, + # and the RECOVERY printed for it is to re-run `commit` once the way is clear. + # Returning here sent that re-run home with exit 0 and the commit still local. + # So `commit`, the one caller that sets CP_PUBLISH_AHEAD, still takes the push + # loop (its lock, the dirty-elsewhere guard, the retries) whenever HEAD holds + # commits `origin/$LANES_BRANCH` does not, as of the last fetch; no such ref + # reads as none. Every other write with nothing to stage stays the no-op it + # always was and reaches no network: a repeated `set-row-state` must not turn + # into a push of whatever this SHARED checkout has queued (the #214 review + # measured exactly that deleting `scripts/link-estates` on origin). + # + # AND `commit` PUBLISHES ONLY WHAT LANE TOOLING STRANDS. Every queued commit is + # read first; one that DELETES a file, or touches a path outside `lanes/` and + # `handoffs/`, refuses the whole publish (2) and nothing is pushed. A read + # that fails refuses the same way, because it proves nothing either way. cp_publish=0 if git -C "$LANES_REPO" -c "$CP_EXACT" diff --cached --quiet -- "${CP_PATHS[@]}"; then cp_ahead="$(git -C "$LANES_REPO" --no-pager log --oneline "origin/$LANES_BRANCH..HEAD" 2>/dev/null || :)" @@ -2036,8 +2050,24 @@ commit_push() { note "nothing staged for ${CP_PATHS[*]} — no commit made" return 0 fi - note "nothing staged for ${CP_PATHS[*]} — no commit made; pushing the local commits origin/$LANES_BRANCH does not have:" + cp_n="$(printf '%s\n' "$cp_ahead" | awk 'END { print NR }')" + if [ "$CP_PUBLISH_AHEAD" != 1 ]; then + note "nothing staged for ${CP_PATHS[*]} — no commit made; the $cp_n local commit(s) ahead of origin/$LANES_BRANCH stay local, and only \`lanes-edit.sh commit\` publishes them" + return 0 + fi + cp_files=""; cp_frc=0 + cp_files="$(git -C "$LANES_REPO" -c core.quotePath=false --no-pager log --no-renames --name-status --format='commit %h' "origin/$LANES_BRANCH..HEAD")" || cp_frc=$? + [ "$cp_frc" = 0 ] || die "git could not list what the $cp_n local commit(s) ahead of origin/$LANES_BRANCH change, so whether they are \`commit\`'s to publish is unknown; nothing was pushed (#194)" 2 + cp_bad="$(printf '%s\n' "$cp_files" | awk -F '\t' '/^commit / { c = substr($0, 8); next } + NF >= 2 && ($1 == "D" || ($2 !~ /^lanes\// && $2 !~ /^handoffs\//)) { printf " %s %s %s\n", c, $1, $2 }')" + if [ -n "$cp_bad" ]; then + note "nothing staged for ${CP_PATHS[*]}, and of the $cp_n local commit(s) ahead of origin/$LANES_BRANCH these delete a file or reach outside lanes/ and handoffs/:" + printf '%s\n' "$cp_bad" >&2 + die "refusing to publish: \`commit\` pushes only the register and handoff commits lane tooling strands (#194), so nothing was pushed. Whoever queued those commits publishes them; never reset them away. Read them: git -C $LANES_REPO log --stat origin/$LANES_BRANCH..HEAD" 2 + fi + note "nothing staged for ${CP_PATHS[*]} — no commit made; pushing the $cp_n local commit(s) origin/$LANES_BRANCH does not have:" printf '%s\n' "$cp_ahead" | head -n 10 >&2 || : + [ "$cp_n" -le 10 ] || note " … and $((cp_n - 10)) more" cp_publish=1 fi if [ "$cp_publish" = 1 ]; then @@ -2105,8 +2135,9 @@ EOF if [ "$attempt" -ge 6 ]; then note "RECOVERY: their author commits those files (a handoff: handoff(@): )," note " then re-run: LANES_LANE= lanes-edit.sh commit \"\" — your edit is already" - note " a local commit here, so that re-run stages nothing, makes no commit of its own, and" - note " pushes the local commits this lists (#194): git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" + note " a local commit here. If $LANES_PATH is clean, that re-run makes no commit of its own and" + note " pushes the local commits this lists (#194), refusing (2) and pushing nothing if any of them" + note " deletes a file or reaches outside lanes/ and handoffs/: git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" die "could not push origin/$LANES_BRANCH: behind the remote, and a peer's uncommitted file blocks the rebase. Nothing was lost — your commit is local." 3 fi elif git_net -C "$LANES_REPO" pull --rebase -q origin "$LANES_BRANCH"; then @@ -2170,10 +2201,11 @@ EOF note " git -C $LANES_REPO fetch origin $LANES_BRANCH || echo FETCH-FAILED # if that printed FETCH-FAILED, STOP and retry — a stale or unreachable origin proves nothing either way" note " git -C $LANES_REPO cherry origin/$LANES_BRANCH $cp_sha | grep -c '^+' # BY CONTENT, not by sha: a peer's rebase changes the hash but git cherry still finds the same patch" note " if that printed 0, STOP — reset or redo now would write it a second time." - note " git -C $LANES_REPO cherry -v origin/$LANES_BRANCH $cp_sha # WHOSE: one '+' line per local commit origin lacks, and this checkout is shared" - note " if any '+' line is not your own write, STOP and take it to the lane its subject names:" - note " never reset, stash or overwrite a peer's work — the reset below drops EVERY '+' commit in this checkout," - note " theirs with yours, and origin holds no copy of any of them (#194)." + note " git -C $LANES_REPO rev-parse HEAD # if this is not $cp_sha, STOP: the checkout moved after this abort, and the lines below no longer describe it" + note " git -C $LANES_REPO cherry -v origin/$LANES_BRANCH HEAD # WHOSE: one '+' line per local commit origin lacks, which is what the reset below drops" + note " if any '+' line is not your own write, STOP and take it to the lane its subject names; a LANES(concurrent@…) or" + note " LANES(pre-existing@…) capture names no lane, so it is NOT yours. Never reset, stash or overwrite a peer's work:" + note " the reset below drops EVERY '+' commit in this checkout, theirs with yours, and origin holds no copy of any of them (#194)." note " git -C $LANES_REPO diff origin/$LANES_BRANCH..HEAD -- ${CP_PATHS[*]} # only if it is not there: read back exactly what you wrote" note " git -C $LANES_REPO reset --hard origin/$LANES_BRANCH # only if every '+' line is yours: drop the local commits (NOTE: also drops any" note " # uncommitted peer edit in this checkout)" @@ -13722,6 +13754,9 @@ Nothing was written." 2 msg="$msg + sweeps uncommitted edit to row $other" fi fi + # #194 — the one write that, with nothing to stage, publishes the local + # commits ahead of origin: it is the RECOVERY's re-run. See commit_push. + CP_PUBLISH_AHEAD=1 commit_push "LANES(${LANES_LANE:-unknown}@$WS): $msg" ;; diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 96be311a..588a6096 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -38,6 +38,9 @@ PEER_LANE = "repoQ-1" REGISTER = "lanes/LANES.md" PEER_HANDOFF = f"handoffs/{PEER_LANE}.md" +#: A tracked file outside `lanes/` and `handoffs/`, the kind the #214 review +#: saw a queued commit delete from origin. +TOOL = "scripts/link-estates" SUBJECT = "row rewritten by hand" #: The environment a case must not inherit from the host that runs it. @@ -111,7 +114,8 @@ def __init__(self, root: Path) -> None: f"{_row(LANE, 'LIVE · seeded')}\n" f"{_row(PEER_LANE, 'LIVE · seeded')}\n") self.write(self.wip, PEER_HANDOFF, f"# {PEER_LANE}\n") - self.git("-C", str(self.wip), "add", "--", REGISTER, PEER_HANDOFF) + self.write(self.wip, TOOL, "#!/usr/bin/env bash\n") + self.git("-C", str(self.wip), "add", "--", REGISTER, PEER_HANDOFF, TOOL) self.git("-C", str(self.wip), "commit", "-q", "-m", "seed") self.git("-C", str(self.wip), "push", "-q", "origin", "main") self.git("clone", "-q", str(self.origin), str(self.peer)) @@ -154,6 +158,17 @@ def peer_edit_in_wip(self, line: str) -> None: with (self.wip / PEER_HANDOFF).open("a", encoding="utf-8") as fh: fh.write(line + "\n") + def queue(self, message: str, *, write: str = "", delete: str = "") -> str: + """A commit made in THIS checkout and not pushed: someone's queue.""" + if write: + with (self.wip / write).open("a", encoding="utf-8") as fh: + fh.write(f"{message}\n") + self.wgit("add", "--", write) + if delete: + self.wgit("rm", "-q", "--", delete) + self.wgit("commit", "-q", "-m", message) + return self.wgit("rev-parse", "--short", "HEAD").strip() + def helper(self, *args: str, lane: str = LANE, **extra: str) -> subprocess.CompletedProcess: self.net_log.write_text("", encoding="utf-8") env = dict(self.env, LANES_LANE=lane, **extra) @@ -205,6 +220,7 @@ def test_the_printed_recovery_publishes_the_edit_it_stranded(box): assert len(box.unpublished()) == 1, "the edit is a local commit and only that" # The RECOVERY names the re-run and says what it does once nothing is staged. assert 'LANES_LANE= lanes-edit.sh commit ""' in stranded.stderr + assert "If lanes/LANES.md is clean, that re-run makes no commit of its own" in stranded.stderr assert "pushes the local commits this lists" in stranded.stderr, stranded.stderr box.wgit("commit", "-q", "-m", f"handoff({PEER_LANE}@Eagle): the mid-write line", @@ -292,19 +308,28 @@ def test_the_conflict_recovery_warns_that_a_reset_drops_a_peers_commits(box): assert "REBASE CONFLICT on origin/main" in err assert box.wgit("merge-base", "--is-ancestor", peer_commit, "HEAD") == "" + aborted_at = box.wgit("rev-parse", "HEAD").strip() count = _line_of(err, "cherry origin/main") - listing = _line_of(err, "cherry -v origin/main") - warning = _line_of(err, "never reset, stash or overwrite a peer's work") + moved = _line_of(err, f"rev-parse HEAD # if this is not {aborted_at}, STOP") + listing = _line_of(err, "cherry -v origin/main HEAD") + warning = _line_of(err, "Never reset, stash or overwrite a peer's work") drops = _line_of(err, "drops EVERY '+' commit in this checkout") reset = _line_of(err, "reset --hard origin/main") - assert count < listing < warning <= drops < reset, err - - # The listing the RECOVERY prints is a command that runs, and it names - # the peer's commit as one origin does not have. + assert count < moved < listing < warning < drops < reset, err + assert f"cherry origin/main {aborted_at}" in err.splitlines()[count] + captures = "\n".join(err.splitlines()[listing:drops + 1]) + assert "LANES(concurrent@" in captures and "LANES(pre-existing@" in captures, captures + assert "names no lane, so it is NOT yours" in captures, captures + + # The listing reads HEAD, which is what the reset acts on: a peer's commit + # made AFTER the abort is in it too, and the moved-checkout STOP fires. + later = box.queue(f"handoff({PEER_LANE}@Eagle): written after the abort", write=PEER_HANDOFF) + assert box.wgit("rev-parse", "HEAD").strip() != aborted_at command = err.splitlines()[listing].split("lanes-edit: ", 1)[1].split("#", 1)[0].split() listed = subprocess.run(command, env=box.env, capture_output=True, text=True, check=True).stdout assert f"+ {peer_commit}" in listed, listed + assert any(line.startswith("+ ") and later in line.split()[1] for line in listed.splitlines()), listed def test_the_dirty_checkout_refusal_names_the_act_to_re_run(box): @@ -320,3 +345,58 @@ def test_the_dirty_checkout_refusal_names_the_act_to_re_run(box): assert result.returncode == 2, result.stderr assert "RECOVERY — whoever wrote those files commits them:" in result.stderr assert "then re-run the refused write NOTED" in result.stderr, result.stderr + + +def test_a_no_op_write_never_publishes_what_the_checkout_queued(box): + """THE #214 REVIEW'S BLOCKER. This shared checkout holds another lane's + unpushed commit that deletes a file outside lane tooling's trees. A write + whose result is the line already there must stay what it is on `main`: + exit 0, no network call, origin untouched. Only `commit` publishes.""" + queued = box.queue("another lane's commit: retire the tool", delete=TOOL) + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + result = box.helper("replace-in-row", LANE, "seeded", "seeded") + assert result.returncode == 0, result.stderr + assert f"nothing staged for {REGISTER} — no commit made" in result.stderr + assert "stay local, and only `lanes-edit.sh commit` publishes them" in result.stderr + assert box.net_calls() == [] + assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n" + assert box.unpublished()[0].startswith(queued) + + +@pytest.mark.parametrize("queued", [ + {"delete": TOOL, "status": "D", "path": TOOL}, + {"write": "workspaces/repoP.yaml", "status": "A", "path": "workspaces/repoP.yaml"}, + {"delete": PEER_HANDOFF, "status": "D", "path": PEER_HANDOFF}, +], ids=["deletes-a-tool", "outside-lane-tooling", "deletes-a-handoff"]) +def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, queued): + """`commit` publishes the register and handoff commits lane tooling strands + and nothing else: one queued commit that deletes a file, or touches a path + outside `lanes/` and `handoffs/`, refuses the WHOLE publish with 2, names + the commit and the path, and pushes nothing, not even the stranded edit.""" + box.set_state(box.wip, LANE, "LIVE · edited by hand") + box.wgit("commit", "-q", "-m", f"LANES({LANE}@Eagle): {SUBJECT}", "--", REGISTER) + if "write" in queued: + (box.wip / queued["write"]).parent.mkdir(parents=True, exist_ok=True) + sha = box.queue("a queued commit", write=queued.get("write", ""), + delete=queued.get("delete", "")) + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + assert result.returncode == 2, result.stderr + assert f" {sha} {queued['status']} {queued['path']}" in result.stderr, result.stderr + assert "nothing was pushed" in result.stderr + assert "pushing the" not in result.stderr + assert box.net_calls() == [] + assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + + +def test_the_publish_counts_what_its_list_leaves_out(box): + """The list of commits being pushed shows ten and counts the rest.""" + for n in range(12): + box.queue(f"handoff({PEER_LANE}@Eagle): stamp {n}", write=PEER_HANDOFF) + result = box.helper("commit", SUBJECT) + assert result.returncode == 0, result.stderr + assert "pushing the 12 local commit(s) origin/main does not have:" in result.stderr + assert " … and 2 more" in result.stderr, result.stderr + assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") From fab2cbc57274522e174d9af079f3ed550bbe27a9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 20:47:30 +0000 Subject: [PATCH 05/10] test_commit_push_published_edit_194: the no-op case asserts its behaviour before its wording, so a regression to publishing fails on the push, not on a note Co-Authored-By: Claude Opus 5.5 --- tests/test_commit_push_published_edit_194.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 588a6096..c5805a10 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -356,12 +356,12 @@ def test_a_no_op_write_never_publishes_what_the_checkout_queued(box): head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") result = box.helper("replace-in-row", LANE, "seeded", "seeded") assert result.returncode == 0, result.stderr - assert f"nothing staged for {REGISTER} — no commit made" in result.stderr - assert "stay local, and only `lanes-edit.sh commit` publishes them" in result.stderr - assert box.net_calls() == [] + assert box.net_calls() == [], result.stderr assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n" assert box.unpublished()[0].startswith(queued) + assert f"nothing staged for {REGISTER} — no commit made" in result.stderr + assert "stay local, and only `lanes-edit.sh commit` publishes them" in result.stderr @pytest.mark.parametrize("queued", [ From f34c993e317ccb0c7e5af9a5b3296d8635c8cacb Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 22:59:24 +0000 Subject: [PATCH 06/10] #214 adversarial check: `commit` reads the queue on the branch it pushes, refuses merges, type changes and HEAD off the branch, fails closed on every read, and pushes exactly the commit it read The check found three ordinary checkout states where `commit` still published a deletion of scripts/link-estates, and a race: - a merge's own changes (`log --name-status` prints none); - HEAD on another branch (the fence read HEAD, the push sent the branch); - a local branch named origin/main shadowing the tracking ref; - a commit made between the fence and the push. cp_publish_fence now reads refs/remotes/origin/..refs/heads/, spelled in full, and requires HEAD to be refs/heads/. Its reads are plumbing (rev-list --parents, diff-tree --stdin --always --raw) under --no-replace-objects. It refuses merge and root commits, and passes only an added or modified regular file under the register's derived directory (LANES_PREFIX), its log and alias paths, or handoffs/. Every read and every classification is status-checked, and diff-tree must return one header per commit it was given, because it skips an unreadable commit with exit 0. The push sends :refs/heads/. After each pull --rebase, CP_AFTER_REBASE re-reads the queue and re-pins the push. Ordinary writes push the branch as before. Lane renames (a log deleted and re-added) stay refused, by design, and the message says what carries them. Both exit-code tables list the new refusal; the manual describes the rule. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 16 +- lanes-edit.sh | 124 ++++++++++--- tests/test_commit_push_published_edit_194.py | 177 ++++++++++++++++++- 3 files changed, 281 insertions(+), 36 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index eebd1e8e..69c087f3 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -96,11 +96,15 @@ rejected (the remote moved), it stops with exit 3, leaves your edit as a local commit, and prints the recovery: the owner of that file commits it, then you re-run `lanes-edit.sh commit`. If `lanes/LANES.md` is clean, that re-run makes no commit of its own and pushes the local commits origin does not have yet, -through the same loop, guard and retries (#194). It refuses with 2 and pushes -nothing if any of those commits deletes a file or touches a path outside -`lanes/` and `handoffs/`. Only `commit` publishes that queue: any other write with -nothing to stage leaves it local, and with nothing ahead `commit` is the no-op -it always was. +through the same loop, guard and retries (#194). It reads that queue on +`refs/heads/main`, the branch the push sends, with HEAD required to be on it, +and pushes exactly the commit it read. It refuses with 2 and pushes nothing +unless every queued commit is a non-merge commit whose every change is an added +or modified regular file under `lanes/` or `handoffs/`. That refuses a merge, a +deletion (a lane rename's included), a type change, a symlink, a gitlink and +any other path. Only `commit` publishes that queue: any other write with nothing +to stage leaves it local, and with nothing ahead `commit` is the no-op it always +was. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -1178,7 +1182,7 @@ else. |---|---| | 0 | done | | 1 | environment — no register, no writer | -| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased | +| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, or from a checkout whose HEAD is off the branch (#194; nothing pushed) | | 3 | the edit is never a permanent loss, but this attempt cannot confirm whether it also reached origin — a rebase conflict (not pushed by this attempt; a later write from this checkout may already carry it to origin, so look by content before you retry), a network timeout on a push or a pull (a hung one often already landed), or six attempts exhausted because a peer's uncommitted file blocks every rebase | | 4 | the mutex could not be taken within 60s | | 5 | an edit moved more than one line and was refused | diff --git a/lanes-edit.sh b/lanes-edit.sh index 20f2ba45..d41d6fab 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -431,7 +431,10 @@ # `canon-lane` refuse until 15(d)'s merge, or AMENDMENT 12's BLOCKED # PROMPT, or — opensoft/openRepoTools#162 — a commit whose pathspec # would stage bytecode, a cache or a virtual environment -# (`commit_push`, `pathspec-check`). SEVEN MEANINGS ON ONE NUMBER, and they +# (`commit_push`, `pathspec-check`), or — #194 — `commit` refusing to +# publish local commits that are not register or handoff writes, or a +# checkout whose HEAD is off the branch (`cp_publish_fence`), nothing +# pushed. EIGHT MEANINGS ON ONE NUMBER, and they # stay readable only because of where each can occur: bad arguments to a # subcommand that predates Amendment 11; `register-row`'s "not lane-shaped"; # AN UNKNOWN SUBCOMMAND (the `*)` arm below), which is how a caller detects @@ -1102,6 +1105,10 @@ CP_AFTER_REBASE="" # staged, commit_push publishes the local commits ahead of origin for that one # write and for no other (#194). CP_PUBLISH_AHEAD=0 +# A publish's push sends the commit its fence read, `:refs/heads/`; +# empty, every push sends the branch as it stands, as it always has (#194). +CP_PUSH_SPEC="" +CP_FENCE_TIP="" # die "" [] — the MESSAGE is $1 alone. It used to be "$*", # which joined the exit code on to the end of every refusal that passed one: @@ -1953,6 +1960,81 @@ BRT_EOF printf 'Nothing was staged or committed. Unstage or delete those paths, then re-run.' } +# #194, AS #214's ADVERSARIAL CHECK HARDENED IT — WHAT `commit` MAY PUBLISH +# WITH NOTHING STAGED, read from the objects the push will send and nothing +# else. Three ordinary checkout states got a deletion past the first fence, +# which read `git log --name-status origin/..HEAD`: +# +# * A MERGE's own changes: `log` prints no diff for a merge, so a deletion +# made while resolving one passed unread. A merge or root commit refuses. +# * HEAD ON ANOTHER BRANCH: the fence read HEAD and the push sent +# `refs/heads/`. The range is the branch, and HEAD must be it, because +# `pull --rebase` moves HEAD and the push moves the branch. +# * A LOCAL BRANCH NAMED `origin/`, which shadows the tracking ref. Both +# ends of the range are spelled in full. +# +# The reads are plumbing (`rev-list`, `diff-tree --raw`), which no `color.*` or +# `diff.ignoreSubmodules` reshapes, under `--no-replace-objects`, so a replace +# ref cannot show the fence one commit while the push sends another. A change +# passes only as an ADDED or MODIFIED REGULAR FILE under the register's own +# directory (`$LANES_PREFIX`, derived, as every path of the register's is) or +# `handoffs/`. A deletion, a type change, a symlink or a gitlink refuses, and so +# does a path anywhere else. That includes lane tooling's own lane rename, +# which deletes a log to re-add it: a deletion is the one change origin cannot +# get back, and the fence keeps one rule for it. A read or a classification +# that fails refuses too (2), because it proves nothing. +# +# It leaves CP_FENCE_TIP at the commit it read, and the publish pushes exactly +# that commit (`:refs/heads/`). A commit another lane makes in this +# shared checkout between the read and the push stays local. +cp_publish_fence() { + cpf_ref="refs/heads/$LANES_BRANCH" + cpf_head="$(git -C "$LANES_REPO" symbolic-ref -q HEAD 2>/dev/null || :)" + [ "$cpf_head" = "$cpf_ref" ] || die "refusing to publish: HEAD is ${cpf_head:-detached}, not $cpf_ref, and the push sends $cpf_ref while \`pull --rebase\` moves HEAD, so what would be published is not what HEAD shows. Nothing was pushed (#194). Whoever moved this shared checkout off $LANES_BRANCH puts it back; never reset or check out over their work." 2 + cpf_rc=0 + cpf_base="$(git --no-replace-objects -C "$LANES_REPO" rev-parse -q --verify "refs/remotes/origin/$LANES_BRANCH^{commit}")" || cpf_rc=$? + cpf_tip="$(git --no-replace-objects -C "$LANES_REPO" rev-parse -q --verify "$cpf_ref^{commit}")" || cpf_rc=$? + cpf_revs="$(git --no-replace-objects -C "$LANES_REPO" rev-list --parents "$cpf_base..$cpf_tip")" || cpf_rc=$? + cpf_odd="$(printf '%s\n' "$cpf_revs" | awk 'NF > 0 && NF != 2 { printf " %s %s\n", substr($1, 1, 12), (NF > 2 ? "is a merge commit" : "is a root commit") }')" || cpf_rc=$? + cpf_pairs="$(printf '%s\n' "$cpf_revs" | awk 'NF == 2')" || cpf_rc=$? + # `diff-tree --stdin` SKIPS A COMMIT IT CANNOT READ and still exits 0, and + # prints no header for an empty diff unless told `--always`: so every commit + # handed to it must come back as a header, or the read refuses. + cpf_raw=""; cpf_n=0 + if [ -n "$cpf_pairs" ]; then + cpf_n="$(printf '%s\n' "$cpf_pairs" | awk 'END { print NR }')" || cpf_rc=$? + cpf_raw="$(printf '%s\n' "$cpf_pairs" | git --no-replace-objects -C "$LANES_REPO" -c core.quotePath=false diff-tree --stdin --always -r --no-renames --raw --ignore-submodules=none)" || cpf_rc=$? + fi + cpf_bad="$(printf '%s\n' "$cpf_raw" | awk -F '\t' -v want="$cpf_n" -v reg="$LANES_PREFIX" -v logp="$LANES_LOG_PREFIX" \ + -v reg_path="$LANES_PATH" -v alias_path="$LANES_ALIASES_PATH" ' + function inside(p) { + return (reg != "" && index(p, reg) == 1) || (logp != "" && index(p, logp) == 1) || + index(p, "handoffs/") == 1 || p == reg_path || p == alias_path + } + NF == 0 { next } + NF == 1 && $1 ~ /^[0-9a-f]+$/ { c = substr($1, 1, 12); seen++; next } + { split($1, f, " "); st = f[5]; mode = f[2] + if (!(NF == 2 && (st == "A" || st == "M") && (mode == "100644" || mode == "100755") && inside($2))) + printf " %s %s %s %s\n", c, st, mode, $2 } + END { if (seen != want) exit 3 }')" || cpf_rc=$? + [ "$cpf_rc" = 0 ] || die "git could not read, or this helper could not classify, the local commits on $cpf_ref ahead of refs/remotes/origin/$LANES_BRANCH, so whether they are \`commit\`'s to publish is unknown; nothing was pushed (#194)" 2 + if [ -n "$cpf_odd$cpf_bad" ]; then + note "of the local commits on $cpf_ref ahead of origin/$LANES_BRANCH, these are not register or handoff writes (a merge or root commit, or a change that is not an added or modified regular file under ${LANES_PREFIX:-the register directory} or handoffs/):" + [ -z "$cpf_odd" ] || printf '%s\n' "$cpf_odd" >&2 + [ -z "$cpf_bad" ] || printf '%s\n' "$cpf_bad" >&2 + die "refusing to publish: \`commit\` pushes only the register and handoff commits lane tooling strands (#194), so nothing was pushed. A lane rename (Amendment 16) deletes and re-adds its log and is refused here too; the next register write from this checkout carries it. Anything else, whoever queued it publishes it; never reset it away. Read them: git -C $LANES_REPO log --stat refs/remotes/origin/$LANES_BRANCH..$cpf_ref" 2 + fi + CP_FENCE_TIP="$cpf_tip" +} + +# The publish's CP_AFTER_REBASE: `pull --rebase` has rewritten the queue onto +# what origin holds now, and may have taken in a commit made since the first +# read, so the fence reads it again and the push is pinned to what it read. +cp_publish_refence() { + cp_publish_fence + CP_PUSH_SPEC="$CP_FENCE_TIP:refs/heads/$LANES_BRANCH" +} + # commit_push "" [...] — the pathspecs default to the # register alone, which is every caller that predates Amendment 7. A LANDING # or LANDED writes TWO (the register and the lane's log) so that both halves @@ -2032,20 +2114,21 @@ commit_push() { # and the RECOVERY printed for it is to re-run `commit` once the way is clear. # Returning here sent that re-run home with exit 0 and the commit still local. # So `commit`, the one caller that sets CP_PUBLISH_AHEAD, still takes the push - # loop (its lock, the dirty-elsewhere guard, the retries) whenever HEAD holds - # commits `origin/$LANES_BRANCH` does not, as of the last fetch; no such ref - # reads as none. Every other write with nothing to stage stays the no-op it + # loop (its lock, the dirty-elsewhere guard, the retries) whenever the branch + # holds commits `origin/$LANES_BRANCH` does not, as of the last fetch; no such + # ref reads as none. Every other write with nothing to stage stays the no-op it # always was and reaches no network: a repeated `set-row-state` must not turn # into a push of whatever this SHARED checkout has queued (the #214 review # measured exactly that deleting `scripts/link-estates` on origin). # - # AND `commit` PUBLISHES ONLY WHAT LANE TOOLING STRANDS. Every queued commit is - # read first; one that DELETES a file, or touches a path outside `lanes/` and - # `handoffs/`, refuses the whole publish (2) and nothing is pushed. A read - # that fails refuses the same way, because it proves nothing either way. + # AND `commit` PUBLISHES ONLY WHAT LANE TOOLING STRANDS, and only what it read: + # `cp_publish_fence` (above) reads the queue on the branch the push sends and + # refuses the whole publish (2) for anything that is not a register or + # handoff write. The push is pinned to the commit the fence read, and every + # `pull --rebase` reads it again (CP_AFTER_REBASE) and re-pins it. cp_publish=0 if git -C "$LANES_REPO" -c "$CP_EXACT" diff --cached --quiet -- "${CP_PATHS[@]}"; then - cp_ahead="$(git -C "$LANES_REPO" --no-pager log --oneline "origin/$LANES_BRANCH..HEAD" 2>/dev/null || :)" + cp_ahead="$(git --no-replace-objects -C "$LANES_REPO" --no-pager log --oneline "refs/remotes/origin/$LANES_BRANCH..refs/heads/$LANES_BRANCH" 2>/dev/null || :)" if [ -z "$cp_ahead" ]; then note "nothing staged for ${CP_PATHS[*]} — no commit made" return 0 @@ -2055,19 +2138,12 @@ commit_push() { note "nothing staged for ${CP_PATHS[*]} — no commit made; the $cp_n local commit(s) ahead of origin/$LANES_BRANCH stay local, and only \`lanes-edit.sh commit\` publishes them" return 0 fi - cp_files=""; cp_frc=0 - cp_files="$(git -C "$LANES_REPO" -c core.quotePath=false --no-pager log --no-renames --name-status --format='commit %h' "origin/$LANES_BRANCH..HEAD")" || cp_frc=$? - [ "$cp_frc" = 0 ] || die "git could not list what the $cp_n local commit(s) ahead of origin/$LANES_BRANCH change, so whether they are \`commit\`'s to publish is unknown; nothing was pushed (#194)" 2 - cp_bad="$(printf '%s\n' "$cp_files" | awk -F '\t' '/^commit / { c = substr($0, 8); next } - NF >= 2 && ($1 == "D" || ($2 !~ /^lanes\// && $2 !~ /^handoffs\//)) { printf " %s %s %s\n", c, $1, $2 }')" - if [ -n "$cp_bad" ]; then - note "nothing staged for ${CP_PATHS[*]}, and of the $cp_n local commit(s) ahead of origin/$LANES_BRANCH these delete a file or reach outside lanes/ and handoffs/:" - printf '%s\n' "$cp_bad" >&2 - die "refusing to publish: \`commit\` pushes only the register and handoff commits lane tooling strands (#194), so nothing was pushed. Whoever queued those commits publishes them; never reset them away. Read them: git -C $LANES_REPO log --stat origin/$LANES_BRANCH..HEAD" 2 - fi - note "nothing staged for ${CP_PATHS[*]} — no commit made; pushing the $cp_n local commit(s) origin/$LANES_BRANCH does not have:" + cp_publish_fence + note "nothing staged for ${CP_PATHS[*]} — no commit made; pushing the $cp_n local commit(s) origin/$LANES_BRANCH does not have, up to ${CP_FENCE_TIP:0:12}:" printf '%s\n' "$cp_ahead" | head -n 10 >&2 || : [ "$cp_n" -le 10 ] || note " … and $((cp_n - 10)) more" + CP_PUSH_SPEC="$CP_FENCE_TIP:refs/heads/$LANES_BRANCH" + CP_AFTER_REBASE=cp_publish_refence cp_publish=1 fi if [ "$cp_publish" = 1 ]; then @@ -2125,7 +2201,7 @@ EOF note "WARNING: this checkout has uncommitted changes to files OTHER than the register — not this invocation's:" printf ' %s\n' $others >&2 note "skipping 'pull --rebase' (it refuses on any unstaged tracked change) and pushing straight out" - if git_net -C "$LANES_REPO" push -q origin "$LANES_BRANCH"; then + if git_net -C "$LANES_REPO" push -q origin "${CP_PUSH_SPEC:-$LANES_BRANCH}"; then LANES_INDEX_NUDGE=1 note "pushed origin/$LANES_BRANCH (attempt $attempt, no pull — see the warning above)" return 0 @@ -2136,8 +2212,8 @@ EOF note "RECOVERY: their author commits those files (a handoff: handoff(@): )," note " then re-run: LANES_LANE= lanes-edit.sh commit \"\" — your edit is already" note " a local commit here. If $LANES_PATH is clean, that re-run makes no commit of its own and" - note " pushes the local commits this lists (#194), refusing (2) and pushing nothing if any of them" - note " deletes a file or reaches outside lanes/ and handoffs/: git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" + note " pushes the local commits this lists (#194), refusing (2) and pushing nothing unless every one" + note " is a register or handoff write: git -C $LANES_REPO log --oneline origin/$LANES_BRANCH..HEAD" die "could not push origin/$LANES_BRANCH: behind the remote, and a peer's uncommitted file blocks the rebase. Nothing was lost — your commit is local." 3 fi elif git_net -C "$LANES_REPO" pull --rebase -q origin "$LANES_BRANCH"; then @@ -2147,7 +2223,7 @@ EOF if [ -n "${CP_AFTER_REBASE:-}" ]; then "$CP_AFTER_REBASE" || return $? fi - if git_net -C "$LANES_REPO" push -q origin "$LANES_BRANCH"; then + if git_net -C "$LANES_REPO" push -q origin "${CP_PUSH_SPEC:-$LANES_BRANCH}"; then LANES_INDEX_NUDGE=1 note "pushed origin/$LANES_BRANCH (attempt $attempt)" return 0 diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index c5805a10..aa8944e7 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -49,8 +49,15 @@ SCRUBBED = {"AGENT_PROTOCOL_ROOT", "PROJECTS_ROOT", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"} +#: With FAKE_NET_RACE naming a tracked file, the FIRST push is raced: another +#: lane commits that file's deletion into the shared checkout after the +#: helper's fence has read the queue and before the push runs. NET_GIT = """#!/usr/bin/env bash printf '%s\\n' "$*" >> "$FAKE_NET_LOG" +if [ -n "${FAKE_NET_RACE:-}" ] && [ "${3:-}" = push ] && [ ! -e "$FAKE_NET_LOG.raced" ]; then + : > "$FAKE_NET_LOG.raced" + git -C "$2" rm -q -- "$FAKE_NET_RACE" && git -C "$2" commit -q -m "another lane, racing: retire the tool" +fi exec git "$@" """ @@ -167,7 +174,7 @@ def queue(self, message: str, *, write: str = "", delete: str = "") -> str: if delete: self.wgit("rm", "-q", "--", delete) self.wgit("commit", "-q", "-m", message) - return self.wgit("rev-parse", "--short", "HEAD").strip() + return self.wgit("rev-parse", "HEAD").strip() def helper(self, *args: str, lane: str = LANE, **extra: str) -> subprocess.CompletedProcess: self.net_log.write_text("", encoding="utf-8") @@ -365,9 +372,9 @@ def test_a_no_op_write_never_publishes_what_the_checkout_queued(box): @pytest.mark.parametrize("queued", [ - {"delete": TOOL, "status": "D", "path": TOOL}, - {"write": "workspaces/repoP.yaml", "status": "A", "path": "workspaces/repoP.yaml"}, - {"delete": PEER_HANDOFF, "status": "D", "path": PEER_HANDOFF}, + {"delete": TOOL, "status": "D 000000", "path": TOOL}, + {"write": "workspaces/repoP.yaml", "status": "A 100644", "path": "workspaces/repoP.yaml"}, + {"delete": PEER_HANDOFF, "status": "D 000000", "path": PEER_HANDOFF}, ], ids=["deletes-a-tool", "outside-lane-tooling", "deletes-a-handoff"]) def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, queued): """`commit` publishes the register and handoff commits lane tooling strands @@ -384,7 +391,7 @@ def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, queued result = box.helper("commit", SUBJECT) assert result.returncode == 2, result.stderr - assert f" {sha} {queued['status']} {queued['path']}" in result.stderr, result.stderr + assert f" {sha[:12]} {queued['status']} {queued['path']}" in result.stderr, result.stderr assert "nothing was pushed" in result.stderr assert "pushing the" not in result.stderr assert box.net_calls() == [] @@ -397,6 +404,164 @@ def test_the_publish_counts_what_its_list_leaves_out(box): box.queue(f"handoff({PEER_LANE}@Eagle): stamp {n}", write=PEER_HANDOFF) result = box.helper("commit", SUBJECT) assert result.returncode == 0, result.stderr - assert "pushing the 12 local commit(s) origin/main does not have:" in result.stderr + assert "pushing the 12 local commit(s) origin/main does not have, up to" in result.stderr assert " … and 2 more" in result.stderr, result.stderr assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") + + +# ---- #214's adversarial check: three ordinary checkout states got a deletion +# past the first fence, and a fourth was a race. Every case below exits 0 and +# deletes `scripts/link-estates` on origin against the first fence (`fab2cbc`). + +def _stranded_edit(box: Box) -> None: + box.set_state(box.wip, LANE, "LIVE · edited by hand") + box.wgit("commit", "-q", "-m", f"LANES({LANE}@Eagle): {SUBJECT}", "--", REGISTER) + + +def _refused_untouched(box: Box, result, head: str, origin: str) -> None: + assert result.returncode == 2, result.stderr + assert "nothing was pushed" in result.stderr, result.stderr + assert box.net_calls() == [], result.stderr + assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n" + + +def test_a_merge_commits_own_deletion_is_refused(box): + """BYPASS 1. `git log --name-status` prints no diff for a merge, so a + deletion made while resolving one went out unread, by the straight push a + peer's dirty file takes. A merge in the queue refuses.""" + _stranded_edit(box) + box.wgit("checkout", "-q", "-b", "side", "origin/main") + box.queue(f"handoff({PEER_LANE}@Eagle): a side line", write=PEER_HANDOFF) + box.wgit("checkout", "-q", "main") + box.wgit("merge", "-q", "--no-ff", "--no-commit", "side") + box.wgit("rm", "-q", "--", TOOL) + box.wgit("commit", "-q", "-m", "Merge branch side") + merge = box.wgit("rev-parse", "HEAD").strip() + box.peer_edit_in_wip("a peer mid-write") + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + _refused_untouched(box, result, head, origin) + assert f" {merge[:12]} is a merge commit" in result.stderr, result.stderr + + +def test_a_checkout_left_on_another_branch_is_refused(box): + """BYPASS 2. The fence read HEAD while the push sent `refs/heads/main`: + with a deletion queued on local main and HEAD on a scratch branch holding a + register edit, the fence saw only the edit. HEAD must be the branch.""" + box.queue("another lane: retire the tool", delete=TOOL) + box.wgit("checkout", "-q", "-b", "scratch", "origin/main") + _stranded_edit(box) + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + _refused_untouched(box, result, head, origin) + assert "HEAD is refs/heads/scratch, not refs/heads/main" in result.stderr, result.stderr + + +def test_a_local_branch_named_origin_main_does_not_shadow_the_tracking_ref(box): + """BYPASS 3. A local branch called `origin/main` made `origin/main..HEAD` + an empty-looking range for the commits beneath it. The range is spelled in + full, so the queued deletion is read and refused.""" + sha = box.queue("peer: retire the tool", delete=TOOL) + box.wgit("branch", "origin/main", "HEAD") + _stranded_edit(box) + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + _refused_untouched(box, result, head, origin) + assert f" {sha[:12]} D 000000 {TOOL}" in result.stderr, result.stderr + + +def test_a_type_change_is_refused(box): + """A handoff turned into a symlink is a `T`, not a modification: the fence + passes only regular files, added or modified.""" + _stranded_edit(box) + (box.wip / PEER_HANDOFF).unlink() + (box.wip / PEER_HANDOFF).symlink_to(f"../{TOOL}") + box.wgit("add", "--", PEER_HANDOFF) + box.wgit("commit", "-q", "-m", "peer: the handoff becomes a symlink to the tool") + sha = box.wgit("rev-parse", "HEAD").strip() + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + _refused_untouched(box, result, head, origin) + assert f" {sha[:12]} T 120000 {PEER_HANDOFF}" in result.stderr, result.stderr + + +@pytest.mark.parametrize("path", ["rebase", "straight"]) +def test_the_push_sends_the_commit_the_fence_read(box, path): + """THE RACE. Another lane commits a deletion into this shared checkout + after the fence read the queue and before the push: the push sends the + commit the fence read, `:refs/heads/main`, so the late commit stays + local, on both roads out (the pull-and-rebase and the straight push a + peer's dirty file forces).""" + _stranded_edit(box) + if path == "straight": + box.peer_edit_in_wip("a peer mid-write") + result = box.helper("commit", SUBJECT, FAKE_NET_RACE=TOOL) + assert result.returncode == 0, result.stderr + assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n", result.stderr + assert _row(LANE, "LIVE · edited by hand") in box.origin_file(REGISTER) + late = box.wgit("log", "-1", "--format=%s", "HEAD").strip() + assert late == "another lane, racing: retire the tool", late + assert box.wgit("rev-parse", "HEAD~1") == box.ogit("rev-parse", "main") + + +def test_a_lane_rename_is_refused_and_told_what_carries_it(box): + """A DELIBERATE OVER-REFUSAL. A lane rename deletes its log to re-add it, + and a deletion is the one change the fence never lets through: the refusal + says so, and names what does carry it.""" + box.write(box.wip, "lanes/log/repocase-1.md", "# log\n") + box.wgit("add", "--", "lanes/log/repocase-1.md") + box.wgit("commit", "-q", "-m", "seed a log") + box.wgit("push", "-q", "origin", "main") + box.wgit("mv", "lanes/log/repocase-1.md", "lanes/log/repoCase-1.md") + box.wgit("commit", "-q", "-m", "LANES(repoCase-1@Eagle): RENAMED lane repocase-1 → repoCase-1") + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + assert result.returncode == 2, result.stderr + assert " D 000000 lanes/log/repocase-1.md" in result.stderr, result.stderr + assert "A lane rename (Amendment 16) deletes and re-adds its log" in result.stderr + assert "the next register write from this checkout carries it" in result.stderr + assert box.net_calls() == [] + assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + + +def test_the_fence_follows_the_registers_own_directory(box): + """The register's directory is derived (`LANES_PREFIX`), never spelled + `lanes/`: a register kept in `registry/` publishes its own stranded edit.""" + text = (box.wip / REGISTER).read_text(encoding="utf-8") + box.write(box.wip, "registry/LANES.md", text) + box.wgit("add", "--", "registry/LANES.md") + box.wgit("commit", "-q", "-m", "a register kept elsewhere") + box.wgit("push", "-q", "origin", "main") + path = box.wip / "registry/LANES.md" + path.write_text(text.replace(_row(LANE, "LIVE · seeded"), _row(LANE, "LIVE · edited by hand")), + encoding="utf-8") + box.wgit("commit", "-q", "-m", f"LANES({LANE}@Eagle): {SUBJECT}", "--", "registry/LANES.md") + + result = box.helper("commit", SUBJECT, LANES_DIR=str(box.wip / "registry")) + assert result.returncode == 0, result.stderr + assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") + assert _row(LANE, "LIVE · edited by hand") in box.origin_file("registry/LANES.md") + + +def test_a_queued_commit_git_cannot_read_refuses(box): + """FAIL CLOSED. `diff-tree --stdin` passes over a commit it cannot read and + still exits 0, so a queued commit whose tree is missing must refuse, not + read as a commit with nothing in it.""" + _stranded_edit(box) + tree = box.wgit("rev-parse", "HEAD^{tree}").strip() + box.queue(f"handoff({PEER_LANE}@Eagle): a line", write=PEER_HANDOFF) + # The EARLIER queued commit's tree, so HEAD's own stays readable and the + # refusal is the fence's, not the index check's. + (box.wip / ".git" / "objects" / tree[:2] / tree[2:]).unlink() + origin = box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + assert result.returncode == 2, result.stderr + assert "nothing was pushed" in result.stderr, result.stderr + assert box.ogit("rev-parse", "main") == origin From 5b68127fdde730fb49290a28f525f04e47dcd0ac Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 23:38:17 +0000 Subject: [PATCH 07/10] Exit-code tables: say the #194 refusal pushes none of them, not "nothing pushed", which test_the_exit_3_documentation_agrees_with_the_die_message_it_describes reserves Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 2 +- lanes-edit.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 69c087f3..549fb8c8 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1182,7 +1182,7 @@ else. |---|---| | 0 | done | | 1 | environment — no register, no writer | -| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, or from a checkout whose HEAD is off the branch (#194; nothing pushed) | +| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, or from a checkout whose HEAD is off the branch (#194; it pushes none of them) | | 3 | the edit is never a permanent loss, but this attempt cannot confirm whether it also reached origin — a rebase conflict (not pushed by this attempt; a later write from this checkout may already carry it to origin, so look by content before you retry), a network timeout on a push or a pull (a hung one often already landed), or six attempts exhausted because a peer's uncommitted file blocks every rebase | | 4 | the mutex could not be taken within 60s | | 5 | an edit moved more than one line and was refused | diff --git a/lanes-edit.sh b/lanes-edit.sh index d41d6fab..4df8c5e8 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -433,8 +433,8 @@ # would stage bytecode, a cache or a virtual environment # (`commit_push`, `pathspec-check`), or — #194 — `commit` refusing to # publish local commits that are not register or handoff writes, or a -# checkout whose HEAD is off the branch (`cp_publish_fence`), nothing -# pushed. EIGHT MEANINGS ON ONE NUMBER, and they +# checkout whose HEAD is off the branch (`cp_publish_fence`), which +# pushes none of them. EIGHT MEANINGS ON ONE NUMBER, and they # stay readable only because of where each can occur: bad arguments to a # subcommand that predates Amendment 11; `register-row`'s "not lane-shaped"; # AN UNKNOWN SUBCOMMAND (the `*)` arm below), which is how a caller detects From 9f8aea86f61360dfe5320e4c11308bcaedbdf046 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 01:20:11 +0000 Subject: [PATCH 08/10] #214 reviews (openRepoShape-2 6242165339, openRepoTools-3 6242725522): the refusals as variants of one test, the flag pinned against the environment, bytecode refused, the fence called a path filter - The HEAD refusal says "nothing was pushed" like every other refusal of the publish. CI at 5b68127 failed on the capital N, and nothing else. - HEAD off the branch, a merge in the queue, a branch shadowing origin/main, a type change, and bytecode beside a handoff are variants of test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish, as lane 3 asked. The separate bypass tests are folded in. - The no-op case also runs with CP_PUBLISH_AHEAD=1 exported, which kills lane 2's mutant M01. - The composite assert is split (Sonar S9073). - The fence refuses bytecode, a cache or an environment (#162's list). Its comment and the manual call it a path and kind filter, never an ownership check. - The no-op note says `commit` would publish every queued commit, so read `git cherry -v` first (lane 2's SHOULD 3). - The `commit` usage line and both exit tables name the publish, its refusals and the failed read. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 14 +- lanes-edit.sh | 33 ++-- tests/test_commit_push_published_edit_194.py | 152 ++++++++++--------- 3 files changed, 116 insertions(+), 83 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 549fb8c8..5a630481 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -100,11 +100,13 @@ through the same loop, guard and retries (#194). It reads that queue on `refs/heads/main`, the branch the push sends, with HEAD required to be on it, and pushes exactly the commit it read. It refuses with 2 and pushes nothing unless every queued commit is a non-merge commit whose every change is an added -or modified regular file under `lanes/` or `handoffs/`. That refuses a merge, a -deletion (a lane rename's included), a type change, a symlink, a gitlink and -any other path. Only `commit` publishes that queue: any other write with nothing -to stage leaves it local, and with nothing ahead `commit` is the no-op it always -was. +or modified regular file under `lanes/` or `handoffs/`, and never bytecode, a +cache or an environment. That refuses a merge, a deletion (a lane rename's +included), a type change, a symlink, a gitlink and any other path. It is a +path filter, not an ownership check: another lane's register write passes it, +so read `git cherry -v origin/main HEAD` before you publish a shared queue. +Only `commit` publishes that queue: any other write with nothing to stage +leaves it local, and with nothing ahead `commit` is the no-op it always was. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -1182,7 +1184,7 @@ else. |---|---| | 0 | done | | 1 | environment — no register, no writer | -| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, or from a checkout whose HEAD is off the branch (#194; it pushes none of them) | +| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them) | | 3 | the edit is never a permanent loss, but this attempt cannot confirm whether it also reached origin — a rebase conflict (not pushed by this attempt; a later write from this checkout may already carry it to origin, so look by content before you retry), a network timeout on a push or a pull (a hung one often already landed), or six attempts exhausted because a peer's uncommitted file blocks every rebase | | 4 | the mutex could not be taken within 60s | | 5 | an edit moved more than one line and was refused | diff --git a/lanes-edit.sh b/lanes-edit.sh index 4df8c5e8..4caade66 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -58,7 +58,9 @@ # (attribution: $LANES_LANE, else the # "lane " the text itself names) # lanes-edit.sh [--no-sweep|--sweep] add-row "" -# lanes-edit.sh commit "" # commit a hand edit +# lanes-edit.sh commit "" # commit a hand edit; with +# nothing staged, publish the stranded +# register and handoff commits (#194) # lanes-edit.sh migrate-state-cells [--yes] # Amendment 13(e), once # lanes-edit.sh retire-rows … [--reason ""] [--writer ] # lanes-edit.sh archive-rows [--yes] # Amendment 19(d) @@ -432,9 +434,9 @@ # PROMPT, or — opensoft/openRepoTools#162 — a commit whose pathspec # would stage bytecode, a cache or a virtual environment # (`commit_push`, `pathspec-check`), or — #194 — `commit` refusing to -# publish local commits that are not register or handoff writes, or a -# checkout whose HEAD is off the branch (`cp_publish_fence`), which -# pushes none of them. EIGHT MEANINGS ON ONE NUMBER, and they +# publish local commits that are not register or handoff writes, from a +# checkout whose HEAD is off the branch, or that it cannot read +# (`cp_publish_fence`), and pushes none of them. EIGHT MEANINGS ON ONE NUMBER, and they # stay readable only because of where each can occur: bad arguments to a # subcommand that predates Amendment 11; `register-row`'s "not lane-shaped"; # AN UNKNOWN SUBCOMMAND (the `*)` arm below), which is how a caller detects @@ -1978,8 +1980,12 @@ BRT_EOF # ref cannot show the fence one commit while the push sends another. A change # passes only as an ADDED or MODIFIED REGULAR FILE under the register's own # directory (`$LANES_PREFIX`, derived, as every path of the register's is) or -# `handoffs/`. A deletion, a type change, a symlink or a gitlink refuses, and so -# does a path anywhere else. That includes lane tooling's own lane rename, +# `handoffs/`, and never as bytecode, a cache or an environment (#162's list). +# A deletion, a type change, a symlink or a gitlink refuses, and so does a path +# anywhere else. IT IS A PATH AND KIND FILTER: it vouches for where a change +# lands and what it is, never for whose it is (it reads no subject, author or +# trailer), so another lane's register write passes it. That includes lane +# tooling's own lane rename, # which deletes a log to re-add it: a deletion is the one change origin cannot # get back, and the fence keeps one rule for it. A read or a classification # that fails refuses too (2), because it proves nothing. @@ -1990,7 +1996,7 @@ BRT_EOF cp_publish_fence() { cpf_ref="refs/heads/$LANES_BRANCH" cpf_head="$(git -C "$LANES_REPO" symbolic-ref -q HEAD 2>/dev/null || :)" - [ "$cpf_head" = "$cpf_ref" ] || die "refusing to publish: HEAD is ${cpf_head:-detached}, not $cpf_ref, and the push sends $cpf_ref while \`pull --rebase\` moves HEAD, so what would be published is not what HEAD shows. Nothing was pushed (#194). Whoever moved this shared checkout off $LANES_BRANCH puts it back; never reset or check out over their work." 2 + [ "$cpf_head" = "$cpf_ref" ] || die "refusing to publish: HEAD is ${cpf_head:-detached}, not $cpf_ref, and the push sends $cpf_ref while \`pull --rebase\` moves HEAD, so what would be published is not what HEAD shows, and nothing was pushed (#194). Whoever moved this shared checkout off $LANES_BRANCH puts it back; never reset or check out over their work." 2 cpf_rc=0 cpf_base="$(git --no-replace-objects -C "$LANES_REPO" rev-parse -q --verify "refs/remotes/origin/$LANES_BRANCH^{commit}")" || cpf_rc=$? cpf_tip="$(git --no-replace-objects -C "$LANES_REPO" rev-parse -q --verify "$cpf_ref^{commit}")" || cpf_rc=$? @@ -2011,10 +2017,19 @@ cp_publish_fence() { return (reg != "" && index(p, reg) == 1) || (logp != "" && index(p, logp) == 1) || index(p, "handoffs/") == 1 || p == reg_path || p == alias_path } + function bytecode(p, n, part, i) { + if (p ~ /\.py[co]$/) return 1 + n = split(p, part, "/") + for (i = 1; i <= n; i++) + if (part[i] == "__pycache__" || part[i] == ".pytest_cache" || part[i] == ".mypy_cache" || + part[i] == ".ruff_cache" || part[i] == "node_modules" || part[i] == ".venv" || + part[i] == "venv" || part[i] == "site-packages") return 1 + return 0 + } NF == 0 { next } NF == 1 && $1 ~ /^[0-9a-f]+$/ { c = substr($1, 1, 12); seen++; next } { split($1, f, " "); st = f[5]; mode = f[2] - if (!(NF == 2 && (st == "A" || st == "M") && (mode == "100644" || mode == "100755") && inside($2))) + if (!(NF == 2 && (st == "A" || st == "M") && (mode == "100644" || mode == "100755") && inside($2) && !bytecode($2))) printf " %s %s %s %s\n", c, st, mode, $2 } END { if (seen != want) exit 3 }')" || cpf_rc=$? [ "$cpf_rc" = 0 ] || die "git could not read, or this helper could not classify, the local commits on $cpf_ref ahead of refs/remotes/origin/$LANES_BRANCH, so whether they are \`commit\`'s to publish is unknown; nothing was pushed (#194)" 2 @@ -2135,7 +2150,7 @@ commit_push() { fi cp_n="$(printf '%s\n' "$cp_ahead" | awk 'END { print NR }')" if [ "$CP_PUBLISH_AHEAD" != 1 ]; then - note "nothing staged for ${CP_PATHS[*]} — no commit made; the $cp_n local commit(s) ahead of origin/$LANES_BRANCH stay local, and only \`lanes-edit.sh commit\` publishes them" + note "nothing staged for ${CP_PATHS[*]} — no commit made; the $cp_n local commit(s) ahead of origin/$LANES_BRANCH stay local. \`lanes-edit.sh commit\` would publish all of them, so read \`git -C $LANES_REPO cherry -v origin/$LANES_BRANCH HEAD\` first: another lane's commits are its own to publish" return 0 fi cp_publish_fence diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index aa8944e7..31993ce7 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -325,7 +325,8 @@ def test_the_conflict_recovery_warns_that_a_reset_drops_a_peers_commits(box): assert count < moved < listing < warning < drops < reset, err assert f"cherry origin/main {aborted_at}" in err.splitlines()[count] captures = "\n".join(err.splitlines()[listing:drops + 1]) - assert "LANES(concurrent@" in captures and "LANES(pre-existing@" in captures, captures + assert "LANES(concurrent@" in captures, captures + assert "LANES(pre-existing@" in captures, captures assert "names no lane, so it is NOT yours" in captures, captures # The listing reads HEAD, which is what the reset acts on: a peer's commit @@ -354,48 +355,27 @@ def test_the_dirty_checkout_refusal_names_the_act_to_re_run(box): assert "then re-run the refused write NOTED" in result.stderr, result.stderr -def test_a_no_op_write_never_publishes_what_the_checkout_queued(box): +@pytest.mark.parametrize("exported", [False, True], ids=["plain", "CP_PUBLISH_AHEAD-exported"]) +def test_a_no_op_write_never_publishes_what_the_checkout_queued(box, exported): """THE #214 REVIEW'S BLOCKER. This shared checkout holds another lane's unpushed commit that deletes a file outside lane tooling's trees. A write whose result is the line already there must stay what it is on `main`: - exit 0, no network call, origin untouched. Only `commit` publishes.""" + exit 0, no network call, origin untouched. Only `commit` publishes, and + `CP_PUBLISH_AHEAD=1` EXPORTED changes nothing: the flag is the `commit` + arm's alone (lane openRepoShape-2's mutant M01 read it from the + environment and survived every other case).""" queued = box.queue("another lane's commit: retire the tool", delete=TOOL) head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") - result = box.helper("replace-in-row", LANE, "seeded", "seeded") + extra = {"CP_PUBLISH_AHEAD": "1"} if exported else {} + result = box.helper("replace-in-row", LANE, "seeded", "seeded", **extra) assert result.returncode == 0, result.stderr assert box.net_calls() == [], result.stderr assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n" assert box.unpublished()[0].startswith(queued) assert f"nothing staged for {REGISTER} — no commit made" in result.stderr - assert "stay local, and only `lanes-edit.sh commit` publishes them" in result.stderr - - -@pytest.mark.parametrize("queued", [ - {"delete": TOOL, "status": "D 000000", "path": TOOL}, - {"write": "workspaces/repoP.yaml", "status": "A 100644", "path": "workspaces/repoP.yaml"}, - {"delete": PEER_HANDOFF, "status": "D 000000", "path": PEER_HANDOFF}, -], ids=["deletes-a-tool", "outside-lane-tooling", "deletes-a-handoff"]) -def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, queued): - """`commit` publishes the register and handoff commits lane tooling strands - and nothing else: one queued commit that deletes a file, or touches a path - outside `lanes/` and `handoffs/`, refuses the WHOLE publish with 2, names - the commit and the path, and pushes nothing, not even the stranded edit.""" - box.set_state(box.wip, LANE, "LIVE · edited by hand") - box.wgit("commit", "-q", "-m", f"LANES({LANE}@Eagle): {SUBJECT}", "--", REGISTER) - if "write" in queued: - (box.wip / queued["write"]).parent.mkdir(parents=True, exist_ok=True) - sha = box.queue("a queued commit", write=queued.get("write", ""), - delete=queued.get("delete", "")) - head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") - - result = box.helper("commit", SUBJECT) - assert result.returncode == 2, result.stderr - assert f" {sha[:12]} {queued['status']} {queued['path']}" in result.stderr, result.stderr - assert "nothing was pushed" in result.stderr - assert "pushing the" not in result.stderr - assert box.net_calls() == [] - assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) + assert "stay local. `lanes-edit.sh commit` would publish all of them" in result.stderr + assert "another lane's commits are its own to publish" in result.stderr def test_the_publish_counts_what_its_list_leaves_out(box): @@ -409,9 +389,9 @@ def test_the_publish_counts_what_its_list_leaves_out(box): assert box.wgit("rev-parse", "HEAD") == box.ogit("rev-parse", "main") -# ---- #214's adversarial check: three ordinary checkout states got a deletion -# past the first fence, and a fourth was a race. Every case below exits 0 and -# deletes `scripts/link-estates` on origin against the first fence (`fab2cbc`). +# ---- #214's adversarial check and its two reviews: three ordinary checkout +# states got a deletion past the first fence (`fab2cbc`), and a fourth was a +# race. The cases below pin each, and the fence's other rules beside them. def _stranded_edit(box: Box) -> None: box.set_state(box.wip, LANE, "LIVE · edited by hand") @@ -426,10 +406,50 @@ def _refused_untouched(box: Box, result, head: str, origin: str) -> None: assert box.origin_file(TOOL) == "#!/usr/bin/env bash\n" -def test_a_merge_commits_own_deletion_is_refused(box): +# ---- `commit` refuses a queue it cannot vouch for. Each setup leaves the +# stranded register edit queued with one more commit, or in one more checkout +# state, and returns the words the refusal must name. The last three are the +# adversarial check's bypasses, and each published a deletion at `fab2cbc`. + +def _deletes_a_tool(box: Box) -> str: + _stranded_edit(box) + return f" {box.queue('a queued commit', delete=TOOL)[:12]} D 000000 {TOOL}" + + +def _outside_lane_tooling(box: Box) -> str: + _stranded_edit(box) + (box.wip / "workspaces").mkdir(exist_ok=True) + sha = box.queue("a queued commit", write="workspaces/repoP.yaml") + return f" {sha[:12]} A 100644 workspaces/repoP.yaml" + + +def _deletes_a_handoff(box: Box) -> str: + _stranded_edit(box) + return f" {box.queue('a queued commit', delete=PEER_HANDOFF)[:12]} D 000000 {PEER_HANDOFF}" + + +def _type_change(box: Box) -> str: + """A handoff turned into a symlink is a `T`, not a modification.""" + _stranded_edit(box) + (box.wip / PEER_HANDOFF).unlink() + (box.wip / PEER_HANDOFF).symlink_to(f"../{TOOL}") + box.wgit("add", "--", PEER_HANDOFF) + box.wgit("commit", "-q", "-m", "peer: the handoff becomes a symlink to the tool") + return f" {box.wgit('rev-parse', 'HEAD').strip()[:12]} T 120000 {PEER_HANDOFF}" + + +def _bytecode_in_handoffs(box: Box) -> str: + """#162's list rides out of a publish no more than out of a commit.""" + _stranded_edit(box) + (box.wip / "handoffs" / "__pycache__").mkdir() + path = "handoffs/__pycache__/x.cpython-312.pyc" + return f" {box.queue('peer: bytecode beside a handoff', write=path)[:12]} A 100644 {path}" + + +def _merge_in_queue(box: Box) -> str: """BYPASS 1. `git log --name-status` prints no diff for a merge, so a deletion made while resolving one went out unread, by the straight push a - peer's dirty file takes. A merge in the queue refuses.""" + peer's dirty file takes.""" _stranded_edit(box) box.wgit("checkout", "-q", "-b", "side", "origin/main") box.queue(f"handoff({PEER_LANE}@Eagle): a side line", write=PEER_HANDOFF) @@ -439,55 +459,51 @@ def test_a_merge_commits_own_deletion_is_refused(box): box.wgit("commit", "-q", "-m", "Merge branch side") merge = box.wgit("rev-parse", "HEAD").strip() box.peer_edit_in_wip("a peer mid-write") - head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") - - result = box.helper("commit", SUBJECT) - _refused_untouched(box, result, head, origin) - assert f" {merge[:12]} is a merge commit" in result.stderr, result.stderr + return f" {merge[:12]} is a merge commit" -def test_a_checkout_left_on_another_branch_is_refused(box): +def _head_off_branch(box: Box) -> str: """BYPASS 2. The fence read HEAD while the push sent `refs/heads/main`: with a deletion queued on local main and HEAD on a scratch branch holding a - register edit, the fence saw only the edit. HEAD must be the branch.""" + register edit, the fence saw only the edit.""" box.queue("another lane: retire the tool", delete=TOOL) box.wgit("checkout", "-q", "-b", "scratch", "origin/main") _stranded_edit(box) - head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") - - result = box.helper("commit", SUBJECT) - _refused_untouched(box, result, head, origin) - assert "HEAD is refs/heads/scratch, not refs/heads/main" in result.stderr, result.stderr + return "HEAD is refs/heads/scratch, not refs/heads/main" -def test_a_local_branch_named_origin_main_does_not_shadow_the_tracking_ref(box): - """BYPASS 3. A local branch called `origin/main` made `origin/main..HEAD` - an empty-looking range for the commits beneath it. The range is spelled in - full, so the queued deletion is read and refused.""" +def _shadowing_origin_main(box: Box) -> str: + """BYPASS 3. A local branch named `origin/main` shadowed the tracking ref.""" sha = box.queue("peer: retire the tool", delete=TOOL) box.wgit("branch", "origin/main", "HEAD") _stranded_edit(box) - head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") + return f" {sha[:12]} D 000000 {TOOL}" - result = box.helper("commit", SUBJECT) - _refused_untouched(box, result, head, origin) - assert f" {sha[:12]} D 000000 {TOOL}" in result.stderr, result.stderr +REFUSALS = { + "deletes-a-tool": _deletes_a_tool, + "outside-lane-tooling": _outside_lane_tooling, + "deletes-a-handoff": _deletes_a_handoff, + "type-change": _type_change, + "bytecode-in-handoffs": _bytecode_in_handoffs, + "merge-in-queue": _merge_in_queue, + "head-off-branch": _head_off_branch, + "shadowing-origin-main": _shadowing_origin_main, +} -def test_a_type_change_is_refused(box): - """A handoff turned into a symlink is a `T`, not a modification: the fence - passes only regular files, added or modified.""" - _stranded_edit(box) - (box.wip / PEER_HANDOFF).unlink() - (box.wip / PEER_HANDOFF).symlink_to(f"../{TOOL}") - box.wgit("add", "--", PEER_HANDOFF) - box.wgit("commit", "-q", "-m", "peer: the handoff becomes a symlink to the tool") - sha = box.wgit("rev-parse", "HEAD").strip() - head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") +@pytest.mark.parametrize("variant", list(REFUSALS)) +def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, variant): + """`commit` publishes the register and handoff commits lane tooling strands + and nothing else. One queued commit, or one checkout state, it cannot vouch + for refuses the WHOLE publish with 2, names it, makes no network call and + pushes nothing, not even the stranded register edit.""" + expect = REFUSALS[variant](box) + head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") result = box.helper("commit", SUBJECT) _refused_untouched(box, result, head, origin) - assert f" {sha[:12]} T 120000 {PEER_HANDOFF}" in result.stderr, result.stderr + assert expect in result.stderr, result.stderr + assert "pushing the" not in result.stderr @pytest.mark.parametrize("path", ["rebase", "straight"]) From ee33a330940e56fbccb0b40f78cf77687dcd3011 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 01:21:34 +0000 Subject: [PATCH 09/10] Say what the publish fence checks: register and handoff writes by path and kind, never whose they are (lane openRepoShape-2's SHOULD 2 wording) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 6 +++--- tests/test_commit_push_published_edit_194.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 4caade66..acebf1db 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1607,7 +1607,7 @@ replace_line() { # `migrate-state-cells`) wanted the file to SAY something rather than to # differ from what it said. Nor does it push: a write with nothing staged # publishes nothing, whatever this shared checkout has queued ahead of - # origin; only `commit` does that, and only lane tooling's own commits (#194). + # origin; only `commit` does that, and only what its path filter passes (#194). if cmp -s -- "$pre" "$out"; then REPLACE_LINE_NOOP=1 note "line $n of $LANES_FILE is already exactly what this write asked for — nothing rewritten, and nothing to commit" @@ -2037,7 +2037,7 @@ cp_publish_fence() { note "of the local commits on $cpf_ref ahead of origin/$LANES_BRANCH, these are not register or handoff writes (a merge or root commit, or a change that is not an added or modified regular file under ${LANES_PREFIX:-the register directory} or handoffs/):" [ -z "$cpf_odd" ] || printf '%s\n' "$cpf_odd" >&2 [ -z "$cpf_bad" ] || printf '%s\n' "$cpf_bad" >&2 - die "refusing to publish: \`commit\` pushes only the register and handoff commits lane tooling strands (#194), so nothing was pushed. A lane rename (Amendment 16) deletes and re-adds its log and is refused here too; the next register write from this checkout carries it. Anything else, whoever queued it publishes it; never reset it away. Read them: git -C $LANES_REPO log --stat refs/remotes/origin/$LANES_BRANCH..$cpf_ref" 2 + die "refusing to publish: \`commit\` pushes only register and handoff writes (#194), so nothing was pushed. A lane rename (Amendment 16) deletes and re-adds its log and is refused here too; the next register write from this checkout carries it. Anything else, whoever queued it publishes it; never reset it away. Read them: git -C $LANES_REPO log --stat refs/remotes/origin/$LANES_BRANCH..$cpf_ref" 2 fi CP_FENCE_TIP="$cpf_tip" } @@ -2136,7 +2136,7 @@ commit_push() { # into a push of whatever this SHARED checkout has queued (the #214 review # measured exactly that deleting `scripts/link-estates` on origin). # - # AND `commit` PUBLISHES ONLY WHAT LANE TOOLING STRANDS, and only what it read: + # AND `commit` PUBLISHES ONLY REGISTER AND HANDOFF WRITES, and only what it read: # `cp_publish_fence` (above) reads the queue on the branch the push sends and # refuses the whole publish (2) for anything that is not a register or # handoff write. The push is pinned to the commit the fence read, and every diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 31993ce7..6f723bec 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -494,9 +494,9 @@ def _shadowing_origin_main(box: Box) -> str: @pytest.mark.parametrize("variant", list(REFUSALS)) def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, variant): - """`commit` publishes the register and handoff commits lane tooling strands - and nothing else. One queued commit, or one checkout state, it cannot vouch - for refuses the WHOLE publish with 2, names it, makes no network call and + """`commit` publishes register and handoff writes, by path and kind, and + nothing else. One queued commit, or one checkout state, it cannot vouch for + refuses the WHOLE publish with 2, names it, makes no network call and pushes nothing, not even the stranded register edit.""" expect = REFUSALS[variant](box) head, origin = box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main") From 656b1914526e2e3be94500d11c7eddc98064126f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 04:09:27 +0000 Subject: [PATCH 10/10] #194: a publish the pull empties exits 0 and says so; pin the fence's rename and prefix guards and the conflict RECOVERY's two STOP lines Lane openRepoShape-2's take-or-leave items on #214 (6243280936), taken: - A queue that `pull --rebase` empties, because origin already holds every commit in it by content, no longer pushes origin its own tip and prints "pushed". `cp_publish_refence` reads the count it re-fenced (CP_FENCE_AHEAD); at 0 it says so and exits 0, no push. Exit 0's row in both tables names the case. - SHOULD 4: two refusal variants pin the fence's guards. A log renamed out of lanes/ is named as its deletion (red under `-M`), and lanes-archive/ beside lanes/ is not the register's (red without the trailing slash). The fence now adds that slash itself to both register prefixes. - NIT 5: the conflict RECOVERY's "STOP and take it to the lane its subject names" line and its reset line's "only if every '+' line is yours" are asserted word for word, in order. Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 5 +- lanes-edit.sh | 31 +++++++++-- tests/test_commit_push_published_edit_194.py | 58 ++++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 5a630481..941759d0 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -107,6 +107,9 @@ path filter, not an ownership check: another lane's register write passes it, so read `git cherry -v origin/main HEAD` before you publish a shared queue. Only `commit` publishes that queue: any other write with nothing to stage leaves it local, and with nothing ahead `commit` is the no-op it always was. +When `pull --rebase` finds every queued commit on origin already, by content +under other shas, it drops them all. The publish then says so and exits 0 +without a push. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -1182,7 +1185,7 @@ else. | code | meaning | |---|---| -| 0 | done | +| 0 | done, including `commit`'s publish of a queue that `pull --rebase` emptied because origin already held every commit in it by content (#194; it says so, and sends no push) | | 1 | environment — no register, no writer | | 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them) | | 3 | the edit is never a permanent loss, but this attempt cannot confirm whether it also reached origin — a rebase conflict (not pushed by this attempt; a later write from this checkout may already carry it to origin, so look by content before you retry), a network timeout on a push or a pull (a hung one often already landed), or six attempts exhausted because a peer's uncommitted file blocks every rebase | diff --git a/lanes-edit.sh b/lanes-edit.sh index acebf1db..f46de30a 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -425,7 +425,7 @@ # may write the marker's vocabulary into a row at all. # # EXIT CODES — every subcommand, one table, no two meanings on one number -# 0 done +# 0 done (and #194's publish whose queue `pull --rebase` emptied) # 1 environment (no register, no writer) # 2 refusal: bad arguments, the object is held, an unknown alias, a # checkout that cannot be rebased, — Amendment 15 — a register holding @@ -1111,6 +1111,7 @@ CP_PUBLISH_AHEAD=0 # empty, every push sends the branch as it stands, as it always has (#194). CP_PUSH_SPEC="" CP_FENCE_TIP="" +CP_FENCE_AHEAD=0 # die "" [] — the MESSAGE is $1 alone. It used to be "$*", # which joined the exit code on to the end of every refusal that passed one: @@ -1990,9 +1991,16 @@ BRT_EOF # get back, and the fence keeps one rule for it. A read or a classification # that fails refuses too (2), because it proves nothing. # -# It leaves CP_FENCE_TIP at the commit it read, and the publish pushes exactly -# that commit (`:refs/heads/`). A commit another lane makes in this -# shared checkout between the read and the push stays local. +# Two guards have a test each (lane openRepoShape-2's SHOULD 4). `--no-renames` +# reads a rename as the deletion it is: read as an `R` line, the refusal would +# name a rename and not the path origin loses. And the register's directories +# are matched with their trailing `/`, so `lanes-archive/` beside `lanes/` is +# not inside it. +# +# It leaves CP_FENCE_TIP at the commit it read and CP_FENCE_AHEAD at how many +# it read, and the publish pushes exactly that commit (`:refs/heads/`). +# A commit another lane makes in this shared checkout between the read and the +# push stays local. cp_publish_fence() { cpf_ref="refs/heads/$LANES_BRANCH" cpf_head="$(git -C "$LANES_REPO" symbolic-ref -q HEAD 2>/dev/null || :)" @@ -2011,7 +2019,8 @@ cp_publish_fence() { cpf_n="$(printf '%s\n' "$cpf_pairs" | awk 'END { print NR }')" || cpf_rc=$? cpf_raw="$(printf '%s\n' "$cpf_pairs" | git --no-replace-objects -C "$LANES_REPO" -c core.quotePath=false diff-tree --stdin --always -r --no-renames --raw --ignore-submodules=none)" || cpf_rc=$? fi - cpf_bad="$(printf '%s\n' "$cpf_raw" | awk -F '\t' -v want="$cpf_n" -v reg="$LANES_PREFIX" -v logp="$LANES_LOG_PREFIX" \ + cpf_reg="${LANES_PREFIX:+${LANES_PREFIX%/}/}"; cpf_logp="${LANES_LOG_PREFIX:+${LANES_LOG_PREFIX%/}/}" + cpf_bad="$(printf '%s\n' "$cpf_raw" | awk -F '\t' -v want="$cpf_n" -v reg="$cpf_reg" -v logp="$cpf_logp" \ -v reg_path="$LANES_PATH" -v alias_path="$LANES_ALIASES_PATH" ' function inside(p) { return (reg != "" && index(p, reg) == 1) || (logp != "" && index(p, logp) == 1) || @@ -2040,13 +2049,25 @@ cp_publish_fence() { die "refusing to publish: \`commit\` pushes only register and handoff writes (#194), so nothing was pushed. A lane rename (Amendment 16) deletes and re-adds its log and is refused here too; the next register write from this checkout carries it. Anything else, whoever queued it publishes it; never reset it away. Read them: git -C $LANES_REPO log --stat refs/remotes/origin/$LANES_BRANCH..$cpf_ref" 2 fi CP_FENCE_TIP="$cpf_tip" + CP_FENCE_AHEAD="$cpf_n" } # The publish's CP_AFTER_REBASE: `pull --rebase` has rewritten the queue onto # what origin holds now, and may have taken in a commit made since the first # read, so the fence reads it again and the push is pinned to what it read. +# THE PULL CAN EMPTY THE QUEUE (lane openRepoShape-2, #214 6243280936): origin +# may hold every queued commit already, by content under other shas, because +# another workstation made the same edit or a peer's write carried it. The +# rebase drops each one, and a push would send origin its own tip and print +# "pushed". So the publish ends here, exit 0, as the no-op it now is, and it +# says so. Only `commit` installs this hook, and this commit_push is its last +# act, so the exit is that return. cp_publish_refence() { cp_publish_fence + if [ "$CP_FENCE_AHEAD" = 0 ]; then + note "origin/${LANES_BRANCH} already holds every commit this publish listed: \`pull --rebase\` found each one there by content and dropped it, so none is left to push and no push was sent (#194)" + exit 0 + fi CP_PUSH_SPEC="$CP_FENCE_TIP:refs/heads/$LANES_BRANCH" } diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 6f723bec..d27e44ee 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -328,6 +328,12 @@ def test_the_conflict_recovery_warns_that_a_reset_drops_a_peers_commits(box): assert "LANES(concurrent@" in captures, captures assert "LANES(pre-existing@" in captures, captures assert "names no lane, so it is NOT yours" in captures, captures + # NIT 5 (lane openRepoShape-2, 6243280936): the two lines that stop a + # reader from resetting a peer's commit away, word for word, because + # lane openRepoTools-5's #68 waits on these words. + stop = _line_of(err, "if any '+' line is not your own write, STOP and take it to the lane its subject names") + assert listing < stop < warning, err + assert "# only if every '+' line is yours" in err.splitlines()[reset], err # The listing reads HEAD, which is what the reset acts on: a peer's commit # made AFTER the abort is in it too, and the moved-checkout STOP fires. @@ -480,6 +486,30 @@ def _shadowing_origin_main(box: Box) -> str: return f" {sha[:12]} D 000000 {TOOL}" +def _renames_out_of_lanes(box: Box) -> str: + """SHOULD 4 (lane openRepoShape-2, 6243280936), THE RENAME GUARD. A log + moved out of `lanes/` is a deletion there, and `--no-renames` names it so. + Read with rename detection it would be one `R100` line instead.""" + log = f"lanes/log/{PEER_LANE}.md" + box.write(box.wip, log, f"# {PEER_LANE}'s log\n") + box.wgit("add", "--", log) + box.wgit("commit", "-q", "-m", f"LANES({PEER_LANE}@Eagle): its log") + box.wgit("push", "-q", "origin", "main") + _stranded_edit(box) + box.wgit("mv", log, f"scripts/{PEER_LANE}.md") + box.wgit("commit", "-q", "-m", "peer: move a log out of lanes/") + return f" {box.wgit('rev-parse', 'HEAD').strip()[:12]} D 000000 {log}" + + +def _beside_the_register_directory(box: Box) -> str: + """SHOULD 4, THE PREFIX GUARD. `lanes/` is a directory: `lanes-archive/` + beside it is not the register's, though its name starts the same way.""" + _stranded_edit(box) + (box.wip / "lanes-archive").mkdir() + path = "lanes-archive/notes.md" + return f" {box.queue('peer: notes beside the register', write=path)[:12]} A 100644 {path}" + + REFUSALS = { "deletes-a-tool": _deletes_a_tool, "outside-lane-tooling": _outside_lane_tooling, @@ -489,6 +519,8 @@ def _shadowing_origin_main(box: Box) -> str: "merge-in-queue": _merge_in_queue, "head-off-branch": _head_off_branch, "shadowing-origin-main": _shadowing_origin_main, + "renames-out-of-lanes": _renames_out_of_lanes, + "beside-the-register-directory": _beside_the_register_directory, } @@ -525,6 +557,32 @@ def test_the_push_sends_the_commit_the_fence_read(box, path): assert box.wgit("rev-parse", "HEAD~1") == box.ogit("rev-parse", "main") +def test_a_publish_the_pull_empties_says_so_and_sends_no_push(box): + """Lane openRepoShape-2's trap (6243280936). The queue `commit` read can + already be on origin by content, under other shas: here another workstation + made the same edit. `pull --rebase` drops it, and the push that followed + sent origin its own tip and printed "pushed". The re-read after the pull + finds nothing left, says so, sends no push and exits 0.""" + _stranded_edit(box) + box.set_state(box.peer, LANE, "LIVE · edited by hand") + box.git("-C", str(box.peer), "commit", "-q", "-am", "the same edit, made elsewhere") + box.git("-C", str(box.peer), "push", "-q", "origin", "main") + origin = box.ogit("rev-parse", "main") + + result = box.helper("commit", SUBJECT) + assert result.returncode == 0, result.stderr + assert "pushing the 1 local commit(s) origin/main does not have" in result.stderr + assert ("origin/main already holds every commit this publish listed: `pull --rebase` " + "found each one there by content and dropped it") in result.stderr, result.stderr + assert "pushed origin/" not in result.stderr, result.stderr + calls = [f" {c} " for c in box.net_calls()] + assert any(" pull " in c for c in calls), calls + assert not any(" push " in c for c in calls), calls + assert box.ogit("rev-parse", "main") == origin + assert box.wgit("rev-parse", "HEAD") == origin + assert box.origin_subjects().count("the same edit, made elsewhere") == 1 + + def test_a_lane_rename_is_refused_and_told_what_carries_it(box): """A DELIBERATE OVER-REFUSAL. A lane rename deletes its log to re-add it, and a deletion is the one change the fence never lets through: the refusal