diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 9b0da28e..0019bb72 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -94,7 +94,22 @@ 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`. 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 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/`, 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. +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 @@ -205,7 +220,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 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. @@ -1194,9 +1219,9 @@ 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 | +| 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 07f945e2..2aa9a6df 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) @@ -424,7 +426,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 @@ -432,7 +434,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, 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 @@ -1102,6 +1107,15 @@ 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 +# 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="" +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: @@ -1608,7 +1622,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 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" @@ -1832,9 +1848,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 } @@ -1981,6 +1998,114 @@ 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/`, 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. +# +# 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 || :)" + [ "$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=$? + 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_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) || + 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) && !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 + 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 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" +} + # 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 @@ -2054,11 +2179,47 @@ 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, 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 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 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 + # `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 - note "nothing staged for ${CP_PATHS[*]} — no commit made" - return 0 + 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 + 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. \`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 + 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 [ -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 @@ -2082,7 +2243,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" @@ -2111,7 +2272,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 @@ -2121,7 +2282,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: 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 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 @@ -2131,7 +2294,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 @@ -2185,8 +2348,13 @@ 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 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 # 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 @@ -14205,6 +14373,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 new file mode 100644 index 00000000..d27e44ee --- /dev/null +++ b/tests/test_commit_push_published_edit_194.py @@ -0,0 +1,641 @@ +# 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" +#: 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. +SCRUBBED_PREFIXES = ("LANES_", "LANE_START_", "CLAUDE_", "OPENREPOTOOLS_", + "FAKE_", "GIT_", "TMUX") +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 "$@" +""" + +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.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)) + (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 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", "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) + 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 '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", + "--", 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 ("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 + 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] + 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") == "" + + aborted_at = box.wgit("rev-parse", "HEAD").strip() + count = _line_of(err, "cherry origin/main") + 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 < 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, 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. + 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): + """`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") + # 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 + + +@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, 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") + 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. `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): + """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, 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 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") + 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" + + +# ---- `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.""" + _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") + return f" {merge[:12]} is a merge commit" + + +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.""" + box.queue("another lane: retire the tool", delete=TOOL) + box.wgit("checkout", "-q", "-b", "scratch", "origin/main") + _stranded_edit(box) + return "HEAD is refs/heads/scratch, not refs/heads/main" + + +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) + 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, + "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, + "renames-out-of-lanes": _renames_out_of_lanes, + "beside-the-register-directory": _beside_the_register_directory, +} + + +@pytest.mark.parametrize("variant", list(REFUSALS)) +def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, variant): + """`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") + result = box.helper("commit", SUBJECT) + _refused_untouched(box, result, head, origin) + assert expect in result.stderr, result.stderr + assert "pushing the" not in 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_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 + 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