diff --git a/lane-worktrees b/lane-worktrees index 11833b6..999ae96 100755 --- a/lane-worktrees +++ b/lane-worktrees @@ -490,13 +490,18 @@ class Helper: self._fetched = False def call(self, *args: str, env_extra: dict | None = None, fetch: bool = False, - timeout: int = 300): + write: bool = False, timeout: int = 300): + """`fetch` asks for one fetch per session; every later read is made + with `LANES_NO_FETCH=1`. A `write` is never given it: for a writer, a + fetch that did not run is a fetch that failed, and lanes-edit.sh's + Amendment 23 S3 refuses it (2, ratified choice 2), so the sweep's + NOTED line was refused after its first removal (#198, slice 1b).""" if not self.path: return subprocess.CompletedProcess(args, 127, "", "no lanes-edit.sh beside this " "command or on PATH") env = dict(os.environ) env["LANES_INDEX"] = "off" - if not fetch or self._fetched: + if not write and (not fetch or self._fetched): env["LANES_NO_FETCH"] = "1" if env_extra: env.update(env_extra) @@ -669,7 +674,7 @@ class Helper: if session: extra["LANES_SESSION"] = session return self.call("log", "NOTED", f"lane:{lane}", register_text(text), - env_extra=extra, fetch=False) + env_extra=extra, write=True) LANE_LINE_RE = re.compile(r"^(?:STARTED|PAUSED|RESUMED|ENDED|RETIRED) — lane [^,]+, " diff --git a/lanes-edit.sh b/lanes-edit.sh index 98b0342..7c896b7 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -47,17 +47,17 @@ # `cat`, `cut`, `head`, `tail`, `grep`, `python open(...,'a')` are all safe. # # USAGE -# lanes-edit.sh [--no-sweep|--sweep] verify-row -# lanes-edit.sh [--no-sweep|--sweep] set-row-state " · " -# lanes-edit.sh [--no-sweep|--sweep] replace-in-row "" "" [""] -# lanes-edit.sh [--no-sweep|--sweep] append-session-id "" "" [""] +# lanes-edit.sh [--no-sweep] verify-row +# lanes-edit.sh [--no-sweep] set-row-state " · " +# lanes-edit.sh [--no-sweep] replace-in-row "" "" [""] +# lanes-edit.sh [--no-sweep] append-session-id "" "" [""] # (the anchor is matched INSIDE THE # SESSION CELL and nowhere else, and the # text is appended at the END of it) -# lanes-edit.sh [--no-sweep|--sweep] append-line "" # LANDING/LANDED lines +# lanes-edit.sh [--no-sweep] append-line "" # LANDING/LANDED lines # (attribution: $LANES_LANE, else the # "lane " the text itself names) -# lanes-edit.sh [--no-sweep|--sweep] add-row "" +# lanes-edit.sh [--no-sweep] add-row "" # lanes-edit.sh commit "" # commit a hand edit; with # nothing staged, publish the stranded # register and handoff commits (#194) @@ -444,28 +444,43 @@ # 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 (and #194's publish whose queue `pull --rebase` emptied) +# 0 done — and for a write, CONFIRMED: Amendment 23 S4 reads origin back +# and finds the commit on it before it says `pushed` # 1 environment (no register, no writer) — and, AMENDMENT 23 S1, a lane # checkout's path that holds something other than a clone of the register # (every routed write, and `lane-checkout`), which is refused and never -# deleted, moved or repaired; `refresh-workspace` with no W -# 2 refusal: bad arguments, the object is held, an unknown alias, a -# checkout that cannot be rebased, — Amendment 15 — a register holding +# deleted, moved or repaired; `refresh-workspace` with no W; a checkout, +# or the directory its lock lives in, removed while the write waited for +# that lock (360: lane-end's sweep of a CLOSED lane), said at once +# 2 refusal: bad arguments, the object is held, an unknown alias, — +# Amendment 15 — a register holding # two rows whose lane names differ only by case, which every writer and # `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`), or — #194 — `commit` refusing to +# (`commit_publish`, `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; or — AMENDMENT 23 — a # write that names no lane, a `LANES_REPO` that IS W (S5), a `LANES_DIR` # or `LANES_FILE` outside the checkout the write goes through (S2), a +# `LANES_REPO` naming a directory below its checkout's top level (S3, +# before any edit), a # lane checkout's clone that failed (S1), a W that is itself a lane # checkout (S1, under the opt-in and for `lane-checkout` and # `refresh-workspace`), a routed write while W's register holds an # uncommitted edit (S5), and `refresh-workspace`'s skip, which changed -# nothing. EIGHT MEANINGS ON ONE NUMBER, and they +# nothing; or — AMENDMENT 23 S3, every writer, with nothing written — a +# checkout that cannot be brought to origin's tip: a fetch that failed +# (`LANES_NO_FETCH=1` is one, for a writer), an operation in progress or +# unmerged entries, HEAD off the branch, staged entries that are not the +# write's own, unpushed commits (the publish act excepted), a diverged +# branch, a pending change to a path the write commits (the register's, +# for every writer but `commit`), or a fast-forward git refused; the +# withdrawn `--sweep`; the publish act over a stack origin has moved +# past (S4, Open question 2); and `claim` or `add-row` meeting origin +# moved under its own checks on each of six attempts (S3, S4), before any +# edit. 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 @@ -506,17 +521,17 @@ # carry both without the caller guessing, so the newer reads spend a number # of their own and the older half of this file keeps 2 where it always was. # Clause (h)'s table is the contract for which read uses which. -# 3 the edit is never a permanent loss, but this attempt cannot confirm -# whether it also reached origin. THREE CAUSES (Copilot round 3 on #50, -# 5203261904, naming the other two this row used to leave out): a -# rebase conflict this attempt could not resolve — not pushed BY THIS -# ATTEMPT; a later write from this checkout may already carry it to -# origin, so LOOK by CONTENT before you retry (round 4, 5203455553: -# SHA ancestry alone misses a peer's rebase of it, which changes the -# hash but not the patch); `git_timeout_die`'s network timeout on a -# push OR a pull, where a hung one often already landed; or six -# attempts exhausted because a peer's own uncommitted file blocks -# every rebase. +# 3 PUSHED, NOT CONFIRMED: (Amendment 23 S4, its one cause). The write +# committed and pushed — or its push timed out, and a hung push often +# lands — and the read-back could not confirm the commit on origin: the +# fetch failed or timed out, or origin does not hold it. Never reported +# as success. The commit stays on HEAD — unless another writer in the +# checkout rewrote it while its push was in flight (an unlocked +# writer's rebase), which the words then say: it is not on HEAD at all, and +# what reached origin is read by content — and the next write's S3 finds it +# by content either way: published, there is nothing to do; not, every +# write refuses until the publish act sends it or an archive (S6.3) +# takes it. # 4 the mutex could not be taken within 60s — or, Amendment 23 S1, a lane # checkout's creation lock within the clone's budget and a minute # 5 an edit moved more than one line and was refused — or, Amendment 15, a @@ -525,7 +540,28 @@ # copy, write or result could not be proved; where the write had cut the # register short its earlier bytes are put back first, and the refusal # says which of the two the register holds -# 6 git add / commit / push failed +# 6 git add or commit failed — or, AMENDMENT 23 S4, the commit is KEPT ON +# HEAD AND NAMED (sha, subject, `git show`): a sixth push refused because +# origin moved (the bound, ratified choices 4 and 5; where S3 finds the +# sixth move before any edit, nothing is committed and it is 2), a +# replay's drop refused over an uncommitted change, a commit another +# writer made on top of this one in the checkout before the replay +# drops it, or under it (this write's commit is known by what it is, +# never by HEAD read after the fact, and the drop moves the branch only +# from that commit and only where it sits on origin's history, so it +# never takes another; `commit`'s carry form drops to its own +# commit's parent by design, so one under it stays, the hand edit +# goes back to the working tree with its copy kept, and the re-run's +# S3 refuses on it: 2, not 6), a commit made in the same moment as this +# write's that cannot be told from it (nothing is pushed), or a push (a +# write's, or the publish act's) +# refused with origin unmoved, which no replay can mend. Or another +# writer in the checkout REWROTE this write's commit (a `pull --rebase` +# with no lock): it is not on HEAD at all, and what reached origin is +# read by content. Or, where the drop's tree step was refused and +# another writer committed on the branch in between, the commit is +# named OFF HEAD, with `reflog -5`. Every later write from that +# checkout refuses under S3 until the lane publishes or archives it # 7 ANOTHER ACT GOT THERE FIRST, and this one wrote nothing. `claim`: # CLAIM-LOST, another lane's claim landed on main first. # `set-lane-state`: the lifecycle fence did not match — the state, the @@ -576,27 +612,20 @@ # — or a link on the way it cannot follow — leaves stat as silent as a # name that is gone, and the message says so rather than "is there". # -# --no-sweep (DEFAULT, added 2026-09-09 after 0d84d34/a1f2438 swept another -# lane's uncommitted hand edit into an unrelated commit): every mutating -# subcommand checks, before making its own edit, whether LANES.md is -# already dirty. If it is, that content is someone else's — never this -# invocation's — so it is committed FIRST, on its own, as -# `LANES(pre-existing@): capture an uncommitted registry edit -# (row )`, before this invocation's own edit is made and committed. -# A WARNING (with `git diff --stat` and the first 200 chars of every -# changed line) always prints first. This never fails the call — the -# write still goes through. Amendment 7's `log`, `claim` and `release` take -# the same capture for the REGISTER between the two halves of their -# dirty-checkout guard, which is why a peer's half-written `lanes/LANES.md` -# does not refuse them (Addendum 3, R11); any OTHER modified tracked file -# still does, and is refused before the capture rather than after it. -# --sweep — the old behaviour: leave the pre-existing content staged so it -# lands inside this invocation's own commit. Still warns exactly as above, -# and appends " + sweeps uncommitted edit to row " to the commit -# subject, so the sweep is never silent. +# --no-sweep is accepted and changes nothing. It named the default of the +# capture that came in on 2026-09-09 after 0d84d34/a1f2438 swept another +# lane's uncommitted hand edit into an unrelated commit: a writer that found +# LANES.md already dirty committed that content first, as its own +# `LANES(pre-existing@)` commit. AMENDMENT 23 S3 STEP 4 +# WITHDRAWS THE CAPTURE (ratified choice 9): a pending hand edit of the +# register refuses every writer (2, nothing written) but `commit`, which +# carries it across the fast-forward and commits it as its own act. +# --sweep is withdrawn with it, and refused (2) for every verb but the two +# hooks: it left that content staged, to land inside this invocation's +# own commit. # `commit` has no edit of its own to separate from a pre-existing one — its -# job IS to wrap whatever is already dirty. It instead warns and appends -# the same " + sweeps uncommitted edit to row " note when the dirty +# job IS to wrap whatever is already dirty. It warns and appends +# " + sweeps uncommitted edit to row " to its subject when the dirty # content touches a row other than the caller's own $LANES_LANE. # # ENVIRONMENT @@ -1141,11 +1170,40 @@ if [ -n "${LANES__CHECKOUT_LANE:-}${LANES__PID:-}" ] && [ "${LANES__PID:-}" != " unset LANES__CHECKOUT_LANE LANES__PID LANES_REPO LANES_DIR LANES_FILE fi LANES_ROUTED="${LANES__CHECKOUT_LANE:-}" # the lane, in the re-run process only +# AMENDMENT 23 S4 — THE REPLAY'S OWN MARKERS. A refused push drops this write's +# commit and runs this file again with the same argv (`commit_publish`), and +# `exec` keeps the pid: `LANES__REPLAY_PID` equal to `$$` is that re-run, and +# `LANES__ATTEMPT` its attempt (2 to 6). With any other pid they were +# inherited and mean nothing. They are read here and UNSET AT ONCE, so no +# process this one starts ever sees them; `claim`'s takeover decision crosses +# the `exec` the same way, and so do Rule 1's reads and the stale claim's +# URL that attempt 1 made before the lock (lane openRepoTools-3's review of +# #307, 284), so a re-run makes no GitHub read with the lock held. So do the +# names of the hand-edit copies this run has written (`CP_CARRY_COPIES`, lane +# 3's 393): a lost race replays `commit`'s carry, which writes a second copy, +# and every one goes once origin has the edit (`cp_pushed`). So does the name of +# the file that holds Rule 1's reads whole where the carried copy is capped +# (`CP_CLAIM_READS_FILE`, lane 3's 706). Not `LANES__PID`: an unrouted write has no routing +# markers, and exporting one would make a child helper drop its caller's +# `LANES_REPO`, `LANES_DIR` and `LANES_FILE` (the rule above). +CP_ATTEMPT=1; CP_REPLAYING=0; CP_CLAIM_FROM=""; CP_CLAIM_DEAD=""; CP_CLAIM_READS=""; CP_CLAIM_URL=""; CP_CARRY_COPIES="" +CP_CLAIM_READS_FILE="" +if [ -n "${LANES__REPLAY_PID:-}" ] && [ "${LANES__REPLAY_PID:-}" = "$$" ]; then + CP_REPLAYING=1 + case "${LANES__ATTEMPT:-}" in [2-6]) CP_ATTEMPT="$LANES__ATTEMPT" ;; esac + CP_CLAIM_FROM="${LANES__CLAIM_FROM:-}"; CP_CLAIM_DEAD="${LANES__CLAIM_DEAD:-}" + CP_CLAIM_READS="${LANES__CLAIM_READS:-}"; CP_CLAIM_URL="${LANES__CLAIM_URL:-}" + CP_CARRY_COPIES="${LANES__CARRY_COPIES:-}" + CP_CLAIM_READS_FILE="${LANES__CLAIM_READS_FILE:-}" +fi +unset LANES__REPLAY_PID LANES__ATTEMPT LANES__CLAIM_FROM LANES__CLAIM_DEAD LANES__CLAIM_READS LANES__CLAIM_URL LANES__CARRY_COPIES LANES__CLAIM_READS_FILE # THE ROUTING IS OPT-IN UNTIL SLICE 1B (the review of opensoft/openRepoTools#282, -# MUST 2). A lane checkout is only as fresh as its own last write until S3's -# fetch before the edit lands, and `commit_push` rebases only after a rejected -# push, so a checkout one commit behind wedges on its first conflicting write: -# the stranding Amendment 23 exists to end. So without `LANES_ROUTE_WRITES=1` +# MUST 2). Before slice 1b a lane checkout was only as fresh as its own last +# write, and the old push loop rebased only after a rejected push, so a +# checkout one commit behind wedged on its first conflicting write: the +# stranding Amendment 23 exists to end. S3 now fetches and fast-forwards +# before every edit, and the flip of this default is a question put to lane +# openRepoTools-3 with slice 1b, not made by it. So without `LANES_ROUTE_WRITES=1` # every write goes through `LANES_REPO` (W by default) and every lock is that # checkout's, exactly as before; the re-run process is routed by definition. LANES_ROUTE_ON=0 @@ -1233,20 +1291,45 @@ LANES_PATH="${LANES_PATH:-$(git -C "${LANES_DIR:-.}" rev-parse --show-prefix 2>/ # The pathspecs the current invocation is writing. Every subcommand that # predates Amendment 7 writes exactly one, the register; a LANDING or LANDED -# writes two. CP_AFTER_REBASE is a function commit_push calls after each -# `pull --rebase`, before it pushes — `claim` uses it to notice that another -# lane's claim landed first. +# writes two. (`CP_AFTER_REBASE`, the hook a `pull --rebase` called, is gone +# with the pull: Amendment 23 S4 replays the whole verb instead.) 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). +# staged, commit_publish publishes the local commits ahead of origin for that +# one write and for no other (#194; Amendment 23 S4's publish act). 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="" +# The publish pushes the commit its fence read, `:refs/heads/`. CP_FENCE_TIP="" CP_FENCE_AHEAD=0 +# `NAME=value` words a verb wants carried across its replay's `exec` (only +# `claim` fills it: the takeover it decided on). Never from the environment. +CP_REPLAY_ENV=() +# The one file a write edits IN PLACE and does not create, where a refused +# push must not replay over its absence: set by `write-handoff` alone (slice +# 2a), for `--stamp` and `--prepend`, and never from the environment. A peer +# that deletes that file while this write's push is in flight leaves the +# replay nothing to write into, so its re-run says "Nothing was written" and +# the write is only in the commit it dropped. Where origin no longer has the +# file and this write's base did, the commit is kept on HEAD and named +# instead (6), before anything is dropped (`cp_replay`). +CP_KEEP_IF_GONE="" +# Set by `commit`'s carry form alone: its edit is the hand edit in the working +# tree, which no argv can make again, so a replay gives it back as the hand +# edit it was instead of dropping it with the commit (`cp_replay`). +CP_REPLAY_KEEPS_EDIT=0 +# Set by `claim` and `add-row` alone, whose own checks run BEFORE the lock on +# what was last fetched: where S3's fetch then finds origin's tip moved, the +# verb runs again from the top on the new tip before anything is written +# (`cs_recheck`). Never from the environment. +CS_RECHECK=0 +# The tip of `refs/remotes/origin/` this process's FIRST `log_sync` +# left, which is the tip every check after it read (`cs_seen_mark`); S3 +# compares origin's tip with it. Set once per process, and never from the +# environment: a replay's `exec` starts it again. +CS_SEEN=""; CS_SEEN_SET=0 +# The lane S3 was given, by argument or by LANES_LANE (the verb's own), kept for +# the words printed after it (`cp_archive_steps`, 702). Never from the environment. +CS_LANE="" # 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: @@ -1353,13 +1436,26 @@ mutex_steal() { # rmdir -- "$ms_claim" 2>/dev/null || : return 0 } -mutex_try() { # [] — 0 taken, 1 not; never exits +# A LOCK WHOSE DIRECTORY IS GONE IS NO LOCK ANYBODY HOLDS (360, lane +# openRepoTools-3's review of #308). lane-end's sweep removes a CLOSED lane's +# checkout, and a person may move one aside, while a writer waits on its lock. +# No `mkdir` can succeed there again, and the waiter used to read the missing +# lock as one naming no holder and over ten minutes old, which `mutex_steal` +# could not take: it waited out its minute and said "another lanes-edit run is +# active (holder pid unrecorded)". It is 2 at once, with MX_WHY, and the caller +# names what went (`lock_gone_die`). +mutex_try() { # [] — 0 taken, 1 not, 2 its directory is gone; never exits mt_d="$1"; mt_max="${2:-60}"; mt_i=0 + case "$mt_d" in */*) mt_up="${mt_d%/*}"; mt_up="${mt_up:-/}" ;; *) mt_up=. ;; esac while :; do if mkdir -- "$mt_d" 2>/dev/null; then printf '%s %s\n' "$$" "$(lock_ident)" > "$mt_d/pid" 2>/dev/null || : return 0 fi + if [ ! -d "$mt_up" ]; then + MX_WHY="$mt_up, where the lock $mt_d lives, is gone" + return 2 + fi if mutex_stale "$mt_d" && mutex_steal "$mt_d"; then continue; fi [ "$mt_i" -lt "$mt_max" ] || return 1 mt_i=$((mt_i + 1)) @@ -1461,11 +1557,11 @@ RL_PATHS_FOR_UNDO=() rename_undo() { [ "$RL_ACTIVE" = 1 ] || return 0 RL_ACTIVE=0 - # A COMMIT THAT EXISTS IS NOT UNDONE. The undo is armed through `commit_push` + # A COMMIT THAT EXISTS IS NOT UNDONE. The undo is armed through `commit_publish` # because that function can die before it commits anything; once it HAS # committed, the four moves are in that commit and restoring the working tree # would be an inverse change nobody asked for, on top of a commit whose own - # recovery `commit_push` prints. HEAD is the whole test. + # recovery `commit_publish` prints. HEAD is the whole test. if [ -n "$RL_HEAD_BEFORE" ]; then ru_head="$(git -C "$LANES_REPO" rev-parse HEAD 2>/dev/null || printf '')" if [ -n "$ru_head" ] && [ "$ru_head" != "$RL_HEAD_BEFORE" ]; then @@ -1490,7 +1586,7 @@ rename_undo() { # and detach the register from git — the hazard this file's own header opens # with. [ -n "$RL_REG" ] && [ -n "$RL_SNAP" ] && [ -f "$RL_SNAP/register" ] && cat -- "$RL_SNAP/register" > "$RL_REG" - # AND THE INDEX WITH IT, because `commit_push` STAGES before it commits: a + # AND THE INDEX WITH IT, because `commit_publish` STAGES before it commits: a # `git add` that succeeded and a `git commit` that did not leaves the four # paths staged, so restoring only the working tree would leave the rename in # the index for the next writer to commit by accident. @@ -1542,8 +1638,11 @@ cleanup() { [ -n "${SE_CACHE_FILE:-}" ] && rm -f -- "$SE_CACHE_FILE" "$SE_CACHE_FILE".* 2>/dev/null [ -n "${LANES_IDX_DIR:-}" ] && [ -d "${LANES_IDX_DIR:-}" ] && rm -rf -- "$LANES_IDX_DIR" [ -n "${LANES_WT_IDX_DIR:-}" ] && [ -d "${LANES_WT_IDX_DIR:-}" ] && rm -rf -- "$LANES_WT_IDX_DIR" + # `claim`'s whole reads (706) cross a re-run's `exec`, which runs no EXIT + # trap, and go when the last run of the claim ends, however it ends. + [ -z "${CP_CLAIM_READS_FILE:-}" ] || rm -f -- "$CP_CLAIM_READS_FILE" # AMENDMENT 14(d) — THE NUDGE IS THE LAST THING THIS PROCESS DOES: after the - # lock is released at the top of this function, and only where `commit_push` recorded a push + # lock is released at the top of this function, and only where `commit_publish` recorded a push # that landed - or, opensoft/openRepoTools#161, where an inventory writer # recorded a snapshot or a tree record it had just replaced. Tested here rather # than inside the function, because `cleanup` also runs for a process that died @@ -1599,8 +1698,8 @@ trap 'cleanup; trap - TERM; kill -TERM $$' TERM # reaches a live holder in this container; it is the whole test for one in # another; and without the opt-in it reaches a live holder here as well. # -# THE MUTEX, TAKEN WITHOUT DYING FOR IT — 0 taken, 1 not taken, and no exit -# either way. `acquire_lock` below is this with the refusal on the end, so the +# THE MUTEX, TAKEN WITHOUT DYING FOR IT — 0 taken, 1 not taken, 2 the +# directory it lives in is gone (360), and no exit either way. `acquire_lock` below is this with the refusal on the end, so the # `mkdir` mutex, the pid test and the age-out are written ONCE and every taker # of the lock obeys the same three. # @@ -1612,28 +1711,55 @@ trap 'cleanup; trap - TERM; kill -TERM $$' TERM # # AND A LOCK THIS PROCESS TOOK BEFORE AN `exec` IS STILL ITS OWN (Amendment 23 # S4's replay re-runs the verb with the lock held). `exec` keeps the pid, and -# the run before the `exec` exported `LANES__PID=$$`: a lock whose pid file -# names this pid in this container, read by a process whose `LANES__PID` is its -# own `$$`, is taken as held. Any other process that finds its own pid there — -# a reused pid — waits like everyone else. +# the run before the `exec` exported `LANES__PID=$$` (routing) or +# `LANES__REPLAY_PID=$$` (the replay, `CP_REPLAYING`): a lock whose pid file +# names this pid in this container, read by a process whose marker is its own +# `$$`, is taken as held. Any other process that finds its own pid there — a +# reused pid — waits like everyone else. lock_adopt() { - [ -n "${LANES__PID:-}" ] && [ "$LANES__PID" = "$$" ] || return 1 + { [ -n "${LANES__PID:-}" ] && [ "$LANES__PID" = "$$" ]; } || [ "$CP_REPLAYING" = 1 ] || return 1 mutex_read "$LOCK" || return 1 [ "$MX_PID" = "$$" ] && mutex_local || return 1 LOCK_HELD=1 return 0 } -lock_try() { # [] +lock_try() { # [] — 0 taken, 1 not, 2 its directory is gone lock_adopt && return 0 - mutex_try "$LOCK" "${1:-60}" || return 1 + lt_rc=0; mutex_try "$LOCK" "${1:-60}" || lt_rc=$? + [ "$lt_rc" = 0 ] || return "$lt_rc" LOCK_HELD=1 return 0 } +# THE CHECKOUT WENT WHILE THIS WRITE WAITED FOR ITS LOCK (360): `mutex_try`'s 2. +# The checkout is named as removed, or, where it is still there, the directory +# the lock lives in, in `mutex_try`'s words. 1, as for a register that is not +# there, and never 4: no other run holds anything. THE RESTORE IS THE ONE THAT +# FOLDER TAKES (701, lane openRepoTools-3's re-check of #307's round 2): git +# restores the checkout's own `lanes/`, and nothing restores the workstation's +# sidecar lock's `-lanes/`, which is no part of any checkout, but a move back. +lock_gone_die() { + if [ -d "$LANES_REPO" ] && [ "$LOCK" = "$LANES_DIR/.lanes-edit.lock" ]; then + die "$MX_WHY from the checkout $LANES_REPO: it was removed while the write waited for the lock. Nothing was written. If nobody meant to remove it, restore it (git -C $LANES_REPO checkout HEAD -- $LANES_DIR) and run the write again." 1 + fi + if [ -d "$LANES_REPO" ]; then + die "$MX_WHY: it was removed while the write waited for the lock, and it is no part of the checkout $LANES_REPO, so git cannot restore it. Nothing was written. If it was moved aside, move it back to ${LOCK%/*} and run the write again; run with it still gone, the write makes the folder again, empty." 1 + fi + die "the checkout $LANES_REPO, which this write goes through, was removed while the write waited for its lock ($MX_WHY): lane-end's sweep removes a CLOSED lane's checkout (Amendment 23 S6), and a person may have moved it aside. Nothing was written. If the lane is not CLOSED, run the write again: a routed write makes the lane's checkout again (S1), and a LANES_REPO that names the removed path must first name a checkout that is there." 1 +} + acquire_lock() { - lock_try 60 && return 0 + al_rc=0; lock_try 60 || al_rc=$? + [ "$al_rc" = 0 ] && return 0 + [ "$al_rc" != 2 ] || lock_gone_die die "could not acquire $LOCK after 60s — another lanes-edit run is active (holder pid $(cat -- "$LOCK/pid" 2>/dev/null || printf 'unrecorded'))" 4 } +# A REPLAY HOLDS THE CHECKOUT'S LOCK FROM ITS FIRST LINE (Amendment 23 S4). The +# run before the `exec` held it and kept it, and this run may refuse before it +# reaches `acquire_lock` again — a rival's claim, a peer's retirement, a row +# that no longer matches — so it is adopted here, where the exit trap releases +# it on every way out. +if [ "$CP_REPLAYING" = 1 ]; then lock_adopt || :; fi # ---------------------------------------------------------------- row lookup @@ -1913,8 +2039,8 @@ lane_from_text() { # part-way left a half register behind a refusal that called it UNCHANGED, # and one that came out short and said nothing was a half register # committed. Even an uncommitted half register is one write from being -# published: the next writer in this checkout commits a dirty register as a -# peer's pre-existing edit (`handle_preexisting`). +# published: the next `commit` in this checkout carries a dirty register as +# its hand edit (Amendment 23 S3 refuses every other writer over it). # # So the copy is compared with the register and the result with what was built. # Where the result is not what was built, the copy's bytes are put back and @@ -1926,11 +2052,13 @@ lane_from_text() { # write their logs, their archive and their earlier rows before a row's # rewrite, so a refusal that said "Nothing was written" was false there: `git # status` showed those files, and a re-run read them back. What holds is that -# THIS ACT has committed nothing — not "nothing has been committed": a peer's -# uncommitted register edit found first is captured as a commit of its own -# (`handle_preexisting`) before the act's edit, and the one caller that HAS -# committed when it rewrites (add-row's after-rebase take-back) says so through -# `REG_COMMITTED_HERE` (the delta review of #263). Each sweep names its local +# THIS ACT has committed nothing. (#263 wrote "this act", not "nothing has been +# committed", beside the capture of a peer's edit as a commit of its own, and +# let add-row's after-rebase take-back, which HAD committed, say so through +# `REG_COMMITTED_HERE` and `DL_SAYS_*`. Amendment 23 removed both, #198 slice +# 1b: S3 refuses a write over a peer's edit and captures nothing, and S4's +# replay drops a refused add-row's commit, so no caller sets those hooks now +# and every refusal says the default.) Each sweep names its local # writes with `reg_wrote`, which the refusals list. AND WHERE THE REGISTER MAY # BE TRUNCATED the copy is KEPT (S2's NIT 6): it is the only exact copy of the # register as it was, uncommitted edits included, and the exit trap removes @@ -1989,7 +2117,7 @@ register_rewrite() { # dl_pre="$TMPD/pre"; dl_out="$TMPD/out" # WHAT A REFUSAL HERE SAYS ABOUT THE ROWS IS THE CALLER'S (the delta review of # #263, NIT). archive-rows' sentences are the default: its rows are already in - # the archive. add-row's take-back passes its own, because there the row is - # add-row's committed one and no archive is involved. + # the archive. add-row's take-back passed its own (the row was add-row's + # committed one, and no archive was involved); it went with the pull in + # #198 slice 1b, so no caller passes one now. dl_unchanged="${DL_SAYS_UNCHANGED:-no row was removed, the rows are in $LANES_ARCH_PATH, and the register is whole}" dl_after_write="${DL_SAYS_AFTER_WRITE:-The archive may have been appended locally, but $(reg_committed); resolve that partial move before retrying.}" register_copy "$dl_pre" "$dl_unchanged" @@ -2148,10 +2277,10 @@ append_text_line() { # was meant — and only then refused: `append changed line count by 2`, exit # 5, the file left modified and uncommitted. What came next was worse than # the refusal. A dirty object log is refused by every later writer in that - # checkout; a dirty REGISTER is committed by the next one, as a peer's - # `pre-existing` edit (`handle_preexisting`) — measured in `lane-handoff`'s own - # flow, where the row flip right after the refused PAUSED line committed the - # two lines. A carriage return is refused with it: `wc -l` does not count one, so it + # checkout; a dirty REGISTER was committed by the next one, as a peer's + # `pre-existing` edit (the capture Amendment 23 S3 withdrew) — measured in + # `lane-handoff`'s own flow, where the row flip right after the refused PAUSED + # line committed the two lines. A carriage return is refused with it: `wc -l` does not count one, so it # passed the proof and put a line no reader splits the same way into a file # nothing rewrites. `write_event` has refused both since R21; this is the # same gate in the one function every append goes through, which is the @@ -2223,70 +2352,418 @@ remote_has_branch() { return 0 } -# Unstaged changes to TRACKED files OTHER than the register. New in Amendment -# 5 and unavoidable: the register shares its checkout with handoffs/ and -# workspaces/, which other lanes write. `git pull --rebase` refuses outright -# on ANY unstaged tracked change, so without this the refusal would surface as -# a bogus "REBASE CONFLICT" and the writer would be sent to a recovery that -# does not apply. Prints the offending paths, one per line. -dirty_elsewhere() { - git -C "$LANES_REPO" --no-pager diff --name-only 2>/dev/null \ - | grep -v -x -F -- "$(printf '%s\n' "${CP_PATHS[@]}")" || : -} - -# Every TRACKED file in this checkout that differs from HEAD — staged or not, -# and whatever pathspec the caller happens to be writing. That is exactly the -# set `git pull --rebase` refuses on. `git status --porcelain` reports the same -# thing, with rename records and path quoting to parse first; these two -# plumbing reads answer the same question unambiguously, and neither reports an -# untracked file, which does not block a rebase. -tracked_dirty() { - { git -C "$LANES_REPO" --no-pager diff --name-only 2>/dev/null - git -C "$LANES_REPO" --no-pager diff --name-only --cached 2>/dev/null - } | LC_ALL=C sort -u -} - -# AMENDMENT 7 — `log`, `claim` and `release` REFUSE a checkout that cannot be -# rebased. $1 names the act; every remaining argument is a pathspec THIS write -# is about to append to and is therefore exempt. -# -# `lanes/LANES.md` is the one exemption that is not a pathspec of the write: -# it is CAPTURED rather than refused, by capture_register_edit below, and the -# guard is then re-run against the checkout that capture left behind. See -# there for why (Addendum 3, R11). -# -# The guard is computed over the WHOLE checkout, and from the same set the -# write will use, because the first version of it was not: `claim` measured -# "dirty elsewhere" against (log + LANES.md) while a CLAIMED writes the log -# alone, so a dirty `lanes/LANES.md` — the one dirty file this repository -# actually has, at ~500 register commits a day — passed the guard and then took -# commit_push's Amendment 5(d) branch, which skips `pull --rebase` and with it -# the rescan that decides the race. Two lanes ended up holding one object with -# no CLAIM-LOST. The guard and the write must never disagree about what this -# invocation is writing. -refuse_dirty_checkout() { - rd_what="$1"; shift - [ "$NO_GIT" = 1 ] && return 0 - git -C "$LANES_REPO" rev-parse --git-dir >/dev/null 2>&1 || return 0 +# ==================================================== AMENDMENT 23 S3 ======= +# +# BEFORE THE EDIT: FETCH, CHECK, FAST-FORWARD — OR REFUSE WITH NOTHING WRITTEN. +# +# The writers used to commit first and sync second: an edit made on whatever +# this checkout last saw, then a `pull --rebase` in front of the push. A +# checkout that was behind met an adjacent row origin had changed, the rebase +# conflicted and aborted, and the write was a local commit origin never heard +# of — eight times in two days in the shared checkout, and in 29 seconds in a +# lane's private clone that was clean and only 10 behind (#198, 2026-10-13T13:12Z). +# So every writer, routed or not, runs this under the checkout's own lock +# BEFORE it reads the file it writes, and the edit is made on origin's tip. +# +# `checkout_sync […]` +# +# THE STEPS ARE THE AMENDMENT'S, IN ITS ORDER, and the first that holds refuses +# with 2: it names the checkout, says nothing was written, prints that row's +# recovery, and the exit releases the lock. +# 1. FETCH. A fetch that fails is a refusal (ratified choice 2): without a +# current origin the writer knows neither its base nor whether it could +# confirm a push. `LANES_NO_FETCH=1` is a fetch that failed, for a writer. +# 2. An operation in progress, or unmerged entries. +# 3. Not on the branch: a detached HEAD or another branch. +# 4. Staged entries other than this write's own pathspecs. Never commit +# staged entries you did not make. +# 5. Unpushed commits, by CONTENT (`git cherry`: a commit a peer already +# carried shows `-`). The publish act alone is exempt (`publish`). +# 6. Diverged: HEAD not an ancestor of origin, with every commit on it on +# origin by content already. +# 7. A PENDING CHANGE TO A PATH THIS WRITE COMMITS (S3 step 4, ratified +# choice 9, and the same reason for every pathspec of the write): a +# pathspec commit would carry it under this write's subject. The register +# refuses every verb but `commit`, which CARRIES it across (`carry`): a +# row edit whose own lines origin left alone applies again even where +# origin changed the row beside it, and anything else applies with its +# full context or is refused and kept in a file. +# 8. The fast-forward. git refuses rather than overwrite an uncommitted +# change, and that refusal is S3's, naming the file. An unstaged change +# to another tracked file that origin did not touch is NOT a refusal +# (ratified choice 8): it guarded a pull no writer runs any more. +# +# EVERY REF IS SPELLED IN FULL. A local branch named `origin/main` shadows the +# tracking ref (#214's adversarial check), and `git cherry origin/main` would +# read the branch. And the fetch names its refspec, so the tracking ref moves +# whatever `remote.origin.fetch` says. +# +# `LANES_NO_GIT=1` (edit only: no commit, no push) skips all of it, as the +# guard it replaces did — that mode publishes nothing to guard. +CS_NO_ORIGIN_BRANCH=0 +CS_CARRY_DIFF="" +CS_CARRY_ANCHORED=0 +CS_CARRY_BASE="" +cs_refuse() { # […] + csr_what="$1"; csr_why="$2"; shift 2 if [ "$#" -gt 0 ]; then - rd_others="$(tracked_dirty | grep -v -x -F -- "$(printf '%s\n' "$@")" || :)" - else - rd_others="$(tracked_dirty)" - fi - [ -n "$rd_others" ] || return 0 - note "this checkout has uncommitted changes to tracked files that are not this $rd_what's:" - printf ' %s\n' $rd_others >&2 - case "$rd_what" in - claim) note "a claim is decided by which CLAIMED lands on main first, and 'pull --rebase' refuses on any" - note "unstaged tracked change — so the race cannot be run safely here." ;; - *) 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:" - 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 + note "RECOVERY (Amendment 23 S3), in $LANES_REPO:" + for csr_l in "$@"; do note " $csr_l"; done + fi + die "refusing to $csr_what in $LANES_REPO: $csr_why. Nothing was written." 2 +} +# The pathspecs among … that differ from HEAD, staged or not. Exact +# and in the C locale, because they are compared as strings. +cs_pending_of() { # … + [ "$#" -gt 0 ] || return 0 + LC_ALL=C git -C "$LANES_REPO" -c "$CP_EXACT" -c core.quotePath=false diff --name-only HEAD -- "$@" 2>/dev/null || : +} +# A PENDING HAND EDIT OF THE REGISTER REFUSES EVERY WRITER BUT `commit` (S3 +# step 4, ratified choice 9). A lane's session and its subagents share this +# checkout, so the edit may be a peer's: it is committed by its own act, and +# never captured into, or swept along with, another write. +cs_refuse_register() { # + cs_refuse "$1" "$LANES_PATH holds an uncommitted edit, which may be a peer's in this shared checkout: a pending hand edit of the register is committed by its own act, \`commit\`, and never swept into another write (Amendment 23 S3 step 4, ratified choice 9)" \ + "read it: git -C $LANES_REPO diff HEAD -- $LANES_PATH" \ + "whoever made it commits it: LANES_LANE=${2:-} $SELF commit \"\"" \ + "then re-run this write" +} +# S3 STEP 4 FOR THE PATHS A WRITE LEARNS ONLY IN ITS SCAN (the sweeps, +# `rename-lane`), asked again right before the first byte; and for the paths a +# write knows up front, from `checkout_sync` itself. +cs_own_pending() { # … + cop_what="$1"; cop_lane="$2"; shift 2 + [ "$NO_GIT" = 1 ] && return 0 + [ "$#" -gt 0 ] || return 0 + cop_hits="$(cs_pending_of "$@")" + [ -n "$cop_hits" ] || return 0 + cop_reg=0; cop_other="" + while IFS= read -r cop_p; do + [ -n "$cop_p" ] || continue + if [ "$cop_p" = "$LANES_PATH" ]; then cop_reg=1; else cop_other="$cop_other $cop_p"; fi + done <()\" --$cop_other" \ + "never commit a change you did not make; then re-run this write" +} +# The published-read caches, dropped because the ref they were read from has +# just moved (the same three `log_sync` drops after a fetch). +cs_flush() { + LANES_REGISTER_CACHE="" + csf_rc="${SE_CACHE_FILE:+$SE_CACHE_FILE.register}"; [ -n "$csf_rc" ] && rm -f -- "$csf_rc" + lane_alias_flush + state_events_flush +} +# MUSTs 1 AND 2 OF rv1b'S PRE-REVIEW OF SLICE 1b: A VERB WHOSE OWN CHECKS RAN +# BEFORE THE LOCK RUNS AGAIN WHEN ORIGIN MOVED UNDER THEM. `claim` (the +# holders, and Amendment 7(f)'s race) and `add-row` (a retired name, a row +# already there) decide on what `log_sync`, or an earlier fetch, last saw. A +# rival's CLAIMED, a peer's retirement of the name or a peer's own row for it, +# landing between that read and S3's fetch, was fast-forwarded in and written +# over: two lanes holding one object, a retired name reissued, a second row. +# S4's replay never ran, because the push was not refused — and the base's +# rescan after every `pull --rebase` went with the pull. So where origin's tip +# after S3's fetch is not the tip the checks read (`CS_SEEN`, whoever moved +# the ref in between), nothing is written yet, and the verb runs again from the +# top on the new tip with the lock held: the replay's own `exec`, so `claim`'s +# rescan comes first (CLAIM-LOST 7, or 9) and `add-row`'s checks refuse (2), +# inside S4's six attempts. At the sixth it refuses (2), nothing written. Only +# those two opt in: the row verbs, `rename-lane` and the sweeps read what they +# check after S3, and an opt-in for every verb would add a fetch and an `exec` +# to every write made from a checkout behind origin. +cs_recheck() { # + if [ "$CP_ATTEMPT" -ge 6 ]; then + cs_refuse "$1" "origin/$LANES_BRANCH moved while this write's own checks ran, on each of six attempts (Amendment 23 S4, ratified choice 4)" \ + "re-run this write when origin is quieter" + fi + note "origin/$LANES_BRANCH moved while this write's checks ran (${2:-no ref} → $3): nothing is written yet, and it runs again from the top on origin's new tip, with the lock held (attempt $CP_ATTEMPT of 6; Amendment 23 S3, S4)." + cleanup_for_exec + LANES__REPLAY_PID="$$"; LANES__ATTEMPT=$((CP_ATTEMPT + 1)) + export LANES__REPLAY_PID LANES__ATTEMPT + [ "${#CP_REPLAY_ENV[@]}" -eq 0 ] || export "${CP_REPLAY_ENV[@]}" + [ -z "$CP_CARRY_COPIES" ] || { LANES__CARRY_COPIES="$CP_CARRY_COPIES"; export LANES__CARRY_COPIES; } + # THE `exec` IS DELIBERATE: it runs this one write again from the top on origin's new tip, with + # the lock held, and does not return; the `die` after it runs only if the `exec` itself fails. + # shellcheck disable=SC2093 + exec "${BASH:-bash}" "$RESOLVED" ${ORIG_ARGV[@]+"${ORIG_ARGV[@]}"} + die "could not run $RESOLVED again on origin's new tip. Nothing was written; run this write again." 1 +} +# 0 where every line the saved hand edit replaces (its `-` lines) occurs +# exactly once in the register as it now stands, origin's version after the +# fast-forward, AND exactly once in the version the edit was made on (292 and +# 341, below at the carry); 1 otherwise, or where either cannot be read. The +# lines are read from a file, never through `$(…)`, so a blank line at the end +# of the list is asked about too. The version the edit was made on is read +# from git by its blob id (`CS_CARRY_BASE`, step 7), never saved as a file: +# `.git/lanes-edit/` holds only a kept hand edit and five scratch files +# (`apply.err`, `fetch.err`, `ff.err` and `removed` here, `reset.err` in the replay). +cs_removed_unique() { # + awk 'p && /^-/ { print substr($0, 2) } /^@@ / { p = 1 }' "$1" > "$cs_tmp/removed" 2>/dev/null || return 1 + while IFS= read -r cru_l || [ -n "$cru_l" ]; do + [ "$(LC_ALL=C grep -cxF -- "$cru_l" "$LANES_FILE" 2>/dev/null || :)" = 1 ] || return 1 + [ "$(git -C "$LANES_REPO" cat-file blob "${CS_CARRY_BASE:-none}" 2>/dev/null | LC_ALL=C grep -cxF -- "$cru_l" || :)" = 1 ] || return 1 + done < "$cs_tmp/removed" + return 0 +} +# THE EARLIER COPIES, BY NAME (707, lane openRepoTools-3's re-check of #307's +# round 2): a lost race replays `commit`'s carry, which saves the hand edit +# again, and where the re-run's carry is refused, both copies are kept. The +# record (`CP_CARRY_COPIES`, 393) names them all; the last is `CS_CARRY_DIFF`. +# The others are printed here. A replay in the same second as the run before +# it reuses the name (704), and that copy is then the last one. +cs_earlier_copies() { + cec_out="" + while IFS= read -r cec_f; do + case "$cec_f" in ''|*/*|"${CS_CARRY_DIFF##*/}") continue ;; esac + cec_out="${cec_out:+$cec_out, }$cs_tmp/$cec_f" + done < […] + cs_what="$1"; cs_lane="${2:-}"; cs_mode="$3"; shift 3 + CS_NO_ORIGIN_BRANCH=0; CS_CARRY_DIFF=""; CS_CARRY_BASE="" + CS_LANE="$cs_lane" + [ "$NO_GIT" = 1 ] && return 0 + cs_c="$LANES_REPO" + cs_trk="refs/remotes/origin/$LANES_BRANCH"; cs_br="refs/heads/$LANES_BRANCH" + cs_gd="$(git -C "$cs_c" rev-parse --absolute-git-dir 2>/dev/null)" || + cs_refuse "$cs_what" "it is not a git checkout, so there is no origin to bring it up to date from and no commit to make" + # A DIRECTORY INSIDE THE CHECKOUT IS NOT THE CHECKOUT (slice 2a's writer + # w2a-c10, on this slice). Every pathspec this file gives git is spelled + # from the top level (`LANES_PATH`, the lane's log, `LANES_PREFIX`), and git + # reads a pathspec relative to the directory it runs in: from below the top + # level the write's `git add` failed (6) after the edit was made, step 7 + # asked about a register path that is not there (a pending hand edit went + # unseen), and every printed recovery named the wrong path. So `LANES_REPO` + # names the top level, or the write refuses here, before any edit. Not a + # rewrite to the top level: with no `LANES_FILE`, the register's default + # comes from `LANES_REPO`, so the recovery names both. + cs_pfx="$(git -C "$cs_c" rev-parse --show-prefix 2>/dev/null || :)" + if [ -n "$cs_pfx" ]; then + cs_top="$(git -C "$cs_c" rev-parse --show-toplevel 2>/dev/null || :)" + cs_refuse "$cs_what" "LANES_REPO names $cs_pfx inside the checkout ${cs_top:-}, not its top level: git reads this write's pathspecs ($LANES_PATH, and the lane's log) relative to the directory it runs in, so its commit would fail after the edit" \ + "name the checkout itself: LANES_REPO=${cs_top:-} LANES_FILE=$LANES_FILE" \ + "then re-run this write" + fi + cs_tmp="$cs_gd/lanes-edit" + mkdir -p -- "$cs_tmp" 2>/dev/null || + cs_refuse "$cs_what" "$cs_tmp could not be made, and this write keeps git's own words and any carried hand edit there" + + # ---- 1. fetch + if [ "${LANES_NO_FETCH:-0}" = 1 ]; then + cs_refuse "$cs_what" "LANES_NO_FETCH=1 is set, and for a writer a fetch that did not run is a fetch that failed (Amendment 23 S3 step 1, ratified choice 2): without a current origin/$LANES_BRANCH this write knows neither its base nor whether it could confirm a push" \ + "run this write without LANES_NO_FETCH (reads may keep it)" + fi + cs_was="$(git -C "$cs_c" rev-parse -q --verify "$cs_trk" 2>/dev/null || :)" + if ! git_net -C "$cs_c" -c gc.auto=0 -c maintenance.auto=false fetch -q origin "+$cs_br:$cs_trk" 2>"$cs_tmp/fetch.err"; then + if [ "$GIT_TIMED_OUT" = 1 ]; then + cs_refuse "$cs_what" "the fetch of origin $LANES_BRANCH timed out after ${GIT_TIMEOUT}s, and a write that cannot see origin's tip does not edit (Amendment 23 S3 step 1)" \ + "re-run this write once origin answers: git -C $cs_c fetch origin $LANES_BRANCH" + fi + # AN ORIGIN WITH NO BRANCH AT ALL is the one fetch failure that is not a + # refusal, and only through an explicit `LANES_REPO`: a new repository's + # first write creates the branch with `push -u`, as it always has. A lane + # checkout is a clone of the branch, so it never meets this. + cs_lr=0 + git_net -C "$cs_c" ls-remote --exit-code --heads origin "$LANES_BRANCH" >/dev/null 2>&1 || cs_lr=$? + if [ "$cs_lr" = 2 ] && [ "$LANES_REPO_GIVEN" = 1 ] && [ "$cs_mode" != publish ]; then + CS_NO_ORIGIN_BRANCH=1 + else + cs_refuse "$cs_what" "the fetch of origin $LANES_BRANCH failed, and a write that cannot see origin's tip does not edit (Amendment 23 S3 step 1, ratified choice 2). git said: $(head -n 3 -- "$cs_tmp/fetch.err" 2>/dev/null | tr '\n' ' ')" \ + "re-run this write once origin answers: git -C $cs_c fetch origin $LANES_BRANCH" + fi + fi + # THE TIP THIS WRITE IS MADE ON IS READ ONCE, HERE, RIGHT AFTER S3's OWN + # FETCH, AS A SHA (lane openRepoTools-3's review of #307, 281). The compare + # below and the fast-forward at step 8 both use it. The fast-forward went to + # `refs/remotes/origin/` BY NAME, about ten git calls after the compare, + # and in a checkout several processes share, anything that fetches without + # the lock moves that ref in between: every reader's `log_sync` (`who`, + # `history`, `lane-dir` …), every other writer's pre-lock one. What it moved + # in was fast-forwarded in unchecked: a rival's CLAIMED under this claim, a + # peer's retirement under this `add-row`, and the push, a plain fast-forward, + # landed at attempt 1. Made on this sha, the write's push is refused where + # origin moved since, and S4's replay decides on the new tip. + cs_now="$(git -C "$cs_c" rev-parse -q --verify "$cs_trk" 2>/dev/null || :)" + if [ "$CS_RECHECK" = 1 ] && [ "$CS_NO_ORIGIN_BRANCH" != 1 ]; then + # AGAINST THE TIP THE CHECKS READ (`CS_SEEN`), and against the one just + # before this fetch: in a shared checkout another process may have moved + # the ref while this write waited on the lock (`cs_seen_mark`). + cs_base="$cs_was"; [ "$CS_SEEN_SET" = 1 ] && cs_base="$CS_SEEN" + if [ "$cs_now" != "$cs_base" ] || [ "$cs_now" != "$cs_was" ]; then + cs_recheck "$cs_what" "$cs_base" "$cs_now" + fi + fi + + # ---- 2. an operation in progress, or unmerged entries + cs_op="" + if [ -d "$cs_gd/rebase-merge" ] || [ -d "$cs_gd/rebase-apply" ]; then cs_op="a rebase" + elif [ -e "$cs_gd/MERGE_HEAD" ]; then cs_op="a merge" + elif [ -e "$cs_gd/CHERRY_PICK_HEAD" ]; then cs_op="a cherry-pick" + elif [ -e "$cs_gd/REVERT_HEAD" ]; then cs_op="a revert" + fi + cs_unm="$(LC_ALL=C git -C "$cs_c" -c core.quotePath=false ls-files -u 2>/dev/null | cut -f2 | LC_ALL=C sort -u | tr '\n' ' ')" + if [ -n "$cs_op$cs_unm" ]; then + cs_why="${cs_op:+$cs_op is in progress here}" + [ -z "$cs_unm" ] || cs_why="${cs_why:+$cs_why, and }the index holds unmerged entries: $cs_unm" + cs_refuse "$cs_what" "$cs_why" \ + "read it first: git -C $cs_c status" \ + "then abort what is in progress: git -C $cs_c rebase --abort (or merge --abort, cherry-pick --abort, revert --abort)" \ + "never --continue what you did not start" + fi + + # ---- 3. not on the branch + cs_head="$(git -C "$cs_c" symbolic-ref -q HEAD 2>/dev/null || :)" + if [ "$cs_head" != "$cs_br" ]; then + cs_refuse "$cs_what" "HEAD is ${cs_head:-detached}, not $cs_br, so a commit here would not be the branch this write pushes" \ + "git -C $cs_c switch $LANES_BRANCH" + fi + + # ---- 4. staged entries that are not this write's own + cs_foreign="" + while IFS= read -r cs_s; do + [ -n "$cs_s" ] || continue + cs_mine=0 + for cs_p in ${1+"$@"}; do [ "$cs_s" = "$cs_p" ] && cs_mine=1; done + [ "$cs_mine" = 1 ] || cs_foreign="$cs_foreign $cs_s" + done </dev/null) +CS_EOF + if [ -n "$cs_foreign" ]; then + cs_refuse "$cs_what" "the index holds staged entries this write did not make:$cs_foreign" \ + "look: git -C $cs_c diff --cached" \ + "unstage what you did not make: git -C $cs_c restore --staged --$cs_foreign" \ + "commit what you made by pathspec, as its own write" \ + "never commit staged entries you did not make" + fi + + if [ "$CS_NO_ORIGIN_BRANCH" != 1 ]; then + # ---- 5. unpushed commits, by content + cs_rc=0 + cs_cherry="$(git -C "$cs_c" cherry "$cs_trk" HEAD 2>/dev/null)" || cs_rc=$? + [ "$cs_rc" = 0 ] || + cs_refuse "$cs_what" "git could not compare HEAD with $cs_trk, so whether this checkout holds unpublished commits is unknown" + cs_plus="$(printf '%s\n' "$cs_cherry" | grep -c '^+' || :)" + if [ "$cs_plus" -gt 0 ] && [ "$cs_mode" != publish ]; then + note "this checkout holds $cs_plus commit(s) origin/$LANES_BRANCH lacks by content:" + git -C "$cs_c" --no-pager cherry -v "$cs_trk" HEAD 2>/dev/null | grep '^+' | head -n 10 >&2 || : + cs_refuse "$cs_what" "HEAD carries $cs_plus unpublished commit(s), and a write made on top of them would push them under its own name or strand itself behind them (Amendment 23 S3)" \ + "list them: git -C $cs_c cherry -v refs/remotes/origin/$LANES_BRANCH HEAD" \ + "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" \ + "publish them with the publish act, which pushes them and writes nothing new: LANES_LANE=${cs_lane:-} $SELF commit \"\" (with nothing staged and $LANES_PATH clean)" \ + "or archive them (S6.3): git -C $cs_c push origin HEAD:refs/heads/archive/${cs_lane:-}-stranded-$(date -u +%Y%m%dT%H%MZ), then git -C $cs_c reset --keep refs/remotes/origin/$LANES_BRANCH" + fi + # ---- 6. diverged + if [ "$cs_plus" = 0 ] && ! git -C "$cs_c" merge-base --is-ancestor HEAD "$cs_trk" 2>/dev/null; then + # A MERGE IS NOT "ON ORIGIN BY CONTENT" (lane openRepoTools-3's review + # of #307, NIT 287). `git cherry` skips merge commits, so a hand merge + # carrying a line of its own reads as nothing unpublished, and the reset + # below would drop that line. Where HEAD holds a merge origin lacks, the + # archive comes first. The refusal is the same: no hand merge belongs + # in a register checkout. + cs_merges="$(git -C "$cs_c" rev-list --merges --count "$cs_trk..HEAD" 2>/dev/null || printf '?')" + if [ "$cs_merges" != 0 ]; then + cs_refuse "$cs_what" "HEAD is not an ancestor of origin/$LANES_BRANCH, and it holds $cs_merges merge commit(s) origin lacks, which \`git cherry\` cannot compare by content: this checkout has diverged, and what the merge carries may be on no branch of origin's" \ + "look: git -C $cs_c log --oneline --graph refs/remotes/origin/$LANES_BRANCH..HEAD; if any commit there is not your own write, STOP and take it to the lane its subject names" \ + "archive it first (S6.3): git -C $cs_c push origin HEAD:refs/heads/archive/${cs_lane:-}-stranded-$(date -u +%Y%m%dT%H%MZ)" \ + "then: git -C $cs_c reset --keep refs/remotes/origin/$LANES_BRANCH, and make again, with lanes-edit.sh, each edit that is still wanted on the register as it now is" + fi + cs_refuse "$cs_what" "HEAD is not an ancestor of origin/$LANES_BRANCH, and every commit on it is on origin already by content: this checkout has diverged" \ + "git -C $cs_c reset --keep refs/remotes/origin/$LANES_BRANCH # the content is already published" + fi + fi + + # ---- 7. a pending change to a path this write commits + cs_reg_pending="$(cs_pending_of "$LANES_PATH")" + if [ -n "$cs_reg_pending" ] && [ "$cs_mode" = carry ]; then + # `commit` CARRIES THE HAND EDIT ACROSS: saved whole, the file put back to + # HEAD's (index and tree), the fast-forward, and the edit applied again. + CS_CARRY_DIFF="$cs_tmp/hand-edit-$(date -u +%Y%m%dT%H%M%SZ)-$$.diff" + git -C "$cs_c" -c "$CP_EXACT" --no-pager diff --binary HEAD -- "$LANES_PATH" > "$CS_CARRY_DIFF" 2>/dev/null && [ -s "$CS_CARRY_DIFF" ] || + cs_refuse "$cs_what" "the hand edit of $LANES_PATH could not be saved to $CS_CARRY_DIFF, and it is never set aside without a copy" + # EVERY COPY THIS RUN WRITES IS RECORDED BY NAME (393), and the record + # crosses the replay's `exec`: `cp_pushed` removes each once origin has it. + CP_CARRY_COPIES="$CP_CARRY_COPIES${CS_CARRY_DIFF##*/}"$'\n' + # A ROW EDIT IS ANCHORED BY THE LINES IT REPLACES. Every hunk of a row edit + # removes the row's old line; where each does, the edit may be applied + # with no context at all, so a neighbour's row changed on origin — the + # register's usual day, ~500 commits of it — does not refuse an edit to + # another row. A hunk that only inserts has no line of its own to find, + # and is applied with its full context or refused. + CS_CARRY_ANCHORED=0 + git -C "$cs_c" -c "$CP_EXACT" --no-pager diff -U0 HEAD -- "$LANES_PATH" 2>/dev/null | + awk '/^@@ / { h++; if ($2 ~ /,0$/) z++ } END { exit !(h > 0 && z == 0) }' && CS_CARRY_ANCHORED=1 + # The version the edit was made on, HEAD's, by its blob id (341, at the + # carry below); none where HEAD has no such file, and then no line is an + # anchor. + CS_CARRY_BASE="$(git -C "$cs_c" -c "$CP_EXACT" rev-parse -q --verify "HEAD:$LANES_PATH" 2>/dev/null || :)" + git -C "$cs_c" -c "$CP_EXACT" checkout -q HEAD -- "$LANES_PATH" 2>/dev/null || + cs_refuse "$cs_what" "$LANES_PATH could not be put back to HEAD's version before the fast-forward; the hand edit is untouched, and a copy of it is $CS_CARRY_DIFF" + cs_reg_pending="" + fi + [ -z "$cs_reg_pending" ] || cs_refuse_register "$cs_what" "$cs_lane" + [ "$#" -eq 0 ] || cs_own_pending "$cs_what" "$cs_lane" "$@" + + # ---- 8. the fast-forward, to the sha S3's fetch read (281 above). Steps 5 + # and 6 read the ref by name, and rightly: a newer ref there only finds more + # of HEAD published, while the sha would call a commit an unlocked writer's + # `pull` brought in from origin since "unpublished", and refuse for it. + cs_to="${cs_now:-$cs_trk}" + if [ "$cs_mode" != publish ] && [ "$CS_NO_ORIGIN_BRANCH" != 1 ] && + ! git -C "$cs_c" merge-base --is-ancestor "$cs_to" HEAD 2>/dev/null; then + if ! LC_ALL=C git -C "$cs_c" -c gc.auto=0 -c maintenance.auto=false merge --ff-only -q "$cs_to" >"$cs_tmp/ff.err" 2>&1; then + cs_ffw="$(head -n 8 -- "$cs_tmp/ff.err" 2>/dev/null | tr '\n' ' ')" + if [ -n "$CS_CARRY_DIFF" ]; then + # The hand edit was made on HEAD's version, so it goes back exactly. + git -C "$cs_c" apply --whitespace=nowarn -- "$CS_CARRY_DIFF" 2>/dev/null || : + fi + cs_refuse "$cs_what" "the fast-forward to origin/$LANES_BRANCH refused, rather than overwrite an uncommitted change here (Amendment 23 S3 step 3). git said: $cs_ffw" \ + "read the file git names: git -C $cs_c diff -- " \ + "whoever made that change commits it by pathspec as its own write, or restores it if it is nobody's; never stash or reset over it" \ + "then re-run this write" + fi + fi + if [ -n "$CS_CARRY_DIFF" ]; then + cs_applied=0; cs_dup="" + git -C "$cs_c" apply --whitespace=nowarn -- "$CS_CARRY_DIFF" 2>"$cs_tmp/apply.err" && cs_applied=1 + # AN ANCHOR IS A LINE THAT OCCURS ONCE (lane openRepoTools-3's review of + # #307, 292, its answer to Q3). `-C0` drops the context and edits the + # first copy of a replaced line it finds, searching out from near its old + # line number: the second of two identical lines, a replaced blank line + # (the register has dozens), a table's second separator, each went to + # another copy and was pushed there. So `-C0` is tried only where every + # line the edit replaces occurs exactly once in origin's version — a real + # row does — and otherwise the edit is refused and kept, at the cost of + # one re-edit. AND ONCE IN THE VERSION THE EDIT WAS MADE ON (lane + # openRepoTools-3's re-check of #307, 341): a line twice there and once on + # origin, because a peer changed or removed the copy the person edited, + # sent `-C0` to the copy nobody touched — a correction landed beside the + # peer's value, and the register's own LANDING, LANDED, LANDED shape, each + # side removing one LANDED, kept the LANDING with none. + if [ "$cs_applied" = 0 ] && [ "$CS_CARRY_ANCHORED" = 1 ] && ! cs_removed_unique "$CS_CARRY_DIFF"; then + cs_dup=", and a line it replaces occurs more than once, or not at all, in origin's version or in the version the edit was made on, so that line is no anchor for it and it is not placed by that line alone" + elif [ "$cs_applied" = 0 ] && [ "$CS_CARRY_ANCHORED" = 1 ] && + git -C "$cs_c" apply -C0 --whitespace=nowarn -- "$CS_CARRY_DIFF" 2>>"$cs_tmp/apply.err"; then + cs_applied=1 + note "origin changed a line beside the hand edit and none of the lines it replaces, each of which occurs once there and once in the version the edit was made on, so the edit was applied anchored by those lines alone" + fi + if [ "$cs_applied" = 0 ]; then + cs_also="$(cs_earlier_copies)" + cs_refuse "$cs_what" "the hand edit of $LANES_PATH no longer applies to origin's version of it$cs_dup, so it is KEPT in $CS_CARRY_DIFF${cs_also:+ (and, as this write saved it before its push lost the race, in $cs_also)} and the register is left at origin's version (Amendment 23 S3 step 4). git said: $(head -n 3 -- "$cs_tmp/apply.err" 2>/dev/null | tr '\n' ' ')" \ + "read what you wrote: cat $CS_CARRY_DIFF" \ + "make the edit again on the register as it now is, then: LANES_LANE=${cs_lane:-} $SELF commit \"\"" + fi + note "carried the hand edit of $LANES_PATH across the fast-forward (a copy is $CS_CARRY_DIFF until the read-back finds the edit on origin)" + fi + cs_flush + LOG_SYNC_FETCH=yes + return 0 } # ------------------------------------------- AMENDMENT 8, ruling (h): THE @@ -2298,11 +2775,12 @@ refuse_dirty_checkout() { # that wanted to write a row waited behind a lock whose holder was blocked on a # socket, and the estate only moved again when the ssh was killed by hand. # -# So every git call that touches the NETWORK — push, pull, fetch, ls-remote — +# So every git call that touches the NETWORK — fetch, push, ls-remote, clone — # runs under a timeout, and a timeout is not a failure to retry: it is a -# refusal to hold the mutex any longer. The helper aborts any rebase it started, -# releases the lock, prints how to find out whether the push landed anyway, and -# exits 3 with the edit safe in a local commit. +# refusal to hold the mutex any longer. Since Amendment 23 each caller says what +# it means there: before the edit it is S3's refusal, with nothing written; a +# push that timed out is read back, because a hung push often landed; and a +# read-back that cannot answer is `pushed, not confirmed`, exit 3. # # `LANES_GIT_TIMEOUT` is the budget in seconds (default 60; 0 disables the # bound). `LANES_GIT` is the binary, so a test can hand these calls — and ONLY @@ -2356,23 +2834,12 @@ ws_follow() { return 0 } -# What a timed-out network call does. NEVER a retry: the point is to stop -# holding the lock, and a second call to the same hung endpoint holds it twice -# as long. -git_timeout_die() { # - note "TIMED OUT after ${GIT_TIMEOUT}s: $1" - note " Amendment 8(h): the writer does not hold the estate's mutex on a hung" - note " network call. The lock is being released now, before this exits." - "$GIT_BIN" -C "$LANES_REPO" rebase --abort 2>/dev/null || : - note " YOUR EDIT IS SAFE — it is already a local commit:" - "$GIT_BIN" -C "$LANES_REPO" --no-pager log --oneline "origin/$LANES_BRANCH..HEAD" 2>/dev/null | head -n 5 >&2 || : - note " RECOVERY — a hung push often means it LANDED and the connection did not close," - note " so look before you push again:" - note " git -C $LANES_REPO fetch origin $LANES_BRANCH && git -C $LANES_REPO log --oneline origin/$LANES_BRANCH -3" - note " git -C $LANES_REPO push origin $LANES_BRANCH # only if it is not there" - release_lock - die "timed out after ${GIT_TIMEOUT}s on '$1' — the mutex is released and your commit is local; nothing was lost." 3 -} +# A TIMED-OUT NETWORK CALL IS NEVER RETRIED, and since Amendment 23 it never +# dies on its own either (`git_timeout_die`, which aborted a rebase and said +# "your commit is local", is gone with the pull it was written for). Each call +# says what its timeout means where it is made: S3's fetch refuses before the +# edit (`checkout_sync`), a push that timed out is READ BACK, and a read-back +# that times out is `pushed, not confirmed` (`commit_publish`, exit 3). # BYTECODE NEVER RIDES A WORKSPACE COMMIT (opensoft/openRepoTools#162, cause # 19: a handoff's attachments were committed with a whole virtualenv's @@ -2470,7 +2937,8 @@ BRT_EOF # 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. +# every read of the queue starts at HEAD and the push moves the branch. +# (S3 refuses a HEAD off the branch first; this stays as the fence's own.) # * A LOCAL BRANCH NAMED `origin/`, which shadows the tracking ref. Both # ends of the range are spelled in full. # @@ -2502,7 +2970,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, 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_head" = "$cpf_ref" ] || die "refusing to publish: HEAD is ${cpf_head:-detached}, not $cpf_ref, and the push sends $cpf_ref while every read of the queue starts at 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=$? @@ -2544,32 +3012,36 @@ 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 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 + 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: no later write carries it (Amendment 23 S3 refuses every write over it), so archive it (S6.3) and make the rename again. 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 +# ==================================================== AMENDMENT 23 S4 ======= +# +# AFTER THE EDIT: COMMIT BY PATHSPEC, PUSH, READ BACK; A REFUSED PUSH REPLAYS +# THE ONE EDIT. +# +# `commit_publish` replaces `commit_push`, whose push loop pulled with rebase +# in front of every push, captured a peer's concurrent edit, skipped the pull +# past a peer's dirty file (Amendment 5(d)) and, on a conflict, aborted and +# left the write a local commit that "a later write from this checkout may +# already carry". Under S3 there is nothing to pull: the checkout was brought to +# origin's tip under the lock, so the push is a fast-forward or it is refused. +# +# 1. Commit by pathspec. Nothing staged is no commit and no network — the +# publish act aside (`cp_publish_act`, below). +# 2. Push the branch, no pull. +# 3. Read back: fetch, then is the commit an ancestor of origin's tip? Yes is +# 0 and `pushed`. No, or a fetch that failed, is 3, `pushed, not confirmed: +# `, never success; the next write's S3 finds it by content either +# way. A push that timed out is read back too. +# 4. A refused push replays the one edit on a fresh fast-forward +# (`cp_replay`), six attempts in all; then the commit is kept on HEAD and +# named, exit 6. +# +# commit_publish "" [...] — 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 # of one act land in one commit and no reader ever sees half of it. @@ -2617,7 +3089,7 @@ cp_publish_refence() { # `lanes/LANES.md` and `lanes/log/.md`, spelled by the code that computes # them. CP_EXACT="core.ignorecase=false" -commit_push() { +commit_publish() { msg="$1"; shift || : if [ "$#" -gt 0 ]; then CP_PATHS=("$@"); else CP_PATHS=("$LANES_PATH"); fi [ "$NO_GIT" = 1 ] && { note "LANES_NO_GIT=1 — not committing"; return 0; } @@ -2642,47 +3114,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, 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 + # NOTHING STAGED IS NOTHING TO PUSH — FOR EVERY WRITE BUT THE PUBLISH ACT. + # A repeated `set-row-state` whose line is already there stays the no-op it + # always was and reaches no network. S3 refuses every write over an + # unpublished commit, so there is no queue here for it to carry either; + # `commit`, the one caller that sets CP_PUBLISH_AHEAD, publishes one (#194). if git -C "$LANES_REPO" -c "$CP_EXACT" diff --cached --quiet -- "${CP_PATHS[@]}"; then - 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 + if [ "$CP_PUBLISH_AHEAD" = 1 ]; then + cp_publish_act + return $? 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 [ "$cp_publish" = 1 ]; then - : # nothing of this write's own to commit: straight on to the push - elif [ -n "$cp_ren" ]; then + note "nothing staged for ${CP_PATHS[*]} — no commit made" + return 0 + fi + # THE HEAD THIS WRITE'S COMMIT IS MADE ON, read before `git commit` (291 + # below, at `cp_own_commit`). + cp_parent="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" + if [ -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 @@ -2700,132 +3148,512 @@ EOF fi git -C "$LANES_REPO" -c "$CP_EXACT" commit -q -m "$msg" || die "git commit failed" 6 # THE RENAME IS DONE AND IN HEAD: the old path is no longer a pathspec of - # anything below, and the next `commit_push` of this run is not a rename. + # anything below, and the next `commit_publish` of this run is not a rename. CP_PATHS=(${cp_add[@]+"${cp_add[@]}"}) LOG_RENAMED_FROM="" else git -C "$LANES_REPO" -c "$CP_EXACT" commit -q -m "$msg" -- "${CP_PATHS[@]}" || die "git commit failed" 6 fi - [ "$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" - die "initial push of '$LANES_BRANCH' failed" 6 + cp_sha="$(cp_own_commit "$cp_parent" "$msg")" || cp_untold "$cp_parent" "$msg" + note "committed: $msg" + + # ---- 2. THE PUSH: the branch as S3 left it plus this one commit, no pull. + cp_spec="refs/heads/$LANES_BRANCH:refs/heads/$LANES_BRANCH" + cp_u="" + [ "$CS_NO_ORIGIN_BRANCH" = 1 ] && cp_u="-u" + cp_prc=0 + git_net -C "$LANES_REPO" push -q $cp_u origin "$cp_spec" || cp_prc=$? + cp_pto="$GIT_TIMED_OUT" + # ---- 3. THE READ-BACK. A push that went, or that timed out (a hung push + # often landed), is confirmed on origin or reported as not confirmed. + if [ "$cp_prc" = 0 ] || [ "$cp_pto" = 1 ]; then + cp_crc=0; cp_confirm "$cp_sha" || cp_crc=$? + [ "$cp_crc" = 0 ] && { cp_pushed "$cp_sha"; return 0; } + cp_not_confirmed "$cp_sha" "$msg" "$([ "$cp_pto" = 1 ] && printf 'the push timed out after %ss, and ' "$GIT_TIMEOUT")$(cp_why_unconfirmed "$cp_crc")" + fi + # ---- 4. A REFUSED PUSH. Origin is read first: the commit may be there after + # all; a read that fails confirms nothing. + cp_crc=0; cp_confirm "$cp_sha" || cp_crc=$? + case "$cp_crc" in + 0) cp_pushed "$cp_sha"; return 0 ;; + 2) cp_not_confirmed "$cp_sha" "$msg" "the push was refused, and $(cp_why_unconfirmed 2)" ;; + esac + # A REFUSAL WITH ORIGIN UNMOVED IS NOT A LOST RACE: the replay would send the + # same commit onto the same tip and meet the same refusal six times (a hook, + # a permission, a protected branch). git's own words are above; the commit + # is kept on HEAD and named, as at the bound. "KEPT ON HEAD" IS READ HERE + # TOO (w2a-c6's note on slice 1b, the shape slice 2a's A41 took out of its + # S4.4): an unlocked writer's commit on top, or a rewrite that took this + # write's commit off the branch while its push was in flight, is named in + # `cp_not_head`'s words, as the replay names it, with this refusal's own. + if git -C "$LANES_REPO" merge-base --is-ancestor "refs/remotes/origin/$LANES_BRANCH" "$cp_sha" 2>/dev/null; then + cp_head="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" + [ "$cp_head" = "$cp_sha" ] || + cp_not_head "$cp_sha" "$msg" "before or while its push was in flight" \ + "the push of $cp_sha was refused with origin/$LANES_BRANCH unmoved (no lost race: a replay would meet the same refusal)" + # THE COMMITS UNDER IT ARE NAMED TOO (lane openRepoTools-3's re-check of + # #307, 344, its case (e2)): an unlocked writer's stamp landing after S3 + # and before `git commit` sits under this write's commit, and the reset + # below would take it. So where this write's commit does not sit on + # origin's history, they are listed with the STOP line before any reset. + if git -C "$LANES_REPO" rev-parse -q --verify "$cp_sha^" >/dev/null 2>&1 && + ! git -C "$LANES_REPO" merge-base --is-ancestor "$cp_sha^" "refs/remotes/origin/$LANES_BRANCH" 2>/dev/null; then + cp_un="$(git -C "$LANES_REPO" rev-list --count "refs/remotes/origin/$LANES_BRANCH..$cp_sha^" 2>/dev/null || printf 'some')" + note "under it are $cp_un commit(s) that origin/$LANES_BRANCH lacks and this write did not make; list them: git -C $LANES_REPO log --oneline refs/remotes/origin/$LANES_BRANCH..HEAD; if any is not your own write, STOP and take it to the lane its subject names — never reset, stash or overwrite a peer's work" fi - LANES_INDEX_NUDGE=1 - note "pushed (created origin/$LANES_BRANCH)" + note "look at it: git -C $LANES_REPO show $cp_sha" + note "every later write from this checkout refuses (S3's unpushed row) until it is published — LANES_LANE= $SELF commit \"\", the publish act — or archived (S6.3) and \`git -C $LANES_REPO reset --keep refs/remotes/origin/$LANES_BRANCH\` run." + die "the push of $cp_sha was refused with origin/$LANES_BRANCH unmoved, so this is no lost race and a replay would meet the same refusal; the commit is local and kept on HEAD: $cp_sha $msg" 6 + fi + cp_replay "$cp_sha" "$msg" +} + +# THIS WRITE'S OWN COMMIT, KNOWN BY WHAT IT IS AND NOT BY READING HEAD AFTER +# THE FACT (lane openRepoTools-3's review of #307, 291). Until slices 2b and 3, +# lane-start's stamp, lane-handoff's commit and the skill's are made in the +# same checkout with no lock. One landing between `git commit` and a +# `rev-parse HEAD` after it was taken for this write's commit: the HEAD check +# and the drop's compare-and-swap then both passed on ITS sha, and the replay +# dropped it, the other writer's file with it. So where HEAD's parent is the +# HEAD read before the commit, HEAD is this write's commit, as it always was; +# otherwise this write's commit is the one first-parent commit since then whose +# message is this write's, as git stored it (`stripspace`, the cleanup `-m` +# gets). None, or two, is no answer, and nothing is guessed. +cp_own_commit() { # + coc_head="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null)" || return 1 + if [ "$(git -C "$LANES_REPO" rev-parse -q --verify "$coc_head^" 2>/dev/null || :)" = "$1" ]; then + printf '%s\n' "$coc_head" return 0 fi - attempt=1 - while [ "$attempt" -le 6 ]; do - # A peer may have written LANES.md between our commit and this pull. - if ! git -C "$LANES_REPO" -c "$CP_EXACT" diff --quiet -- "${CP_PATHS[@]}"; then - cap="$(git -C "$LANES_REPO" -c "$CP_EXACT" --no-pager diff --numstat -- "${CP_PATHS[@]}" | cut -f1,2 | tr '\t' '/')" - git -C "$LANES_REPO" -c "$CP_EXACT" commit -q -m "LANES(concurrent@$WS): capture an uncommitted registry edit ($cap lines +/-) made by whoever else is writing right now — its author should follow up with a commit that says what it was" -- "${CP_PATHS[@]}" || : - note "captured a concurrent uncommitted edit ($cap lines +/-) as its own commit" - fi - others="$(dirty_elsewhere)" - if [ -n "$others" ]; then - # Someone else's uncommitted file in this shared checkout. Pulling is - # impossible until they commit it, and it is NOT ours to commit — a - # handoff mid-write is exactly the content Amendment 4(d) says its own - # author commits. So skip the pull and try the push: it succeeds unless - # the remote moved, and the register edit is already a local commit - # either way. - 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 "${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 - fi - [ "$GIT_TIMED_OUT" = 1 ] && git_timeout_die "git push origin $LANES_BRANCH" - note "push rejected and the pull is blocked by the files above (attempt $attempt)" - 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. 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 - # The rebase has just put this invocation's commit on top of whatever - # landed first. `claim` hooks in HERE, because that is the moment the - # race is decided: if a peer's claim is on this history, theirs landed. - if [ -n "${CP_AFTER_REBASE:-}" ]; then - "$CP_AFTER_REBASE" || return $? - fi - 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 - fi - [ "$GIT_TIMED_OUT" = 1 ] && git_timeout_die "git push origin $LANES_BRANCH" - note "push raced (attempt $attempt) — retrying" + coc_want="$(printf '%s\n' "$2" | git -C "$LANES_REPO" stripspace 2>/dev/null)" + coc_hit=""; coc_n=0 + while IFS= read -r coc_c; do + [ -n "$coc_c" ] || continue + [ "$(git -C "$LANES_REPO" log -1 --format=%B "$coc_c" 2>/dev/null)" = "$coc_want" ] || continue + coc_hit="$coc_c"; coc_n=$((coc_n + 1)) + done </dev/null) +COC_EOF + [ "$coc_n" = 1 ] || return 1 + printf '%s\n' "$coc_hit" +} +# NO ANSWER (291): another commit was made in this checkout in the moment of +# this write's own, and which one is this write's cannot be told. Nothing is +# pushed on a guess, and nothing is moved. +cp_untold() { # + note "this write committed ($2), and another commit was made in this checkout in the same moment (an unlocked writer: lane-start's stamp, lane-handoff), so which one is this write's cannot be told by its message: nothing was pushed, and nothing was moved." + note "look at them: git -C $LANES_REPO log --oneline ${1:+$1..}HEAD" + note "every later write from this checkout refuses (S3's unpushed row) until they are published — LANES_LANE= $SELF commit \"\", the publish act — or archived (S6.3); if any is not your own write, STOP and take it to the lane its subject names." + die "this write's commit cannot be told from another made in this checkout in the same moment, so nothing was pushed and both are kept on HEAD (Amendment 23 S4): $2" 6 +} +# THE DROP TAKES THIS WRITE'S COMMIT ALONE (291): an unlocked writer's commit +# that landed just BEFORE this write's `git commit`, or between S3 and it, is +# UNDER this write's commit, where the HEAD check cannot see it. The keep +# form's drop goes to origin's tip, and would take it too. So the drop is made +# only where this write's commit sits on origin's history; otherwise it and +# what is under it are kept on HEAD and named. +# +# WHAT IS UNDER IT IS NAMED BY WHAT IS KNOWN (lane openRepoTools-3's re-check +# of #307, 343): commits origin lacks, which this write did not make. An +# unlocked writer's commit here is one cause; history origin no longer has (a +# rewrite there, at the push) or never had (a new branch's first write, raced +# by another) is another, and nothing here tells them apart. The publish act +# is not offered, since origin has moved past HEAD and it refuses there +# ("moved past", 2): the list and the STOP line come first, then S6.3's +# archive, in the publish act's own refusal's order. +cp_under() { # + cu_n="$(git -C "$LANES_REPO" rev-list --count "$3..$1^" 2>/dev/null || printf 'some')" + note "under this write's commit $1 are $cu_n commit(s) that origin/$LANES_BRANCH lacks and this write did not make: another writer's here (an unlocked one: lane-start's stamp, lane-handoff), or history origin no longer has (rewritten there) or never had (a new branch's first write, raced by another). Dropping this write's commit to origin's tip would drop them too, so nothing is moved." + note "every later write from this checkout refuses (S3's unpushed row) until they are archived and dropped here. RECOVERY (S6.3), in this order:" + note " list them: git -C $LANES_REPO log --oneline $3..$1; if any is not your own write, STOP and take it to the lane its subject names — never reset, stash or overwrite a peer's work" + cp_archive_steps + die "the push was refused because origin/$LANES_BRANCH moved, and this write's commit sits on $cu_n commit(s) that origin lacks and this write did not make, so it is not replayed: it and they are kept on HEAD (Amendment 23 S4): $1 $2" 6 +} + +# THE RECOVERY FOR AN UNTRACKED FILE A WRITE WOULD COMMIT (lane +# openRepoTools-3's review of #307, NIT 288). `git commit -- ` of a path +# git does not track answers "pathspec ... did not match any file(s) known to +# git" and exits 1, so the file is added first; and the commit made is then an +# unpublished one, which S3 refuses every writer over, so it is published by +# the publish act. Said where the migration, `retire-rows` and `archive-rows` +# meet one. +# +# UNDER THE OPT-IN, NEVER A COMMIT IN W (lane 3's re-check of #307, 331). The +# migration's and the archive's dry runs are not routed, so they read W, and +# a commit made there as they said was stranded: the publish act routed to +# the lane's own checkout, found nothing to publish there and exited 0, and +# W, holding the commit, was then refused by S5 and by `refresh-workspace`. +# So where `LANES_REPO` is W (by repository, as S5 compares it) the file is +# moved into its maker's own lane checkout and written from there, or +# removed. Anywhere else the publish act names the lane whose checkout this +# is — a routed sweep's runner — so that it routes back here. +untracked_recovery() { # + if [ "$LANES_ROUTE_ON" = 1 ] && repo_is_w; then + printf 'This checkout is W, the readers'"'"' copy, which under LANES_ROUTE_WRITES=1 takes no commit (Amendment 23 S5). Whoever made it moves it into their own lane'"'"'s checkout (mv -- %s/%s %s-lanes//%s, after %s lane-checkout --create where that checkout is not there yet), commits it there as their own write (git -C %s-lanes/ add -- %s && git -C %s-lanes/ commit -m "()" -- %s) and publishes it with the publish act (LANES_LANE= %s commit ""); or removes it if it is nobody'"'"'s (rm -- %s/%s)' \ + "$LANES_REPO" "$1" "$LANES_WS_REAL" "$1" "$SELF" "$LANES_WS_REAL" "$1" "$LANES_WS_REAL" "$1" "$SELF" "$LANES_REPO" "$1" + return 0 + fi + printf 'Whoever made it commits it as their own write, in this checkout (git -C %s add -- %s && git -C %s commit -m "()" -- %s), and publishes it with the publish act (LANES_LANE=%s %s commit "")' \ + "$LANES_REPO" "$1" "$LANES_REPO" "$1" "${LANES_ROUTED:-${LANES_LANE:-}}" "$SELF" +} +# 0 where `LANES_REPO` is W, by repository and not by spelling, as S5 compares +# it (`lane_checkout_enter`): its git common directory is W's, which W +# itself, a directory inside W and a linked worktree of W all share. +repo_is_w() { + [ -n "$LANES_WS_REAL" ] || return 1 + riw_g="$(git_common_real "$LANES_REPO")" + [ -n "$riw_g" ] && [ "$riw_g" = "$(git_common_real "$LANES_WS_REAL")" ] +} + +# 0 when origin's `main` holds , after a fresh fetch; 1 when it does not; +# 2 when the fetch failed. Never a guess. +cp_confirm() { # + git_net -C "$LANES_REPO" -c gc.auto=0 -c maintenance.auto=false fetch -q origin \ + "+refs/heads/$LANES_BRANCH:refs/remotes/origin/$LANES_BRANCH" 2>/dev/null || return 2 + git -C "$LANES_REPO" merge-base --is-ancestor "$1" "refs/remotes/origin/$LANES_BRANCH" 2>/dev/null && return 0 + return 1 +} +cp_why_unconfirmed() { # + case "$1" in + 2) [ "$GIT_TIMED_OUT" = 1 ] && printf 'the read-back fetch of origin %s timed out after %ss' "$LANES_BRANCH" "$GIT_TIMEOUT" || + printf 'the read-back fetch of origin %s failed' "$LANES_BRANCH" ;; + *) printf 'origin/%s, read back, does not hold it' "$LANES_BRANCH" ;; + esac +} +# THE SUCCESS NOTE KEEPS MAIN'S WORDS, `pushed origin/ (attempt N)`, +# byte for byte: `lane-start`, the triad goldens and every caller that reads +# it stay right. What changed is when it prints. Since S4 it prints only after +# the read-back has found this commit on origin, so the word "pushed" now +# means confirmed, and a push it cannot confirm is never this line (exit 3). +cp_pushed() { # + LANES_INDEX_NUDGE=1 + note "pushed origin/$LANES_BRANCH (attempt $CP_ATTEMPT)" + cp_drop_carry_copies +} +# A CARRIED HAND EDIT'S COPIES GO ONCE ORIGIN HAS IT (lane openRepoTools-3's +# 393 on #307, the companion of #308's 351). `lane-end`'s sweep holds a +# checkout on anything in `.git/lanes-edit/` but its five scratch files, since +# a KEPT hand edit there may be a person's only copy; a copy of one origin +# already has would hold the checkout for good. `cp_pushed` is reached only +# where `cp_confirm`'s fresh fetch has found this write's commit, and so the +# edit, on origin. Each copy THIS RUN recorded is removed by its own name: +# never by a glob, since pids recur and an older run's kept copy can carry +# this run's pid; and never only the last, since a lost race replays the +# carry under the same pid and writes a second copy. One that cannot be +# removed is named, and the write is still 0: it is on origin. +cp_drop_carry_copies() { + [ -n "$CP_CARRY_COPIES" ] || return 0 + cdc_gd="$(git -C "$LANES_REPO" rev-parse --absolute-git-dir 2>/dev/null)" || cdc_gd="" + while IFS= read -r cdc_f; do + case "$cdc_f" in + ''|*/*) continue ;; + hand-edit-*-"$$".diff) ;; + *) continue ;; + esac + if [ -n "$cdc_gd" ] && rm -f -- "$cdc_gd/lanes-edit/$cdc_f" 2>/dev/null && [ ! -e "$cdc_gd/lanes-edit/$cdc_f" ]; then + note "origin has the carried hand edit, so its copy is removed: $cdc_gd/lanes-edit/$cdc_f" else - # A TIMED-OUT PULL IS NOT A CONFLICT. Read as one it would send the writer - # to a recovery that does not apply, and `rebase --abort` would be the - # only true line in it. - [ "$GIT_TIMED_OUT" = 1 ] && git_timeout_die "git pull --rebase origin $LANES_BRANCH" - note "REBASE CONFLICT on origin/$LANES_BRANCH — conflicting lines follow:" - for cp in "${CP_PATHS[@]}"; do - grep -n -e '^<<<<<<<' -e '^=======' -e '^>>>>>>>' -- "$LANES_REPO/$cp" 2>/dev/null | head -n 40 >&2 || : - grep -n -A2 -e '^<<<<<<<' -- "$LANES_REPO/$cp" 2>/dev/null | head -n 40 >&2 || : - done - git -C "$LANES_REPO" rebase --abort 2>/dev/null || : - cp_sha="$(git -C "$LANES_REPO" rev-parse HEAD 2>/dev/null)" - note "rebase ABORTED — the worktree is clean and NOT mid-rebase. Your edit is safe in these local commits:" - git -C "$LANES_REPO" --no-pager log --oneline "origin/$LANES_BRANCH..HEAD" 2>/dev/null | head -n 10 >&2 || : - # #32 — AN ABORTED PULL IS NOT PROOF THIS COMMIT NEVER REACHED ORIGIN - # (the same rule RV-B3 took for the handoff stamp's own push, in - # lane-start). This checkout is shared with every lane on the - # workstation, so the very next write out of it pulls this dangling - # commit onto its own and pushes both together — three of the four - # "rebase conflict … nothing was pushed" lines seen on Eagle in one - # hour named a commit that was ALREADY on origin by the time anyone - # read them. LOOK before you reset or redo anything — by ANCESTRY - # (Copilot round 2 on #50, 5203033893): a log capped at a few entries - # can bury this commit below the window while it stays an ancestor, the - # moment four or more later writes land ahead of it. - # ROUND 3 (Copilot 5203261904): `fetch && merge-base ... && echo A || - # echo B` is left-associative — a FAILED fetch also falls to the `||` - # and prints the SAME "not-there" a genuine non-ancestor does, so a - # stale or unreachable origin would silently wave the reset/redo path - # through. Fetch is now its own line, checked before anything else. - # ROUND 4 (Copilot 5203455553): SHA ancestry is the wrong test — the - # very write this whole recovery exists to catch (a peer's own - # `commit_push` pulling THIS dangling commit and rebasing it onto a - # newer base, per the very next branch above) gives it a NEW sha, so - # `merge-base --is-ancestor $cp_sha …` answers "not-there" even once - # the CONTENT is published. `git cherry` compares by PATCH, not by - # sha, so a rebase that only replays the same change (no real - # conflict) is still found. Measured against a real bare repo: a - # commit rebased by a peer this way answers `not-there` under - # `merge-base --is-ancestor` and `-` (found, equivalent) under - # `git cherry` — verified for the genuinely-absent case too (`+`) and - # the plain same-sha case (no output at all, which the `-c '^+'` - # count below also reads as zero, i.e. already there). - note "RECOVERY (in that order):" - 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 # 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 - fi - attempt=$((attempt + 1)) - sleep 3 - done - die "could not push origin/$LANES_BRANCH after 6 attempts; your commit is local — retry later" 6 + note "origin has the carried hand edit, and its copy $cdc_f in $LANES_REPO's .git/lanes-edit/ could not be removed: remove it by hand (lane-end's sweep holds this checkout while it is there)" + fi + done <` wins the short name (703). +# +# "KEPT ON HEAD" IS READ HERE TOO (rv1b-c3's neighbour of NIT 4, found by +# w1b-c6): an unlocked writer's commit on top, or a rewrite that took this +# write's commit off the branch while its push was in flight, is named in +# `cp_not_head`'s and `cp_rewritten`'s words, with this die's own clause and +# its own number. It stays 3, the one cause the table gives it, because what +# no writer can settle is where the push went, and a commit on top does not +# change that; `cp_not_head`'s optional fifth argument carries the number. +cp_not_confirmed() { # + note "this write committed $1 ($2) and cannot say whether it reached origin/$LANES_BRANCH: $3." + note "LOOK, by content: git -C $LANES_REPO fetch origin $LANES_BRANCH && git -C $LANES_REPO cherry -v refs/remotes/origin/$LANES_BRANCH HEAD" + note " no line, or a '-' line for it: it is on origin, and there is nothing to do." + note " a '+' line: it is not, and every later write from this checkout refuses until it is published — LANES_LANE= $SELF commit \"\", the publish act — or archived (S6.3)." + cnc_head="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" + cnc_rc=3 # the die below's number, passed on as `cp_not_head`'s fifth argument + [ "$cnc_head" = "$1" ] || + cp_not_head "$1" "$2" "before or while its push was in flight" "pushed, not confirmed: $1 — $3 (never reported as success)" "$cnc_rc" + die "pushed, not confirmed: $1 — $3; the commit is kept on HEAD, and this is never reported as success (Amendment 23 S4)" 3 +} +# ANOTHER WRITER REWROTE THIS WRITE'S COMMIT (rv1b-c's SHOULD A on slice 1b). +# Until slices 2b and 3, lane-start's stamp and lane-handoff run +# `pull --rebase` and push in the same checkout with no lock, and one that runs +# while this write's push is in flight rebases this write's commit, and may +# push its copy. HEAD then does not hold this sha at all: nothing of this +# write's is "kept on HEAD", and nothing is lost either. What the rewrite made +# is read by content, and the words say so. +cp_rewritten() { # [, as `cp_not_head` takes it] [] + note "HEAD is $3, and this write's commit $1 is not in it: another writer in this checkout rewrote it while its push was in flight (a rebase with no lock: lane-start's stamp, lane-handoff), so it is not replayed." + note "read what reached origin, by content: git -C $LANES_REPO fetch origin $LANES_BRANCH && git -C $LANES_REPO cherry -v refs/remotes/origin/$LANES_BRANCH HEAD" + note " no '+' line: everything here is on origin, this write's change too if the other writer pushed its copy; that copy went out without this write's own checks on origin's new tip, so read it there: git -C $LANES_REPO log --oneline -5 refs/remotes/origin/$LANES_BRANCH" + note " a '+' line: it is not on origin, and every later write from this checkout refuses (S3's unpushed row) until it is published (LANES_LANE= $SELF commit \"\", the publish act) or archived (S6.3)." + die "${4:-the push was refused because origin/$LANES_BRANCH moved}, and another writer in this checkout rewrote this write's commit, so it is not replayed: $1 $2 is no longer on HEAD (Amendment 23 S4)" "${5:-6}" +} +# THE COMMIT ON HEAD IS NOT THIS WRITE'S (rv1b's MUST 4 on slice 1b, and +# rv1b-c2's SHOULD C). Asked before the drop, again where the drop's +# compare-and-swap was refused, and before the refusal with origin unmoved +# (w2a-c6's note). "Kept on HEAD" is said only where HEAD holds this write's +# commit, read by `merge-base --is-ancestor`, never assumed. The fourth +# argument, given by the refusals that are not a lost race (origin unmoved, the +# publish act's, and a push that could not be confirmed), is their own clause +# in place of "origin moved", and no drop is named, since none would be made; +# every other caller (slice 2a's S4.4 among them) gives three. A fifth, given +# only by `cp_not_confirmed`, is the exit status, which stays 3 there. WHERE +# HEAD IS STILL THIS WRITE'S COMMIT (rv1b-c3's NIT E: the drop's compare-and- +# swap refused for a reason that is not a moved branch, a `refs/heads/.lock` +# held), no commit was made on top, and none is named: git could not move the +# branch, and nothing was moved. WHERE ORIGIN MOVED (no fourth argument) the +# publish act refuses ("moved past", 2), so the recovery is S6.3's archive, +# `cp_recovery_steps` (705); with a fourth, origin did not move, and the +# publish act is offered. +cp_not_head() { # [] [] + cnh_why="${4:-the push was refused because origin/$LANES_BRANCH moved}" + cnh_drop=", and dropping this write's commit to replay it would drop that one too" + [ -z "${4:-}" ] || cnh_drop="" + cnh_head="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" + if [ -z "$cnh_head" ]; then + note "HEAD cannot be read, so whether this write's commit $1 is still on it is unknown: read git -C $LANES_REPO status and git -C $LANES_REPO reflog -5 once nothing else writes here." + die "$cnh_why, and HEAD cannot be read, so it is not replayed: $1 $2 (Amendment 23 S4)" "${5:-6}" + fi + if [ "$cnh_head" = "$1" ]; then + note "HEAD is still this write's commit $1, so no commit was made on top of it: git could not move refs/heads/$LANES_BRANCH off it $3, and nothing was moved." + if [ -z "${4:-}" ]; then + note "look at it: git -C $LANES_REPO show $1" + cp_recovery_steps "it is" + else + note "look at it: git -C $LANES_REPO show $1; every later write from this checkout refuses (S3's unpushed row) until it is published — LANES_LANE= $SELF commit \"\", the publish act — or archived (S6.3)." + fi + die "$cnh_why, and git could not move the branch off this write's commit to drop it, so nothing was moved and it is kept on HEAD: $1 $2" "${5:-6}" + fi + # A COMMIT ON TOP IS ONE CASE; A REWRITE IS ANOTHER (rv1b-c's SHOULD A). + git -C "$LANES_REPO" merge-base --is-ancestor "$1" HEAD 2>/dev/null || + cp_rewritten "$1" "$2" "$cnh_head" "$cnh_why" "${5:-6}" + note "HEAD is $cnh_head, not this write's commit $1: another commit was made on top of it in this checkout $3$cnh_drop." + note "look at both: git -C $LANES_REPO log --oneline $1^..HEAD" + if [ -z "${4:-}" ]; then + cp_recovery_steps "they are" + else + note "every later write from this checkout refuses (S3's unpushed row) until they are published — LANES_LANE= $SELF commit \"\", the publish act — or archived (S6.3)." + fi + die "$cnh_why, and this write's commit is no longer HEAD, so it is not replayed: it and the commit on top of it are kept on HEAD (Amendment 23 S4): $1 $2" "${5:-6}" +} +# THE DROP MOVED THE BRANCH, ITS TREE STEP WAS REFUSED, AND THE BRANCH COULD +# NOT BE PUT BACK: another writer committed on it in the moment between. This +# write's commit is then on no branch here, and nothing is "kept"; the reflog +# names it, and nothing more is moved. +cp_lost_hold() { # + note "the replay moved refs/heads/$LANES_BRANCH from this write's commit $1 to $3, git refused to move the index and the working tree after it, and the branch could not be put back: another writer in this checkout committed on it in between (HEAD is $(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || printf unreadable))." + note "nothing more is moved. Read it once nothing else writes here: git -C $LANES_REPO status, git -C $LANES_REPO log --oneline -3 and git -C $LANES_REPO reflog -5 (which names $1); the index still holds this write's tree." + die "the push was refused because origin/$LANES_BRANCH moved, and this write's commit could be neither dropped cleanly nor put back, so it is not replayed: $1 $2 is off HEAD, and git -C $LANES_REPO reflog -5 names it (Amendment 23 S4)" 6 +} +# S4.4 AND S4.5 — A REFUSED PUSH REPLAYS THE ONE EDIT ON A FRESH FAST-FORWARD, +# SIX ATTEMPTS IN ALL. The drop takes exactly this write's commit off the +# branch — S3 allowed no other unpublished one — and keeps every other change +# in the tree (or refuses, naming it). Then this file runs again with the same argv and the +# same pid, the lock adopted across the `exec`, and the verb re-reads its file +# and re-runs its own checks on origin's new tip: never a `pull --rebase`, +# never a rebase of a stack. At the sixth attempt the commit is kept on HEAD +# and named, and every later write from this checkout refuses under S3 until +# the lane publishes or archives it (ratified choices 4 and 5). +cp_replay() { # + # THE COMMIT DROPPED IS THIS WRITE'S AND NO OTHER (rv1b's pre-review of + # slice 1b, MUST 4). Until slices 2b and 3, lane-start's stamp, lane-handoff's + # commit and the skill's commit are made in the same checkout with no lock, + # so one can land on top of this write's commit while its push is in flight. + # The drop below would then take that commit too, and say nothing. So HEAD + # must still be this write's commit, or both are kept on HEAD and named. One + # landing just after `git commit` came BEFORE the push (291's case (a)), so + # the words say "before or while" (343). + cr_head="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" + [ "$cr_head" = "$1" ] || cp_not_head "$1" "$2" "before or while its push was in flight" + # A FILE THIS WRITE EDITS IN PLACE IS GONE FROM ORIGIN (slice 2a's A37, + # `CP_KEEP_IF_GONE`): the re-run would find nothing to write into and drop + # the write with its commit. Asked of the base this write's commit was made + # on, so a file the write itself creates is never "gone"; and asked after + # the HEAD check, so "kept on HEAD" is said only of a commit HEAD is. Origin + # was just fetched (`cp_confirm`), and a file its base had and origin lacks + # means origin moved, so the origin-unmoved refusal can never be this one. + if [ -n "$CP_KEEP_IF_GONE" ] && + git -C "$LANES_REPO" cat-file -e "$1^:$CP_KEEP_IF_GONE" 2>/dev/null && + ! git -C "$LANES_REPO" cat-file -e "refs/remotes/origin/$LANES_BRANCH:$CP_KEEP_IF_GONE" 2>/dev/null; then + note "origin/$LANES_BRANCH no longer has $CP_KEEP_IF_GONE, which this write edits in place and its base had: another writer deleted or moved it while this write's push was in flight." + note "look at it: git -C $LANES_REPO show $1; and at origin's: git -C $LANES_REPO log -1 --stat refs/remotes/origin/$LANES_BRANCH -- $CP_KEEP_IF_GONE" + cp_recovery_steps "it is" + die "the push was refused because origin/$LANES_BRANCH moved, and origin's copy of $CP_KEEP_IF_GONE was deleted while this write's push was in flight, so a replay would find nothing to write into and drop this write; the commit is local and kept on HEAD: $1 $2" 6 + fi + if [ "$CP_ATTEMPT" -ge 6 ]; then + note "origin/$LANES_BRANCH moved under this write six times (Amendment 23 S4, ratified choice 4)." + note "look at it: git -C $LANES_REPO show $1" + cp_recovery_steps "it is" + die "could not push origin/$LANES_BRANCH after six attempts; the commit is local and kept on HEAD: $1 $2" 6 + fi + cr_err="$(git -C "$LANES_REPO" rev-parse --absolute-git-dir 2>/dev/null)/lanes-edit/reset.err" + # `commit`'S CARRY FORM KEEPS ITS EDIT. Every other verb makes its edit again + # from its argv; `commit`'s is the hand edit, so the commit is dropped to its + # parent — S3's base — with the change left in the working tree, unstaged, + # and the re-run carries it across the fast-forward as attempt 1 did (or + # refuses, keeping it in a file, where it no longer applies). + cr_to="refs/remotes/origin/$LANES_BRANCH"; [ "$CP_REPLAY_KEEPS_EDIT" = 1 ] && cr_to="$1^" + cr_to="$(git -C "$LANES_REPO" rev-parse -q --verify "$cr_to^{commit}" 2>/dev/null || :)" + [ -n "$cr_to" ] || + die "the push was refused because origin/$LANES_BRANCH moved, and git could not name the commit to drop this write's to, so nothing was moved and it is kept on HEAD: $1 $2" 6 + # THE KEEP FORM'S DROP GOES TO ORIGIN'S TIP, so it is made only where this + # write's commit sits on origin's history (291, `cp_under`). The carry form + # drops to `$1^`, which takes this write's commit alone by construction. + if [ "$CP_REPLAY_KEEPS_EDIT" != 1 ] && git -C "$LANES_REPO" rev-parse -q --verify "$1^" >/dev/null 2>&1 && + ! git -C "$LANES_REPO" merge-base --is-ancestor "$1^" "$cr_to" 2>/dev/null; then + cp_under "$1" "$2" "$cr_to" + fi + # THE DROP IS A COMPARE-AND-SWAP (rv1b-c2's SHOULD C on slice 1b). The + # HEAD check above and the drop are two git calls, and an unlocked commit + # (lane-start's stamp, lane-handoff's, until slices 2b and 3) can land + # between them. `reset` would take it too, and what it records of the move, + # ORIG_HEAD, is a slot every git process here writes: an up-to-date pull + # rewrites it without moving HEAD. So the branch is moved only from this + # write's own commit — `update-ref ` is refused otherwise, + # and nothing is moved — by the sha resolved above, which no reader's fetch + # can move under it; then the index and the working tree follow, by the same + # two-tree merge `reset --keep` makes (or the index alone, as `--mixed`), + # refused over an uncommitted change in the way. A refused tree step puts + # the branch back the same way, from where the drop put it. + cr_ref="refs/heads/$LANES_BRANCH" + if ! git -C "$LANES_REPO" update-ref -m "lanes-edit: the replay drops $1 (attempt $CP_ATTEMPT)" \ + "$cr_ref" "$cr_to" "$1" 2>"$cr_err"; then + # A REFUSED COMPARE-AND-SWAP IS NOT ALWAYS A COMMIT ON TOP (rv1b-c3's + # NIT E): git also refuses where another process holds + # `refs/heads/.lock` (a peer's commit or a `pack-refs` in flight), and + # HEAD is then still this write's commit. Git's own words are shown, as + # at the tree step below, and `cp_not_head` reads HEAD to say which. + [ ! -s "$cr_err" ] || note "git said: $(head -n 4 -- "$cr_err" 2>/dev/null | tr '\n' ' ')" + cp_not_head "$1" "$2" "between the replay's HEAD check and its drop" + fi + cr_how=-u; [ "$CP_REPLAY_KEEPS_EDIT" = 1 ] && cr_how=-i + if ! git -C "$LANES_REPO" read-tree -m "$cr_how" "$1" "$cr_to" 2>"$cr_err" >/dev/null; then + note "git said: $(head -n 4 -- "$cr_err" 2>/dev/null | tr '\n' ' ')" + git -C "$LANES_REPO" update-ref -m "lanes-edit: the replay puts back $1" "$cr_ref" "$1" "$cr_to" 2>/dev/null || + cp_lost_hold "$1" "$2" "$cr_to" + if [ "$CP_REPLAY_KEEPS_EDIT" = 1 ]; then + die "the push was refused because origin/$LANES_BRANCH moved, and this commit could not be turned back into the hand edit it was to replay it, so it is kept on HEAD: $1 $2" 6 + fi + note "look at it: git -C $LANES_REPO show $1; the change git names is someone's, so commit it by pathspec as its own write, or restore it if it is nobody's — never stash or reset over it." + die "the push was refused because origin/$LANES_BRANCH moved, and this write's commit could not be dropped to replay it (git refused to move the working tree over an uncommitted change), so it is kept on HEAD: $1 $2" 6 + fi + if [ "$CP_REPLAY_KEEPS_EDIT" = 1 ]; then + note "push refused (attempt $CP_ATTEMPT of 6): origin/$LANES_BRANCH moved. Commit $1 is dropped and its change left in the working tree as the hand edit it was, and \`commit\` runs again to carry it onto origin's new tip (Amendment 23 S4)." + else + note "push refused (attempt $CP_ATTEMPT of 6): origin/$LANES_BRANCH moved. Commit $1 is dropped, and this write runs again on origin's new tip (Amendment 23 S4)." + fi + sleep 3 + cleanup_for_exec + LANES__REPLAY_PID="$$"; LANES__ATTEMPT=$((CP_ATTEMPT + 1)) + export LANES__REPLAY_PID LANES__ATTEMPT + [ "${#CP_REPLAY_ENV[@]}" -eq 0 ] || export "${CP_REPLAY_ENV[@]}" + [ -z "$CP_CARRY_COPIES" ] || { LANES__CARRY_COPIES="$CP_CARRY_COPIES"; export LANES__CARRY_COPIES; } + # THE `exec` IS DELIBERATE: it replays this one write on origin's new tip, with the lock held, + # and does not return; the `die` after it runs only if the `exec` itself fails. + # shellcheck disable=SC2093 + exec "${BASH:-bash}" "$RESOLVED" ${ORIG_ARGV[@]+"${ORIG_ARGV[@]}"} + die "could not run $RESOLVED again to replay this write. Its commit was dropped, so nothing of it is on origin or here; run it again." 1 +} + +# THE PUBLISH ACT (Amendment 23 S4; #194's remedy 1, in #214's form): `commit` +# with nothing staged publishes commits already made and writes nothing new. +# S3 ran with every row but the unpushed one, and no fast-forward. The queue is +# LISTED BY CONTENT before anything is pushed. Only a FAST-FORWARD push is made +# (Open question 2): where origin has moved past the stack, a push would be +# refused and a rebase of a stack is the stale-base hazard #198 measured, so it +# refuses, naming S6.3's archive. #214's fence still vouches for what the queue +# holds, and the push sends the commit the fence read. Then the read-back, and +# no replay: there is no edit to repeat. +cp_publish_act() { + cpa_trk="refs/remotes/origin/$LANES_BRANCH"; cpa_br="refs/heads/$LANES_BRANCH" + cpa_rc=0 + cpa_list="$(git --no-replace-objects -C "$LANES_REPO" --no-pager cherry -v "$cpa_trk" "$cpa_br" 2>/dev/null)" || cpa_rc=$? + [ "$cpa_rc" = 0 ] || die "git could not compare $cpa_br with $cpa_trk, so what this publish would send is unknown; nothing was pushed (#194)" 2 + cpa_plus="$(printf '%s\n' "$cpa_list" | grep '^+' || :)" + if [ -z "$cpa_plus" ]; then + note "nothing staged for ${CP_PATHS[*]} — no commit made, and origin/$LANES_BRANCH lacks nothing of this checkout's" + return 0 + fi + cpa_n="$(printf '%s\n' "$cpa_plus" | awk 'END { print NR }')" + note "the publish act (Amendment 23 S4, #194): the $cpa_n commit(s) origin/$LANES_BRANCH lacks by content, listed before anything is pushed:" + printf '%s\n' "$cpa_plus" | head -n 10 | sed 's/^/ /' >&2 || : + [ "$cpa_n" -le 10 ] || note " … and $((cpa_n - 10)) more" + if ! git -C "$LANES_REPO" merge-base --is-ancestor "$cpa_trk" "$cpa_br" 2>/dev/null; then + cp_publish_moved + fi + cp_publish_fence + note "pushing the $cpa_n local commit(s) origin/$LANES_BRANCH does not have, up to ${CP_FENCE_TIP:0:12}" + cpa_prc=0 + git_net -C "$LANES_REPO" push -q origin "$CP_FENCE_TIP:refs/heads/$LANES_BRANCH" || cpa_prc=$? + cpa_pto="$GIT_TIMED_OUT" + cpa_crc=0; cp_confirm "$CP_FENCE_TIP" || cpa_crc=$? + if [ "$cpa_crc" = 0 ]; then cp_pushed "$CP_FENCE_TIP"; return 0; fi + if [ "$cpa_prc" = 0 ] || [ "$cpa_pto" = 1 ] || [ "$cpa_crc" = 2 ]; then + cp_not_confirmed "$CP_FENCE_TIP" "the publish of $cpa_n commit(s)" "$( [ "$cpa_prc" != 0 ] && [ "$cpa_pto" != 1 ] && printf 'the push was refused, and ')$(cp_why_unconfirmed "$cpa_crc")" + fi + if ! git -C "$LANES_REPO" merge-base --is-ancestor "$cpa_trk" "$CP_FENCE_TIP" 2>/dev/null; then + cp_publish_moved + fi + # "THEY ARE KEPT ON HEAD" IS READ, NOT ASSUMED (w1b-c6's second find, the + # shape 638b13a took out of `commit_publish`'s refusal): a commit another + # writer made on top, which the fence leaves local by design, or a rewrite + # that took the queue's tip off the branch while the push was in flight, is + # named in `cp_not_head`'s and `cp_rewritten`'s words. + cpa_head="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" + [ "$cpa_head" = "$CP_FENCE_TIP" ] || + cp_not_head "$CP_FENCE_TIP" "the publish of $cpa_n commit(s)" "before or while its push was in flight" \ + "the push of the $cpa_n commit(s) up to ${CP_FENCE_TIP:0:12} failed with origin/$LANES_BRANCH unmoved" + die "the push of the $cpa_n commit(s) up to ${CP_FENCE_TIP:0:12} failed with origin/$LANES_BRANCH unmoved; they are kept on HEAD, and nothing of them reached origin by this push. Look: git -C $LANES_REPO cherry -v refs/remotes/origin/$LANES_BRANCH HEAD" 6 +} +# OPEN QUESTION 2: A STACK ORIGIN HAS MOVED PAST IS NEVER REBASED. The commits +# stay where they are, and the lane archives them and redoes what is still +# wanted on the new tip, by hand (S6.3). +cp_publish_moved() { + note "origin/$LANES_BRANCH has moved past this stack, so a push would be refused, and a rebase of a stack is the stale-base hazard (#198 §3.1). They stay on HEAD." + # THE LANE IS FILLED IN, AND THE STOP LINE COMES FIRST (lane + # openRepoTools-3's review of #307, NIT 294, and its answer to Q2): as S3's + # own refusal and the manual give them. And the reset names the tracking ref + # in full, since a local branch named `origin/` wins the short name. + cp_recovery_steps + die "refusing to publish: origin/$LANES_BRANCH has moved past the local commits, and the publish act makes only a fast-forward push (Amendment 23 S4, Open question 2). Nothing was pushed." 2 +} +# S6.3'S RECOVERY WHERE ORIGIN HAS MOVED PAST HEAD: the list and the STOP +# line, then the archive and the drop. The publish act's refusal above gives +# it, and so do the four notes that offered the publish act first (705, lane +# openRepoTools-3's re-check of #307's round 2, 343's fix carried to them): +# `cp_replay`'s bound and keep-if-gone notes, and `cp_not_head`'s two where +# origin moved. The publish act refuses there ("moved past", 2), so it is not +# offered. The argument, where given, says what is kept: "it is", "they are". +cp_recovery_steps() { # [] + [ -z "${1:-}" ] || + note "every later write from this checkout refuses (S3's unpushed row) until $1 archived and dropped here: origin/$LANES_BRANCH has moved past HEAD, so the publish act refuses (2)." + note "RECOVERY (S6.3), in this order:" + note " list them: git -C $LANES_REPO cherry -v refs/remotes/origin/$LANES_BRANCH HEAD; 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" + cp_archive_steps +} +# S6.3'S ARCHIVE, AFTER THE LIST AND THE STOP LINE: the publish act's refusal +# above and `cp_under` (343) give it in these words. The archive goes to +# origin first, then the drop here names the tracking ref in full. THE BRANCH +# IS RUNNABLE AS PRINTED (702, lane openRepoTools-3's re-check of #307's round +# 2): the lane is LANES_LANE, or the one S3 was given (a lane named by argument +# alone, LANES_LANE unset), and where no lane is named at all (`commit` with no +# LANES_LANE) the archive carries none. A literal `` made a branch of +# that name, and a shell reads it as a redirection. +cp_archive_steps() { + cas_lane="${LANES_LANE:-${CS_LANE:-}}" + note " git -C $LANES_REPO push origin HEAD:refs/heads/archive/${cas_lane:+$cas_lane-}stranded-$(date -u +%Y%m%dT%H%MZ) # the archive, on origin" + note " git -C $LANES_REPO reset --keep refs/remotes/origin/$LANES_BRANCH # then drop them here" + note " then make again, with lanes-edit.sh, each edit that is still wanted on the register as it now is." } # ====================================================== AMENDMENT 14 ======= @@ -2839,7 +3667,7 @@ EOF # THIS FILE TOUCHES IT IN EXACTLY TWO PLACES, AND NEITHER IS ON AN ACT'S PATH. # # THE NUDGE (clause (d)). After a push has LANDED and the mutex is released — -# `cleanup`, on the way out, once `commit_push` has recorded a push that went +# `cleanup`, on the way out, once `commit_publish` has recorded a push that went # through — ONE DETACHED `lanes-index sync`: stdin closed, stdout and stderr # on /dev/null, descriptors 3 to 9 closed, started from a subshell that # exits at once so this process never has a child to wait for, and its @@ -3013,67 +3841,6 @@ warn_dirty() { git -C "$LANES_REPO" --no-pager diff -- "${CP_PATHS[@]}" 2>/dev/null | awk '/^[+-][^+-]/ {print substr($0,1,200)}' >&2 } -# Called by every mutating subcommand BEFORE it makes its own edit. If -# LANES.md is already dirty at that point, that content is someone else's — -# a peer's hand edit, or a helper run interrupted before it could push — -# never this invocation's. Always warns. In the default --no-sweep mode it -# commits that content now, as its own attributed commit, so it can never -# land combined with this invocation's edit (the 0d84d34 / a1f2438 -# incidents: another lane's row edit landed inside an unrelated commit with -# no trace of whose it was). In --sweep mode it leaves the content staged -# for this invocation's own commit and sets PRE_DIRTY_LANES so the caller -# can annotate its commit subject. -# -# Sets PRE_DIRTY_LANES to the affected lane list in --sweep mode; empty -# otherwise (nothing left to annotate — it was already committed separately). -handle_preexisting() { - PRE_DIRTY_LANES="" - if [ "$#" -gt 0 ]; then CP_PATHS=("$@"); else CP_PATHS=("$LANES_PATH"); fi - [ "$NO_GIT" = 1 ] && return 0 - git -C "$LANES_REPO" diff --quiet -- "${CP_PATHS[@]}" 2>/dev/null && return 0 - lanes="$(identify_changed_lanes)"; lanes="${lanes:-unknown}" - note "WARNING: LANES.md already has uncommitted changes before this edit (row: $lanes) — not this invocation's" - warn_dirty - if [ "$SWEEP_MODE" = "sweep" ]; then - PRE_DIRTY_LANES="$lanes" - note "SWEEP_MODE=sweep — leaving it staged; it will land inside this invocation's own commit, annotated" - else - git -C "$LANES_REPO" add -- "${CP_PATHS[@]}" - git -C "$LANES_REPO" commit -q -m "LANES(pre-existing@$WS): capture an uncommitted registry edit (row $lanes)" -- "${CP_PATHS[@]}" || : - note "captured pre-existing edit (row $lanes) as its own commit" - fi - return 0 -} - -# R11 (Addendum 3, 2026-09-11) — a peer's uncommitted REGISTER edit is -# CAPTURED, not refused. `lanes/LANES.md` is dirty in this shared checkout for -# much of the day — the register takes ~500 commits a day and a half-written -# row is what one of them looks like a second before it lands — so refusing -# every `claim`, `log` and `release` on it would push lanes to stop using the -# tool, and the pre-existing capture already exists precisely to make that -# state safe to rebase over. So: capture FIRST, exactly as every register -# write has done since --no-sweep (the warning included), and let the guard -# re-test the checkout afterwards. -# -# Only when the register is NOT one of this write's own pathspecs: a LANDING -# or a LANDED commits it itself, and the ordinary handle_preexisting call -# covers it there, --sweep included. There is nothing to sweep a register edit -# INTO when this commit does not touch the register, so this capture is always -# its own commit whatever --sweep says. -# -# It is reached only AFTER write_event's first guard, so it never commits on -# behalf of a write that is about to refuse: a checkout dirty in anything ELSE -# is refused before the lock and before this runs, and a refusal that had -# already committed a peer's register edit would be a refusal that wrote. -capture_register_edit() { - case " $* " in *" $LANES_PATH "*) return 0 ;; esac - cre_mode="$SWEEP_MODE" - SWEEP_MODE="no-sweep" - handle_preexisting "$LANES_PATH" - SWEEP_MODE="$cre_mode" - return 0 -} - # ======================================================== Amendment 7 ====== # # THE PER-LANE OBJECT LOG. @@ -3358,6 +4125,29 @@ ensure_log() { el_hits="$(log_files_named_ci "$lane")" el_n="$(printf '%s' "$el_hits" | grep -c . || :)" if [ "$el_n" -gt 1 ]; then + # ONE TRACKED AND ONE UNTRACKED IS A RENAME THAT NEVER LANDED, NOT TWO LOGS + # TO MERGE (rv1b's NIT 2 on slice 1b). S3 fast-forwards before this runs, + # so the state a failed case-only rename leaves — the canonical file here, + # untracked, and the old-cased one still published — now reaches this + # check with BOTH in the working tree, and 15(d)'s hand merge would commit + # the stray copy as a second published file. Its recovery is to remove the + # copy and re-run, and the rename then happens once, in its own commit. + if [ "$el_n" = 2 ] && [ "$NO_GIT" != 1 ]; then + el_t=""; el_u=""; el_tn=0 + while IFS= read -r el_h; do + [ -n "$el_h" ] || continue + if git -C "$LANES_REPO" ls-files --error-unmatch -- "${LANES_LOG_PREFIX}${el_h##*/}" >/dev/null 2>&1; then + el_t="$el_h"; el_tn=$((el_tn + 1)) + else + el_u="$el_h" + fi + done <` (R19): on a checkout that is # behind, a published `lanes/log/repocase-1.md` is invisible to the scan - # above, this write would create `repoCase-1.md`, and `commit_push`-s own + # above, this write would create `repoCase-1.md`, and `commit_publish`'s own # rebase would then land BOTH. The refusal below is fail-closed and its cure # is one command, where the cure for two published files is 15(d)-s hand # merge (Copilot round 1 on openRepoTools#41). @@ -3387,7 +4177,7 @@ ensure_log() { if [ "$el_pub" != "$(log_path_for "$lane")" ] && [ "${el_from##*/}" != "${el_pub##*/}" ]; then el_here="and this checkout does not have it" [ -n "$el_from" ] && el_here="while this checkout has ${LANES_LOG_PREFIX}${el_from##*/} instead — a case-only rename whose commit never landed" - die "lane ${lane}'s object log is published as $el_pub $el_here: the row spells the lane $lane, so the log is $(log_path_for "$lane") (Amendment 15), and writing one here now would leave TWO files for one lane. Pull first — \`git -C $LANES_REPO pull --rebase\` — and re-run; the rename then happens once, inside this write's own commit. Nothing was written." 2 + die "lane ${lane}'s object log is published as $el_pub $el_here: the row spells the lane $lane, so the log is $(log_path_for "$lane") (Amendment 15), and writing one here now would leave TWO files for one lane. Re-run the write: it fast-forwards this checkout to origin's tip under its lock first (Amendment 23 S3), and the rename then happens once, inside its own commit. Nothing was written." 2 fi fi if [ -n "$el_from" ] && [ "$el_from" != "$lf" ]; then @@ -3399,7 +4189,7 @@ ensure_log() { # renaming; two `mv`s do the same job on both kinds of filesystem, and # neither is `git mv`, whose own case-only rename is the thing that differs # between platforms. The caller puts the old path in this write's pathspecs - # and `commit_push` drops its INDEX entry by that exact path before staging + # and `commit_publish` drops its INDEX entry by that exact path before staging # the new one, then commits the index rather than the pathspecs — the one # sequence that records the rename on a case-insensitive filesystem as well # as on this one, argued in full above `CP_EXACT`. @@ -3937,7 +4727,7 @@ lane_log_exists() { # # IT IS SAFE BECAUSE NOTHING INSIDE ONE INVOCATION MOVES IT. `origin/` # is advanced by `log_sync`, which every subcommand runs BEFORE it reads, and by -# `commit_push`, which runs AFTER the last read of a write path — so the ref a +# `commit_publish`, which runs AFTER the last read of a write path — so the ref a # process reads is fixed for as long as that process reads it. The one act that # would break that assumption is a second `log_sync` mid-read, and there is # none; `log_sync` clears this cache anyway, so a caller that adds one gets the @@ -4027,8 +4817,9 @@ register_published() { # [] # that is the ordinary case rather than an error. # # It is NOT cached the way `register_text` is: this file is the small one (the -# rows nobody reads day to day), and a write that rescans it after its rebase -# (`add-row`'s `retired_identity_hits`) must read it as it now stands, which a +# rows nobody reads day to day), and a write that reads it again on its replay +# (`add-row`'s `retired_identity_hits`, run again on origin's new tip after a +# refused push, Amendment 23 S4) must read it as it now stands, which a # cache kept for the process would not. ONE LISTING PINS IT, and only for its # own reads: `worktree_rows` reads the archive once, and where that read # succeeded, `PIN_ARCHIVE` hands its text to every read of the listing it then @@ -4165,130 +4956,6 @@ retired_identity_hits() { # ; stdout: matching retired spellings · 1 not [ -z "$ri_hits" ] || printf '%s\n' "$ri_hits" | LC_ALL=C sort -u } -# EVERY REFUSAL HERE TAKES ITS ROW BACK, not only the retired match (the review -# of #263, M1). This runs after `pull --rebase`, so the add-row is already a -# local commit on top of the peer's archive. A refusal that only returned left -# that commit at HEAD, and the next unrelated write from this checkout pushed -# it: measured, a peer's archive line this scan could not take apart during -# add-row's pull gave exit 1, then `set-row-state` published the row it had -# refused. A read that failed and a line that is not a row are refusals of the -# same row as a retired match is, so all three run the same compensation. -add_row_retirement_rescan() { - ar_rebased_rc=0 - ar_rebased_hits="$(retired_identity_hits "$ADD_ROW_LANE")" || ar_rebased_rc=$? - if [ "$ar_rebased_rc" != 0 ]; then - ar_why="the archive could not be read after rebase, or it carries a line that is not a row (above), so whether lane $ADD_ROW_LANE was retired meanwhile is not known" - ar_subject="cancel add-row whose retirement could not be checked after rebase" - ar_back=1 - elif [ -n "$ar_rebased_hits" ]; then - ar_why="lane $ADD_ROW_LANE was retired by a peer before this add-row could push (archive: $(printf '%s' "$ar_rebased_hits" | tr '\n' ' '))" - ar_subject="cancel add-row rejected by peer retirement" - ar_back=2 - else - return 0 - fi - # Leaving the rejected add-row at HEAD would let the next unrelated registry - # write push it. Remove ONLY this invocation's exact appended line and commit - # that compensation locally; any swept pre-existing edit stays intact. - # - # AND ONLY A ROW THIS CHECKOUT STILL ADDS IS ITS OWN TO TAKE BACK (the delta - # review of #263, D1). A peer that published the byte-identical row first - # makes `pull --rebase` DROP this add-row's commit as already upstream, and - # the one copy left in the register is then the peer's: the exact-line grep - # found it and deleted it, and the next write published that deletion - # (measured: the peer's row gone from origin). So the copy taken back is the - # one this checkout's register adds over origin/$LANES_BRANCH's, read from - # their diff by content — which is what a surviving add-row commit is, under - # whatever sha the rebase gave it, and whatever local commits sit beside it. - # None added: nothing of this add-row's is left, and nothing is taken back. - # One: that line, wherever an identical peer copy sits. More than one, or a - # diff that cannot be read: not this act's to guess, and said so. - # "origin already has the row" would be wrong the other way: an add-row - # commit that SURVIVED beside a peer's identical row at another place would - # be kept, and published by the next write as a second copy. - # - # AND THE LINE IS COUNTED WHATEVER CONTEXT THE DIFF CARRIES (the delta review - # of #263, S3). `-U0` does not hold on its own: a `GIT_DIFF_OPTS=-u3` in the - # environment overrides it outright, and a user's `diff.interHunkContext` - # fuses two nearby changes into one hunk with the lines between them as - # context. The parse counted only `+` lines, so a hunk carrying context put - # the row at the wrong line, the line check refused, nothing was taken back, - # and the next ordinary write published the row add-row had refused (M1 - # again; measured with each of the two). So both: this one diff runs with - # `GIT_DIFF_OPTS` unset and `diff.interHunkContext=0` (a `-c` on the command - # line outranks every config file and `GIT_CONFIG_*` in the environment), and - # the parse advances its new-file line on every line of a hunk but a removed - # one (`-`) or git's "\ No newline" note, so whatever context still arrives - # is counted where it stands. The `unset` is inside the command - # substitution's own subshell, and so touches nothing after it. - # - # AND IT IS READ AS TEXT (delta review 4 of #263, S4). Where git classes the - # register as binary, the diff is one line, "Binary files … differ", with no - # hunk to read: a `binary` or `-diff` attribute, published or local, a diff - # driver with `binary=true`, `core.attributesFile`, `GIT_ATTR_SOURCE`, - # `attr.tree`, a NUL byte in its first 8000 bytes, or a `core.bigFileThreshold` - # below its size (the live register is over 2 MB). add-row then said this - # checkout added no copy of the row, kept its commit, and the next write - # published the row (measured with each). `--text` gives the hunks whatever - # classes the register. - ar_diff="$(unset GIT_DIFF_OPTS; git --no-replace-objects --no-pager -C "$LANES_REPO" -c "$CP_EXACT" -c diff.interHunkContext=0 diff --no-color --no-ext-diff --no-textconv --text -U0 "refs/remotes/origin/$LANES_BRANCH" -- "$LANES_PATH" 2>/dev/null)" || { - note "$ar_why; and this checkout's register could not be compared with origin/$LANES_BRANCH's, so whether a copy of the rejected row here is this add-row's or a peer's is not known. Nothing was taken back: read \`git -C $LANES_REPO log -p origin/$LANES_BRANCH..HEAD -- $LANES_PATH\` and remove this add-row's row before any later push" - return 1 - } - ar_lines="$(printf '%s\n' "$ar_diff" | AR_ROW="$ADD_ROW_TEXT" LC_ALL=C awk ' - /^@@ / { h = $0; sub(/^@@ -[0-9,]+ [+]/, "", h); sub(/[ ,].*$/, "", h); n = h + 0; inh = 1; next } - !inh { next } - { c = substr($0, 1, 1) } - c == "-" || c == "\\" { next } - c == "+" && substr($0, 2) == ENVIRON["AR_ROW"] { print n } - { n++ } - ')" || { - note "$ar_why; and the diff of this checkout's register against origin/$LANES_BRANCH's could not be read, so nothing was taken back: remove this add-row's row before any later push" - return 1 - } - case "$ar_lines" in - '') - if git --no-replace-objects --no-pager -C "$LANES_REPO" show "refs/remotes/origin/$LANES_BRANCH:$LANES_PATH" 2>/dev/null | command grep -qFx -- "$ADD_ROW_TEXT"; then - note "$ar_why. origin/$LANES_BRANCH already carries this exact row, published by a peer, and the rebase dropped this add-row's commit as already upstream: this checkout adds no copy of it, so there is nothing of this add-row's to take back and the peer's row is left as it is." - else - note "$ar_why. This checkout's register carries no copy of the row that origin/$LANES_BRANCH's does not, so there is nothing of this add-row's to take back." - fi - return "$ar_back" ;; - *$'\n'*) - note "$ar_why; and this checkout's register adds more than one exact copy of the rejected row over origin/$LANES_BRANCH's, so which is this add-row's is not known and none was taken back: the local add-row commit must be repaired before any later push" - return 1 ;; - esac - ar_line="$ar_lines" - [ "$(sed -n "${ar_line}p" "$LANES_FILE" 2>/dev/null)" = "$ADD_ROW_TEXT" ] || { - note "$ar_why; and line $ar_line of $LANES_FILE, where the diff puts the rejected row, does not hold it exactly, so nothing was taken back: the local add-row commit must be repaired before any later push" - return 1 - } - # A REFUSAL OF THE TAKE-BACK ITSELF SAYS ADD-ROW'S TRUTH (the delta review of - # #263, NIT): `delete_lines`' own sentences are archive-rows' — "the rows are - # in the archive", "the archive may have been appended" — and false here. - REG_COMMITTED_HERE="this act HAS committed its row for lane $ADD_ROW_LANE, locally and unpushed" - DL_SAYS_UNCHANGED="no row was removed, so that row is still in it and in that commit. Remove it from $LANES_PATH and commit that before any other write in $LANES_REPO, because the next one would publish it" - DL_SAYS_AFTER_WRITE="Its row for lane $ADD_ROW_LANE was not taken back, and this act HAS committed it, locally and unpushed: with the register whole, remove that row from $LANES_PATH and commit that before any other write in $LANES_REPO, because the next one would publish it." - delete_lines "$ar_line" - REG_COMMITTED_HERE=""; DL_SAYS_UNCHANGED=""; DL_SAYS_AFTER_WRITE="" - # A REMOVAL THAT CANNOT BE COMMITTED SAYS SO (delta review 4 of #263, N3). - # These returned in silence: the removal was left written or staged under - # add-row's commit, and the next write's own commit carried it, under its - # own caption, with no one told. - ar_msg="LANES($ADD_ROW_LANE@$WS): $ar_subject" - ar_fix="add-row's commit still holds the row. Commit the removal before any other write in $LANES_REPO, so that write does not carry it under its own caption: \`git -C $LANES_REPO commit -m '$ar_msg' -- $LANES_PATH\`" - git -C "$LANES_REPO" -c "$CP_EXACT" add -- "$LANES_PATH" || { - note "$ar_why; the rejected row was removed from $LANES_FILE, but staging that removal failed, so it is only in the working tree, and $ar_fix" - return 1 - } - git -C "$LANES_REPO" -c "$CP_EXACT" commit -q -m "$ar_msg" -- "$LANES_PATH" || { - note "$ar_why; the rejected row was removed from $LANES_FILE and that removal is staged, but its compensating commit failed, so $ar_fix" - return 1 - } - note "$ar_why; the rejected row was removed in a compensating LOCAL commit, so no later write publishes it. Neither commit was pushed." - return "$ar_back" -} - # Each lane's LAST line on , as # utc lane verb uuid ws object ref payload text file line # @@ -6549,11 +7216,6 @@ write_event() { # the commit are given and stays exactly what it was, with the old path joining # it below as `LOG_RENAMED_FROM` once the rename has actually happened. we_lp="$(log_path_ci "$we_lane")" || die "lane $we_lane's object log is published twice (above), and one lane is ONE lane under any case: its log is ONE file (Amendment 15). Merge them by hand (Amendment 15(d)) and re-run. Nothing was written." 2 - # R11 — the register is EXEMPT from this first test, because it is captured - # below rather than refused. Everything ELSE this checkout has dirty is - # refused HERE: before the lock, before the capture and before anything is - # created, so that a refusal leaves the checkout exactly as it found it. - refuse_dirty_checkout "write $we_verb" "${we_paths[@]}" "$we_lp" "$LANES_PATH" # THE LIFECYCLE SNAPSHOT AS IT STANDS BEFORE THIS LINE EXISTS (openRepoTools#91, # Copilot round 5 on #97). It is read HERE — before the lock, before the append # and before the commit — because it is the pre-image the follow-up at the foot @@ -6578,34 +7240,33 @@ write_event() { we_pre="$(lane_state_preimage "$we_lane" "$we_pay")" ;; esac acquire_lock - capture_register_edit "${we_paths[@]}" - handle_preexisting "${we_paths[@]}" - # AMENDMENT 15 — `ensure_log` IS BELOW THE CAPTURE AND NOT ABOVE IT, and the - # order is load-bearing rather than tidy. It may now RENAME the lane's log to - # the row's own spelling, and the amendment says that rename lands "in that - # same commit" as the write — while `handle_preexisting` exists to commit - # whatever it finds dirty in this write's pathspecs FIRST, as somebody else's - # content. Run above it, the rename was exactly what that capture would - # commit: the one act of this write, taken out of it and attributed to a peer. - # Below it, the checkout is clean when the rename happens and the rename is - # this write's own. The old path joins the pathspecs so the deletion is - # committed beside the append. + # AMENDMENT 23 S3, UNDER THE LOCK AND BEFORE THE LOG IS READ OR CREATED: the + # fetch, the checks and the fast-forward, so the line is appended to origin's + # tip. Its pathspecs are this write's own: the log (at the path it is + # actually at, which may be an older case until `ensure_log` renames it), and + # the register for a LANDING or a LANDED. A pending change to any of them — + # or to the register, whoever made it — refuses; nothing is captured. + checkout_sync "write $we_verb" "$we_lane" write "${we_paths[@]}" "$we_lp" + # THE SEAM AGAIN, ON WHAT S3 BROUGHT IN (lane openRepoTools-3's review of + # #307, 283). The ask above read the ref as last fetched; S3's fetch and + # fast-forward may have brought in the managed ledger's marker for this + # lane since, and a legacy line written over it is the act the ruling + # forbids. Asked here, under the lock and before a byte, it refuses (2) + # with nothing written. The same line follows S3 in the row verbs and + # `rename-lane`. + is_lane_verb "$we_verb" && managed_seam_refuse "$we_lane" "a $we_verb line in its object log" # 283: again, on what S3 brought in + # AMENDMENT 15 — `ensure_log` may RENAME the lane's log to the row's own + # spelling, and that rename lands "in that same commit" as the write. The + # old path joins the pathspecs so the deletion is committed beside the append. ensure_log "$we_lane" [ -n "$LOG_RENAMED_FROM" ] && we_paths+=("$LOG_RENAMED_FROM") - # THEN re-test, against the checkout the capture left behind. The capture is - # not assumed to have worked — handle_preexisting swallows a failed commit by - # design, it never fails the call — and a register still dirty at this point - # would send commit_push down Amendment 5(d)'s skip-the-pull branch, which is - # the branch that disables the rescan deciding a race. - refuse_dirty_checkout "write $we_verb" "${we_paths[@]}" "$we_lp" append_text_line "$we_line" "$(log_file_for "$we_lane")" if [ -n "$we_r6" ]; then append_text_line "$we_r6" "$LANES_FILE" note "Rule 6 line also appended to $LANES_FILE (one commit, ${#we_paths[@]} pathspecs)" fi we_msg="LOG($we_lane@$WS): $we_verb $we_obj" - [ -n "$PRE_DIRTY_LANES" ] && we_msg="$we_msg + sweeps uncommitted edit to row $PRE_DIRTY_LANES" - commit_push "$we_msg" "${we_paths[@]}" + commit_publish "$we_msg" "${we_paths[@]}" we_rc=$? state_events_flush release_lock @@ -6646,6 +7307,22 @@ write_event() { # a paragraph in front of every launch on the workstation to fix a sentence that # is printed in one place. LOG_SYNC_FETCH="" # yes · fell-back · else WHY this run did not fetch +# THE TIP THE CHECKS READ IS THE ONE THE FIRST `log_sync` LEFT (rv1b-c's +# MUST-N on slice 1b). `claim` and `add-row` check what this fetch brought +# and then wait on the lock, and S3 asks whether origin moved under those +# checks. Asking only whether S3's OWN fetch moved the ref missed every move +# another process made to the same ref in between: in a checkout several +# writers share (W, every lane's while routing is off), a peer's push, its +# read-back or any reader's `log_sync` moves `refs/remotes/origin/` +# while this write waits, S3's fetch then finds nothing new, and two lanes +# held one object. The FIRST tip is kept, not the latest: a later `log_sync` +# in the same process can only make the comparison see a move twice, never +# miss one. +cs_seen_mark() { + [ "$CS_SEEN_SET" = 1 ] && return 0 + CS_SEEN="$(git -C "$LANES_REPO" rev-parse -q --verify "refs/remotes/origin/$LANES_BRANCH" 2>/dev/null || :)" + CS_SEEN_SET=1 +} log_sync() { # THE CACHE ABOVE IS THE REF AS IT STOOD; a fetch moves the ref, so it is # dropped here rather than read past. Both copies of it: the variable this @@ -6659,11 +7336,12 @@ log_sync() { lane_alias_flush LOG_SYNC_FETCH="" [ "$NO_GIT" = 1 ] && { LOG_SYNC_FETCH="LANES_NO_GIT=1 is set, so nothing in this run touches git"; return 0; } - [ "${LANES_NO_FETCH:-0}" = 1 ] && { LOG_SYNC_FETCH="LANES_NO_FETCH=1 is set in this environment"; note "LANES_NO_FETCH=1 — not fetching; reading origin/$LANES_BRANCH as the ref already stands here"; return 0; } + [ "${LANES_NO_FETCH:-0}" = 1 ] && { LOG_SYNC_FETCH="LANES_NO_FETCH=1 is set in this environment"; note "LANES_NO_FETCH=1 — not fetching; reading origin/$LANES_BRANCH as the ref already stands here"; cs_seen_mark; return 0; } git -C "$LANES_REPO" rev-parse --git-dir >/dev/null 2>&1 || { LOG_SYNC_FETCH="$LANES_REPO is not a git checkout"; return 0; } if ! remote_has_branch; then LOG_SYNC_FETCH="origin/$LANES_BRANCH could not be reached" [ "$GIT_TIMED_OUT" = 1 ] && LOG_SYNC_FETCH="$LOG_SYNC_FETCH — ls-remote timed out after ${GIT_TIMEOUT}s" + cs_seen_mark return 0 fi if git_net -C "$LANES_REPO" fetch -q origin "$LANES_BRANCH" 2>/dev/null; then @@ -6672,6 +7350,7 @@ log_sync() { LOG_SYNC_FETCH=fell-back note "fetch $([ "$GIT_TIMED_OUT" = 1 ] && printf 'timed out after %ss' "$GIT_TIMEOUT" || printf 'failed') — reading the logs as they stand locally" fi + cs_seen_mark return 0 } @@ -11975,7 +12654,7 @@ gh_comment() { printf '%s\n' "$gc_url" } -# The hook `commit_push` calls after every `pull --rebase`, before it pushes. +# The check a `claim` replay runs first (Amendment 23 S4), on origin's new tip. # If another lane's CLAIMED or TAKEOVER on this object is on the rebased # history, THEIRS LANDED FIRST and this claim has lost. Git's push # serialization is the arbiter across workstations; the local `mkdir` mutex @@ -12015,9 +12694,10 @@ EOF claim_rescan_hook() { CLAIM_WINNER="" - # The pull has just moved `origin/`, and OUR commit is not on it — - # so everything this read can see landed before ours, which is the whole - # test. Flush the cache first: the ref moved a moment ago. + # AMENDMENT 23 S4: THE REPLAY'S FIRST CHECK. This claim's push was refused, + # its commit dropped, and origin's new tip fetched — so OUR line is not on + # `origin/`, and everything this read can see landed before ours, + # which is the whole test. Flush the cache first: the ref moved a moment ago. state_events_flush # ISSUE #30's OWN RACE (Copilot round 12, PR #61): the dead-lane verdict was # read ONCE, before this claim's first pull, and nothing about $CLAIM_OBJ has @@ -12098,6 +12778,39 @@ EOF return 7 } +# Lost: Rule 1 says the lane STOPS AND REPORTS. The claim is abandoned, never +# queued — Rule 7's queueing is for substrates, which this amendment does not +# touch. TWO CAUSES, TWO EXIT CODES (Copilot's own "closer look" on 21b8e82, PR +# #61): 7 is the documented, pre-existing CLAIM-LOST — a rival's claim landed +# on main first; 9 is issue #30's own dead-lane verdict failing to reconfirm +# before this takeover's push landed. Both write the same CLAIM-LOST event +# (this attempt did not succeed, in either cause), but automation reading the +# PROCESS exit must be able to tell them apart, so the exit is never coerced to +# 7 for the second. Since Amendment 23 S4 this is reached from a replay, after +# `claim_rescan_hook`, and the CLAIM-LOST line is the only one of this claim's +# that reaches origin: the CLAIMED or TAKEOVER commit was dropped. +claim_lost_write() { # <7|9> + clw_rc="$1"; clw_lane="$2"; clw_obj="$3"; clw_uuid="$4" + # "ALIVE AGAIN" ONLY WHERE IT WAS SEEN (Copilot on 05d7889, PR #61): + # `claim_rescan_hook` sets `CLAIM_ABANDON_REVIVED` for a confirmed revival — a + # live session now backs the lane, or its log moved past the terminal line — + # and leaves it empty where the verdict simply could not be reconfirmed (an + # unreadable log, or an unreadable records tree). The exit is 9 either way; + # only the words differ. + if [ "$clw_rc" = 9 ] && [ -n "$CLAIM_ABANDON_REVIVED" ]; then + note "TAKEOVER ABANDONED — lane $CLAIM_WINNER is alive again. Rule 1: stop and report; do not author a successor." + write_event "$clw_lane" CLAIM-LOST "$clw_obj" "→" "lane:$CLAIM_WINNER" "abandoned: $CLAIM_WINNER is no longer dead, takeover withdrawn before landing" "$(utc_now)" "$clw_uuid" + elif [ "$clw_rc" = 9 ]; then + note "TAKEOVER ABANDONED — lane $CLAIM_WINNER's dead-lane verdict could not be reconfirmed before this push landed (above). Rule 1: stop and report; do not author a successor." + write_event "$clw_lane" CLAIM-LOST "$clw_obj" "→" "lane:$CLAIM_WINNER" "abandoned: the dead verdict on $CLAIM_WINNER could not be reconfirmed; takeover withdrawn before landing" "$(utc_now)" "$clw_uuid" + else + note "CLAIM LOST — lane $CLAIM_WINNER's claim on $clw_obj landed on main first. Rule 1: stop and report; do not author a successor." + write_event "$clw_lane" CLAIM-LOST "$clw_obj" "→" "lane:$CLAIM_WINNER" "abandoned: $CLAIM_WINNER landed its claim first" "$(utc_now)" "$clw_uuid" + fi + who_object "$clw_obj" || : + exit "$clw_rc" +} + # The URL of the stale claim a TAKEOVER supersedes — Rule 1's takeover comment # must name it. Read from GitHub, because that is where the comment is. gh_stale_claim_url() { @@ -12351,17 +13064,23 @@ migrate_state_cells() { # <1 = the act, 0 = the dry run> # THE FETCH FIRST (R30), AND A CHECKOUT THAT IS BEHIND IS REFUSED. This one # act rewrites every row of the register and appends to every lane's log in a # single commit; made against a register that is not the published one, each - # of those rewrites is a conflict for `commit_push`'s rebase to resolve, and - # the one it cannot resolve costs the whole migration its atomicity. + # of those rewrites is made on a base origin has moved past, and S4 replays + # the whole act or keeps it on HEAD — never a rebase of several rows. log_sync + # AMENDMENT 23 S3: THE ACT TAKES ITS LOCK AND S3 UNDER IT BEFORE THE BEHIND + # TEST, so the test below reads a checkout already at origin's tip and can + # fire only for the dry run, which takes no lock and moves nothing. + if [ "$msc_do" = 1 ]; then + acquire_lock + checkout_sync "migrate-state-cells" "${LANES_LANE:-}" write "$LANES_PATH" + fi if [ "$NO_GIT" != 1 ] && have_remote_ref; then - msc_behind="$(git -C "$LANES_REPO" rev-list --count "HEAD..origin/$LANES_BRANCH" 2>/dev/null || printf '')" + msc_behind="$(git -C "$LANES_REPO" rev-list --count "HEAD..refs/remotes/origin/$LANES_BRANCH" 2>/dev/null || printf '')" case "$msc_behind" in '' | 0) : ;; - *) die "this checkout is $msc_behind commit(s) behind origin/$LANES_BRANCH. The migration rewrites EVERY row and every lane's log in ONE commit, so it is made against the published register or not at all. Pull first — \`git -C $LANES_REPO pull --rebase\` — and re-run. Nothing was written." 2 ;; + *) die "this checkout is $msc_behind commit(s) behind origin/$LANES_BRANCH. The migration rewrites EVERY row and every lane's log in ONE commit, so it is made against the published register or not at all. The act itself fast-forwards first, under its lock (Amendment 23 S3); for this dry run, \`git -C $LANES_REPO merge --ff-only origin/$LANES_BRANCH\`, and re-run. Nothing was written." 2 ;; esac fi - refuse_dirty_checkout "migrate-state-cells" "$LANES_PATH" # THE MUTEX COMES BEFORE THE SCAN, NOT BETWEEN THE SCAN AND THE WRITE (Copilot # round 2 on openRepoTools#82). This act reads every row's LINE NUMBER and its @@ -12375,7 +13094,7 @@ migrate_state_cells() { # <1 = the act, 0 = the dry run> # under it costs a report one line of accuracy and nothing else — and a read # that holds the estate's one register mutex for the seconds this scan takes # is a read that blocks every lane's write to say what it would have done. - [ "$msc_do" = 1 ] && acquire_lock + # (The act's lock is taken above, before S3 and the behind test.) # TWO ROWS FOR ONE LANE UNDER TWO CASES ARE 15(d)'s HAND MERGE, and both of # them are skipped rather than migrated into one log under a name that means @@ -12479,22 +13198,65 @@ EOF msc_loc="$(log_files_named_ci "$msc_lane")" msc_ln="$(printf '%s' "$msc_loc" | grep -c . || :)" if [ "$msc_ln" -gt 1 ]; then - msc_why="this checkout holds $msc_ln object logs for it whose names differ only by case — 15(d)'s hand merge" + # THE PUBLISHED LOG TRACKED AND AN UNTRACKED COPY BESIDE IT IS THE + # STRAY COPY, NOT TWO LOGS TO MERGE (NIT 4's neighbour on slice 1b; + # `ensure_log`'s B36 refusal names the same state): what a case-only + # rename whose commit never landed leaves once the published log is + # back. A hand merge would commit the copy as a second published + # file for one lane. Said only where the tracked one is the log + # origin publishes; any other pair keeps 15(d)'s words. + msc_lt=""; msc_lu=""; msc_ltn=0 + if [ "$msc_ln" = 2 ] && [ "$NO_GIT" != 1 ]; then + while IFS= read -r msc_lg; do + [ -n "$msc_lg" ] || continue + if git -C "$LANES_REPO" -c "$CP_EXACT" ls-files --error-unmatch -- "${LANES_LOG_PREFIX}${msc_lg##*/}" >/dev/null 2>&1; then + msc_lt="$msc_lg"; msc_ltn=$((msc_ltn + 1)) + else + msc_lu="$msc_lg" + fi + done </dev/null; then + msc_why="its object log is published as $msc_pub while this checkout has ${msc_rel##*/}, committed here and not on origin: a case-only rename whose commit never landed. The publish act refuses it (#194: a rename deletes and re-adds the log) and no later write carries it (S3 refuses every write over it), so archive it (S6.3): git -C $LANES_REPO push origin HEAD:refs/heads/archive/${msc_lane}-stranded-$(date -u +%Y%m%dT%H%MZ), then git -C $LANES_REPO reset --keep refs/remotes/origin/$LANES_BRANCH, and re-run; the lane's next write renames the log once, inside its own commit" + elif [ "$NO_GIT" != 1 ] && git -C "$LANES_REPO" -c "$CP_EXACT" ls-files --error-unmatch -- "$msc_rel" >/dev/null 2>&1; then + msc_why="its object log is published as $msc_pub while this checkout has ${msc_rel##*/} STAGED and not committed: a case-only rename whose commit never landed (a write's \`git commit\` failed after its \`add\`), so there is no commit to archive and nothing on origin to keep. Unstage it (git -C $LANES_REPO restore --staged -- $msc_pub $msc_rel), which leaves the copy NOT TRACKED, then compare them (git -C $LANES_REPO show refs/remotes/origin/$LANES_BRANCH:$msc_pub | diff - \"$msc_lf\"): a line only the copy holds belongs to a write that never landed, and is re-run as that write. Then remove the copy (rm -- \"$msc_lf\"), put the published log back (git -C $LANES_REPO checkout HEAD -- $msc_pub) and re-run; the lane's next write renames it once, inside its own commit" + else + msc_why="its object log is published as $msc_pub while this checkout has ${msc_rel##*/} NOT TRACKED: the copy left by a case-only rename whose commit never landed, which is not merged by hand, since a commit of it would publish a second file for one lane (Amendment 15). Compare them (git -C $LANES_REPO show refs/remotes/origin/$LANES_BRANCH:$msc_pub | diff - \"$msc_lf\"): a line only the copy holds belongs to a write that never landed, and is re-run as that write. Then remove the copy (rm -- \"$msc_lf\"), put the published log back (git -C $LANES_REPO checkout HEAD -- $msc_pub) and re-run; the lane's next write renames it once, inside its own commit" + fi elif [ "$NO_GIT" != 1 ] && ! git -C "$LANES_REPO" ls-files --error-unmatch -- "$msc_rel" >/dev/null 2>&1; then # AN UNTRACKED LOG IS SOMEBODY'S UNCOMMITTED WORK, AND THIS ACT WOULD # COMMIT IT (Copilot round 2 on openRepoTools#82). A log that is - # TRACKED and dirty is already refused for the whole run by - # `refuse_dirty_checkout` above, which exempts the register alone and - # reads staged and unstaged alike; an untracked file is in neither of - # those lists, so appending to it here would put a peer's first lines + # TRACKED and dirty is refused before the first byte, by S3's + # `cs_own_pending` on every log this act writes; an untracked file + # is in no diff at all, so appending to it here would put a peer's first lines # into this migration's one commit under this migration's message. # The row is skipped and the file named, exactly as the other five # reasons are, and its cell keeps every character. - msc_why="its object log ${msc_rel} exists here but is NOT TRACKED — somebody's uncommitted work, which this act would commit inside the migration's own commit. Commit it first (\`git -C $LANES_REPO commit -m \"\" -- $msc_rel\`) and re-run" + msc_why="its object log ${msc_rel} exists here but is NOT TRACKED — somebody's uncommitted work, which this act would commit inside the migration's own commit. $(untracked_recovery "$msc_rel"); then re-run" fi else msc_rel="$msc_pub"; msc_lf="$LANES_LOG_DIR/${msc_pub##*/}" @@ -12628,25 +13390,24 @@ EOF # rewritten below are the ones this invocation read (Copilot round 2 on # openRepoTools#82). # - # AND A PEER'S REGISTER EDIT IS CAPTURED HERE, AFTER EVERY REFUSAL AND SKIP OF - # THE SCAN AND BEFORE THE FIRST BYTE IS WRITTEN (openRepoTools#157 item 4b). - # Captured before the scan, a refusal in it — the seam's, or the "nothing to - # migrate" one — left a `LANES(pre-existing@…)` commit behind: a refusal that - # wrote, which `capture_register_edit` names as the one thing it must never - # be. The capture commits the file's bytes and changes none of them, so what - # the scan read is what is committed, as it was. `retire_rows` and - # `archive_rows` capture at their writes for the same reason. - handle_preexisting "$LANES_PATH" + # AND EVERY PATH THIS ACT COMMITS IS ASKED AGAIN, AFTER EVERY REFUSAL AND SKIP + # OF THE SCAN AND BEFORE THE FIRST BYTE IS WRITTEN (Amendment 23 S3 step 4; + # openRepoTools#157 item 4b's place). A pending change to the register, a log + # or the archive refuses with nothing written: a pathspec commit would carry + # it under this act's subject. `retire_rows` and `archive_rows` ask at their + # writes for the same reason. (The `LANES(pre-existing@…)` capture that stood + # here is withdrawn, ratified choice 9.) + cs_own_pending "migrate-state-cells" "${LANES_LANE:-}" "$LANES_PATH" "${LANES_LOG_PREFIX%/}" "${LANES_PREFIX}archive" # # EVERYTHING BELOW IS ONE COMMIT OR NONE. The archive, the appended logs and # the rewritten rows are made on disk first and published by the single - # `commit_push` at the end, so a refusal in the middle of them — a log whose + # `commit_publish` at the end, so a refusal in the middle of them — a log whose # append could not be proved, a row whose rewrite moved more than one line — # leaves this checkout DIRTY and the register unpublished, which is what # `git status` then shows and `git checkout -- lanes` undoes whole. Nothing is # half-published, because the commit is the only thing that publishes; and a - # re-run on a checkout still holding that half is REFUSED by - # `refuse_dirty_checkout` above rather than made twice. + # re-run on a checkout still holding that half is REFUSED by S3 (the + # register's pending edit, or `cs_own_pending` above) rather than made twice. mkdir -p -- "$LANES_DIR/archive" || die "could not create $LANES_DIR/archive — the archive is written BEFORE anything else changes, so nothing has been" 6 # A `>` REDIRECT, WHICH FOLLOWS A SYMLINK, and the source read whole first: # this copy is the file's state before this act and it is one of the three @@ -12683,7 +13444,7 @@ $(cat -- "$msc_tmp/cells") EOF msc_msg="LANES(migrate@$WS): Amendment 13(e) — $msc_ok state cells become one phrase; $msc_lines lines ($msc_noted NOTED, $msc_ruled RULED) into the lanes' own logs; pre-migration register archived" - commit_push "$msc_msg" "${msc_paths[@]}" + commit_publish "$msc_msg" "${msc_paths[@]}" msc_rc=$? release_lock # THE STAGING GOES ON THE WAY OUT, on both paths: this directory holds a block @@ -13135,7 +13896,7 @@ retire_rows() { # … [--reason ""] [--writer ] # R30 — THE FETCH FIRST, AND A CHECKOUT THAT IS BEHIND IS REFUSED, for the # reason the migration gives: this act rewrites several rows and appends to # several logs in ONE commit, and made against a register that is not the - # published one each of those is a conflict for `commit_push`'s rebase. + # published one each of those is made on a stale base (Amendment 23 S3). log_sync if [ "$NO_GIT" != 1 ] && [ "$LOG_SYNC_FETCH" != yes ]; then die "retire-rows requires a successful fresh fetch before rewriting several rows; $LOG_SYNC_FETCH. The local origin/$LANES_BRANCH ref may be stale. Nothing was written." 1 @@ -13156,21 +13917,21 @@ retire_rows() { # … [--reason ""] [--writer ] # All path cleanliness and ref fences run under the same writer lock as the # scan. A peer can otherwise alter a target log while this act waits. acquire_lock + checkout_sync "retire-rows" "$rr_writer" write "$LANES_PATH" # Amendment 23 S3 if [ "$NO_GIT" != 1 ] && have_remote_ref; then - rr_behind="$(git -C "$LANES_REPO" rev-list --count "HEAD..origin/$LANES_BRANCH" 2>/dev/null || printf '')" + rr_behind="$(git -C "$LANES_REPO" rev-list --count "HEAD..refs/remotes/origin/$LANES_BRANCH" 2>/dev/null || printf '')" case "$rr_behind" in '' | 0) : ;; - *) die "this checkout is $rr_behind commit(s) behind origin/$LANES_BRANCH. The sweep is ONE commit over several rows and several logs, so it is made against the published register or not at all. Pull first — \`git -C $LANES_REPO pull --rebase\` — and re-run. Nothing was written." 2 ;; + *) die "this checkout is $rr_behind commit(s) behind origin/$LANES_BRANCH. The sweep is ONE commit over several rows and several logs, so it is made against the published register or not at all, and origin moved after this write's fast-forward under its lock (Amendment 23 S3): re-run it, and it starts from origin's new tip. Nothing was written." 2 ;; esac fi - refuse_dirty_checkout "retire-rows" "$LANES_PATH" # THE MUTEX COMES BEFORE THE SCAN, not between the scan and the write: this # act reads every row's LINE NUMBER and rewrites those lines by number, and a # peer's `add-row` landing in between moves them under it (the finding # `migrate-state-cells` took in round 2 of openRepoTools#82). It is held from - # `acquire_lock` above; a peer's register edit is captured at the write, after - # every refusal of the scan (#157 item 4b, `migrate_state_cells`' reason). + # `acquire_lock` above; every path this sweep commits is asked again at the + # write, after every refusal of the scan (`migrate_state_cells`' reason). # THE LIVE READ IS FAIL-CLOSED, AND IT IS TAKEN INSIDE THE LOCK (Copilot round # 2 on #93). `lanes_rows` degrades to the log's verb when this workstation's @@ -13369,15 +14130,14 @@ $(session_ids_local_of_lane "$rr_l" 2>/dev/null || :)" # AN UNTRACKED LOG IS SOMEBODY'S UNCOMMITTED WORK, AND THIS ACT WOULD # COMMIT IT — the finding `migrate-state-cells` took in round 2 of # openRepoTools#82, and this sweep has the same shape. A TRACKED log that is - # dirty is already refused for the whole run by `refuse_dirty_checkout` - # above, which exempts the register alone; an untracked file is in neither - # of its lists, so appending here would put a peer's first lines into this + # dirty is refused before the first byte by S3's `cs_own_pending`; an + # untracked file is in no diff at all, so appending here would put a peer's first lines into this # sweep's one commit under this sweep's message. (A dormant row usually has # no log at all — but a migrated one has a log of NOTED lines, and that is # the file this meets.) if [ "$rr_ln2" = 1 ] && [ "$NO_GIT" != 1 ] && ! git -C "$LANES_REPO" ls-files --error-unmatch -- "$(log_path_for "$rr_l")" >/dev/null 2>&1; then - die "lane $rr_l's object log $(log_path_for "$rr_l") exists in this checkout but is NOT TRACKED — somebody's uncommitted work, which this sweep would commit inside its own commit and under its own message. Commit it first (\`git -C $LANES_REPO commit -m \"\" -- $(log_path_for "$rr_l")\`) and re-run. Nothing was written." 2 + die "lane $rr_l's object log $(log_path_for "$rr_l") exists in this checkout but is NOT TRACKED — somebody's uncommitted work, which this sweep would commit inside its own commit and under its own message. $(untracked_recovery "$(log_path_for "$rr_l")"); then re-run. Nothing was written." 2 fi # THE PUBLISHED PATH IS THE ONE THE READABILITY PROBE ALREADY RESOLVED — # `log_path_ci` is a `ls-tree` of the whole log directory and this loop runs @@ -13395,14 +14155,17 @@ $(session_ids_local_of_lane "$rr_l" 2>/dev/null || :)" # commit had had their logs created and appended. It is asked here as # `ensure_log` asks it, in its words, because it is the same refusal; the # refusal of a local file of another case above means `rr_from`, where - # there is one, is the row's own spelling. + # there is one, is the row's own spelling. Under Amendment 23 S3 (#198 slice + # 1b) a rename whose commit is not pushed is refused before this scan, so the + # scan meets this only where origin moves after S3's fast-forward: a reader's + # fetch brings a respelled log into origin/main under this checkout. rr_pub="$rr_pubp" rr_from="" [ "$rr_ln2" = 1 ] && rr_from="$rr_loc" if [ "$rr_pub" != "$(log_path_for "$rr_l")" ] && [ "${rr_from##*/}" != "${rr_pub##*/}" ]; then rr_here="and this checkout does not have it" [ -n "$rr_from" ] && rr_here="while this checkout has ${LANES_LOG_PREFIX}${rr_from##*/} instead — a case-only rename whose commit never landed" - die "lane ${rr_l}'s object log is published as $rr_pub $rr_here: the row spells the lane $rr_l, so the log is $(log_path_for "$rr_l") (Amendment 15), and writing one here now would leave TWO files for one lane. Pull first — \`git -C $LANES_REPO pull --rebase\` — and re-run. Nothing was written." 2 + die "lane ${rr_l}'s object log is published as $rr_pub $rr_here: the row spells the lane $rr_l, so the log is $(log_path_for "$rr_l") (Amendment 15), and writing one here now would leave TWO files for one lane. Re-run the write: it fast-forwards this checkout to origin's tip under its lock first (Amendment 23 S3), and the rename then happens once, inside its own commit. Nothing was written." 2 fi # THE LINE AND THE CELL, BOTH BUILT AND BOTH CHECKED BEFORE ANYTHING IS @@ -13437,12 +14200,12 @@ $(session_ids_local_of_lane "$rr_l" 2>/dev/null || :)" # --------------------------------------------------------------- the write # # THE LOGS FIRST AND THE ROWS SECOND, and all of it published by the single - # `commit_push` at the end: a refusal in the middle leaves this checkout dirty + # `commit_publish` at the end: a refusal in the middle leaves this checkout dirty # and the register unpublished, which `git status` shows and # `git checkout -- lanes` undoes whole. Nothing is half-published, because the - # commit is the only thing that publishes. A peer's register edit is - # committed as its own first, as `migrate_state_cells` says at its write. - handle_preexisting "$LANES_PATH" + # commit is the only thing that publishes. Every path it commits is asked + # again first, as `migrate_state_cells` says at its write (S3 step 4). + cs_own_pending "retire-rows" "$rr_writer" "$LANES_PATH" "${LANES_LOG_PREFIX%/}" rr_paths=("$LANES_PATH") while IFS="$US" read -r rr_ln rr_l rr_new rr_head2 rr_tail; do [ -n "${rr_ln:-}" ] || continue @@ -13469,7 +14232,7 @@ EOF rm -rf -- "$rr_tmp" rr_msg="LANES(retire-dormant@$WS): Amendment 19(c) — $rr_n dormant row(s) RETIRED by lane $rr_writer;$rr_names; reason $rr_reason" - commit_push "$rr_msg" "${rr_paths[@]}" + commit_publish "$rr_msg" "${rr_paths[@]}" rr_crc=$? release_lock # AND EACH SWEPT LANE'S SNAPSHOT FOLLOWS ITS LINE, after the lock, for the @@ -13510,24 +14273,25 @@ archive_rows() { # <1 = the act, 0 = the dry run> if [ "$NO_GIT" != 1 ] && [ "$LOG_SYNC_FETCH" != yes ]; then die "archive-rows requires a successful fresh fetch before moving rows; $LOG_SYNC_FETCH. The local origin/$LANES_BRANCH ref may be stale. Nothing was written." 1 fi + # The cleanliness fence belongs INSIDE the writer lock. Another writer + # could otherwise create or edit the archive while this act waits for it. + # So the lock, then S3 under it, then the behind test, which S3's + # fast-forward leaves nothing to find but a race (Amendment 23 S3). + acquire_lock + checkout_sync "archive-rows" "${LANES_LANE:-}" write "$LANES_PATH" if [ "$NO_GIT" != 1 ] && have_remote_ref; then - ar_behind="$(git -C "$LANES_REPO" rev-list --count "HEAD..origin/$LANES_BRANCH" 2>/dev/null || printf '')" + ar_behind="$(git -C "$LANES_REPO" rev-list --count "HEAD..refs/remotes/origin/$LANES_BRANCH" 2>/dev/null || printf '')" case "$ar_behind" in '' | 0) : ;; - *) die "this checkout is $ar_behind commit(s) behind origin/$LANES_BRANCH. The move is ONE commit carrying the register and the archive, so it is made against the published register or not at all. Pull first — \`git -C $LANES_REPO pull --rebase\` — and re-run. Nothing was written." 2 ;; + *) die "this checkout is $ar_behind commit(s) behind origin/$LANES_BRANCH. The move is ONE commit carrying the register and the archive, so it is made against the published register or not at all, and origin moved after this write's fast-forward under its lock (Amendment 23 S3): re-run it, and it starts from origin's new tip. Nothing was written." 2 ;; esac fi - acquire_lock - # The cleanliness fence belongs INSIDE the writer lock. Another writer - # could otherwise create or edit the archive while this act waits for it. - # THE ARCHIVE IS NOT EXEMPT (Copilot round 4 on #93). It was, because this - # act writes it — but `handle_preexisting` captures the REGISTER alone, so a - # tracked edit somebody else left in the archive rode into this commit under - # this act's message. And it is what makes a half-done move safe: if the - # append lands and `delete_lines` or the commit does not, the archive is - # dirty and the next run REFUSES by name instead of appending the same rows - # a second time. - refuse_dirty_checkout "archive-rows" "$LANES_PATH" + # THE ARCHIVE IS NOT EXEMPT (Copilot round 4 on #93): a tracked edit + # somebody else left in it would ride into this commit under this act's + # message. And it is what makes a half-done move safe: if the append lands + # and `delete_lines` or the commit does not, the archive is dirty and the + # next run REFUSES by name (S3 step 4, `cs_own_pending` at the write) + # instead of appending the same rows a second time. # THE SAME RULE FOR THE ARCHIVE ITSELF: an untracked one is a file this act # did not create, and appending to it would commit somebody else's lines # inside this move (openRepoTools#82, round 2). An archive this act creates @@ -13535,7 +14299,7 @@ archive_rows() { # <1 = the act, 0 = the dry run> # why the test is made HERE — before anything is written. if [ -f "$LANES_ARCH_FILE" ] && [ "$NO_GIT" != 1 ] && ! git -C "$LANES_REPO" ls-files --error-unmatch -- "$LANES_ARCH_PATH" >/dev/null 2>&1; then - die "$LANES_ARCH_PATH exists in this checkout but is NOT TRACKED — somebody's uncommitted work, which this move would commit inside its own commit. Commit it first (\`git -C $LANES_REPO commit -m \"\" -- $LANES_ARCH_PATH\`) and re-run. Nothing was written." 2 + die "$LANES_ARCH_PATH exists in this checkout but is NOT TRACKED — somebody's uncommitted work, which this move would commit inside its own commit. $(untracked_recovery "$LANES_ARCH_PATH"); then re-run. Nothing was written." 2 fi # AN ARCHIVE THAT IS A SYMLINK, OR THERE AND NOT A REGULAR FILE, IS REFUSED # (Copilot on #93's `afd5481`, #138 item 10). The rows are appended with @@ -13654,10 +14418,10 @@ EOF # THE ARCHIVE IS WRITTEN FIRST AND THE ROWS REMOVED SECOND, for the reason # `delete_lines` gives: a refusal between the two leaves a copy in the archive - # and the register whole, and never a row in neither file. A peer's register - # edit is committed as its own first, after every refusal above (#157 item - # 4b, `migrate_state_cells`' reason). - handle_preexisting "$LANES_PATH" + # and the register whole, and never a row in neither file. Every path this + # move commits is asked again first, after every refusal above (S3 step 4, + # `migrate_state_cells`' reason). + cs_own_pending "archive-rows" "${LANES_LANE:-}" "$LANES_PATH" "$LANES_ARCH_PATH" mkdir -p -- "$LANES_DIR/archive" || die "could not create $LANES_DIR/archive — the archive is written before anything is removed, so nothing has been" 6 if [ ! -f "$LANES_ARCH_FILE" ]; then @@ -13679,7 +14443,7 @@ EOF ar_msg="LANES(archive@$WS): Amendment 19(d) — $ar_n RETIRED row(s) of $ar_repo moved to $LANES_ARCH_PATH: $(tr '\n' ' ' < "$ar_tmp/names")" rm -rf -- "$ar_tmp" - commit_push "$ar_msg" "$LANES_PATH" "$LANES_ARCH_PATH" + commit_publish "$ar_msg" "$LANES_PATH" "$LANES_ARCH_PATH" ar_crc=$? release_lock return "$ar_crc" @@ -13881,7 +14645,7 @@ lane_is_managed_owned() { # # AND THIS CHECKOUT'S ROW, NOT ONLY THE PUBLISHED ONE (Copilot round 2 and 3 on # openRepoTools#97). The read above is the published register's (R19), and every # legacy row writer rewrites the row in THIS checkout: one ahead of origin, or -# holding an edit `handle_preexisting` is about to capture, can carry managed-owner +# holding a hand edit `commit` is about to carry, can carry managed-owner # vocabulary the published row does not. Two copies that disagree on ownership # are UNKNOWN, never legacy (Amendment 7(d)). A checkout with no register file # has no row here to rewrite, and the published read has already answered. @@ -16009,7 +16773,11 @@ lane_checkout_ensure() { # *) lce_budget=600; [ "$GIT_TIMEOUT" -le 600 ] || lce_budget="$GIT_TIMEOUT"; lce_wait=$((lce_budget + 60)) ;; esac lce_lock="$lce_base/.$lce_lane.lock" - mutex_try "$lce_lock" "$lce_wait" || + lce_rc=0; mutex_try "$lce_lock" "$lce_wait" || lce_rc=$? + # `-lanes/` ITSELF WENT WHILE THIS WRITE WAITED (360's other lock). + [ "$lce_rc" != 2 ] || + die "$lce_base, where this workstation's lane checkouts live, is gone: it was removed while this write waited to create lane $lce_lane's checkout. Nothing was written; run the write again, which makes it anew (Amendment 23 S1)." 1 + [ "$lce_rc" = 0 ] || die "could not take $lce_lock within ${lce_wait}s: another writer is creating lane $lce_lane's checkout (holder pid $(cat -- "$lce_lock/pid" 2>/dev/null || printf 'unrecorded')). Nothing was written; run the write again." 4 CREATE_LOCK="$lce_lock" # ANOTHER CREATOR MAY HAVE FINISHED while this one waited. @@ -16244,24 +17012,55 @@ ws_pending_refuse() { # # four routed `row_line` callers; `rename-lane` goes through its OLD name, # whose row it needs), a lane with no row in the published register refuses # first, and nothing is cloned. -# * NOT THE OTHER WRITES: `log`, `claim`, `release`, `commit`, `append-line`, -# `request-handoff` and the sweeps take a lane with no row — an archived -# lane keeps its log (Amendment 16(e)) — and `add-row` makes the row. +# * AND THE WRITES THAT NAME A LANE WITH NO ROW (lane openRepoTools-3's +# re-check of #282, R4-1, for slice 1b): `log`, `commit`, `request-handoff` +# and `append-line` take a lane with no row — an archived lane keeps its +# log (Amendment 16(e)) — so for them a row OR the lane's object log on +# origin passes it, and a lane with neither is a typo: `log NOTED +# lane:repoX-99 …` for repoX-9 cloned a checkout for a lane that does not +# exist and left it. So do `claim` and `release`, which take their lane +# from `LANES_LANE` as `log` does, and the three sweeps' runner lane +# (`retire-rows`, and `archive-rows` and `migrate-state-cells` with +# `--yes`; lane openRepoTools-3's review of #307, 285): `LANES_LANE= +# claim …` cloned `-lanes//` and published a CLAIMED for a lane +# nobody started. `add-row`, a new lane's first act, makes the row. # * A MISS IS ASKED AGAIN after one fetch of W (`log_sync`: W is still -# `LANES_REPO` here), so a row, or a rename's new name, that another +# `LANES_REPO` here), so a row, a log, or a rename's new name that another # checkout published and W has not fetched yet is never refused. -# * A READ THAT COULD NOT BE MADE, OR A FETCH THAT DID NOT RUN (failed, -# `LANES_NO_FETCH=1`, `LANES_NO_GIT=1`), IS NO ANSWER (R22): the write goes -# on, and its arm decides, as it always has. +# * A READ THAT COULD NOT BE MADE, OR A FETCH THAT FAILED OR DID NOT RUN +# (`LANES_NO_GIT=1`), IS NO ANSWER (R22): the write goes on, and its arm +# decides, as it always has. `LANES_NO_GIT=1` then meets +# `lane_checkout_ensure`'s own refusal to clone. +# * `LANES_NO_FETCH=1` NEVER REACHES THIS GATE WITHOUT A CHECKOUT: every +# routed writer is refused before it, in `lane_checkout_enter`, wherever +# its lane's checkout does not exist yet (Q4 of #307), so a miss here is +# always one that a fetch, run or failed, has answered. +# `lane_log_exists` is 0 for a log, 2 for two logs whose names differ only by +# case (no answer: the arm refuses that itself), and otherwise whatever its +# test gave — `cat-file -e` of a path origin's tree lacks is 128, not 1. +lrg_known() { # <1 = its published log answers too> — 0 known · 1 not · 2 no answer + lrgk_rows="$(rows_named_ci "$1" 2>/dev/null)" || return 2 + [ -z "$lrgk_rows" ] || return 0 + [ "$2" = 1 ] || return 1 + lrgk_rc=0; lane_log_exists "$1" >/dev/null 2>&1 || lrgk_rc=$? + case "$lrgk_rc" in 0) return 0 ;; 2) return 2 ;; *) return 1 ;; esac +} lane_row_gate() { # - case "$cmd" in set-row-state|replace-in-row|append-session-id|rename-lane) : ;; *) return 0 ;; esac + case "$cmd" in + set-row-state|replace-in-row|append-session-id|rename-lane) lrg_log=0 ;; + log|commit|request-handoff|append-line|claim|release|retire-rows|archive-rows|migrate-state-cells) lrg_log=1 ;; + *) return 0 ;; + esac [ ! -e "$2/.git" ] || return 0 - lrg_rows="$(rows_named_ci "$1" 2>/dev/null)" || return 0 - [ -z "$lrg_rows" ] || return 0 + lrg_rc=0; lrg_known "$1" "$lrg_log" || lrg_rc=$? + [ "$lrg_rc" = 1 ] || return 0 log_sync [ "$LOG_SYNC_FETCH" = yes ] || return 0 - lrg_rows="$(rows_named_ci "$1" 2>/dev/null)" || return 0 - [ -z "$lrg_rows" ] || return 0 + lrg_rc=0; lrg_known "$1" "$lrg_log" || lrg_rc=$? + [ "$lrg_rc" = 1 ] || return 0 + if [ "$lrg_log" = 1 ]; then + die "lane $1 has no row and no object log on origin/$LANES_BRANCH (fetched just now into W), so no lane of that name was ever started or archived, and \`$cmd\` would write for it: its checkout $2/ was not cloned (Amendment 23 S1, S2). Nothing was written. Check the name — \`$SELF lanes\` lists every lane, and \`$SELF lanes --closed\` the closed ones — or start the lane first (lane-start; its \`add-row\` makes the row)." 2 + fi die "lane $1 has no row in the register on origin/$LANES_BRANCH (fetched just now into W), and \`$cmd\` edits that lane's row: there is nothing for it to edit, so its checkout $2/ was not cloned (Amendment 23 S1, S2). Nothing was written. Check the name — \`$SELF lanes\` lists every lane — or add the row first (\`add-row\`)." 2 } @@ -16319,6 +17118,17 @@ lane_checkout_enter() { # lcn_p="$(lane_checkout_path "$lcn_c")" || die "$(lanes_workspace_why)" 1 lane_checkout_inside "$lcn_p" ws_pending_refuse "$lcn_c" "$lcn_p" + # NO FETCH, NO CLONE (Q4 of lane openRepoTools-3's review of #307). For a + # writer a fetch that did not run is a fetch that failed, so S3 step 1 + # refuses every write under `LANES_NO_FETCH=1` in the lane's checkout + # (ratified choice 2). Where that checkout does not exist yet, cloning it + # first is pure cost — the network, and about 0.4 GB of working tree on a + # nearly full volume — for a write that is then refused anyway. So every + # routed writer refuses here, before any clone, whatever the gate below + # would say; an existing checkout goes on, and S3 refuses there at no cost. + if [ "${LANES_NO_FETCH:-0}" = 1 ] && [ "$NO_GIT" != 1 ] && [ ! -e "$lcn_p/.git" ]; then + die "LANES_NO_FETCH=1 is set, and for a writer a fetch that did not run is a fetch that failed (Amendment 23 S3 step 1, ratified choice 2): \`$cmd\` would refuse in lane $lcn_c's checkout for it, so that checkout, $lcn_p/, was not cloned. Nothing was written. Run this write without LANES_NO_FETCH (reads may keep it)." 2 + fi lane_row_gate "$lcn_c" "$lcn_p" lane_checkout_ensure "$lcn_c" if [ -n "$LANES_DIR_GIVEN" ]; then LANES_DIR="$LANES_DIR_GIVEN" @@ -16505,6 +17315,19 @@ cmd="${1-}" # stale the moment the header grew — as it did under Amendment 5.) [ -n "$cmd" ] || { sed -n -e '1,2d' -e '/^#/!q' -e 's/^# \{0,1\}//' -e p "$RESOLVED"; exit 2; } shift || : +# AMENDMENT 23 S3 STEP 4 WITHDRAWS `--sweep` (ratified choice 9). It left a +# peer's uncommitted register edit staged so that it landed inside this +# invocation's own commit; a pending hand edit of the register is now +# committed by its own act and refuses every other write, so there is nothing +# for the flag to do but break that rule. Refused before anything is read, for +# every verb but the two hooks, which must answer as hooks. `--no-sweep`, the +# old default, stays accepted and changes nothing. +if [ "$SWEEP_MODE" = sweep ]; then + case "$cmd" in + session-start|guard) : ;; + *) die "--sweep is withdrawn (Amendment 23 S3 step 4, ratified choice 9): a pending hand edit of the register is committed by its own act and never swept into another write. Commit it first — LANES_LANE= $SELF commit \"\" — then run \`$cmd\` without --sweep. Nothing was written." 2 ;; + esac +fi # `session-start` is EXEMPT. It is a hook: it never writes, and it must exit 0 # even where there is no register to read at all — a hook that dies is a hook # that breaks the session it was meant to orient. @@ -16654,7 +17477,8 @@ Nothing was written." 2 # 2026-10-04), and replacing it would take the lane out of the ledger's # hands without the ledger: refused before the lock, nothing written. managed_seam_refuse "$lane" "set-row-state" - acquire_lock; handle_preexisting + acquire_lock; checkout_sync set-row-state "$lane" write "$LANES_PATH" # Amendment 23 S3 + managed_seam_refuse "$lane" "set-row-state" # 283: again, on what S3 brought in (write_event says why) n="$(row_line "$lane")" || exit 2 row="$(sed -n -e "${n}p" "$LANES_FILE")" srs_rc=0; row_split_state_cell "$row" || srs_rc=$? @@ -16680,8 +17504,7 @@ Nothing was written." 2 # there: the line is capped at 240 characters by the act above, and a # subject cut at 72 loses exactly the tail that says what happened. msg="LANES($lane@$WS): state · $ROW_STATE · $ROW_STATE_LINE" - [ -n "$PRE_DIRTY_LANES" ] && msg="$msg + sweeps uncommitted edit to row $PRE_DIRTY_LANES" - commit_push "$msg" + commit_publish "$msg" ;; replace-in-row) @@ -16694,7 +17517,8 @@ Nothing was written." 2 if managed_projection_hint "$new"; then die "the replacement text carries managed-owner vocabulary ('managed owner', 'managed binding', 'mode=managed' or 'managed:'), and only the managed ledger's own writer writes a managed-owner marker, and a legacy writer that wrote its vocabulary into a row would forge that lane's ownership (ruling 2026-10-04: \"managed ledger owns enrolled lanes; #97 owns legacy\"). Nothing was written." 2 fi - acquire_lock; handle_preexisting + acquire_lock; checkout_sync replace-in-row "$lane" write "$LANES_PATH" # Amendment 23 S3 + managed_seam_refuse "$lane" "replace-in-row" # 283: again, on what S3 brought in n="$(row_line "$lane")" || exit 2 row="$(sed -n -e "${n}p" "$LANES_FILE")" c="$(count_occurrences "$row" "$old")" || exit 2 @@ -16714,8 +17538,7 @@ Nothing was written." 2 ri_pre="${row%%"$old"*}"; ri_post="${row#*"$old"}" replace_line "$n" "$ri_pre$new$ri_post" msg="LANES($lane@$WS): ${why:-replace-in-row}" - [ -n "$PRE_DIRTY_LANES" ] && msg="$msg + sweeps uncommitted edit to row $PRE_DIRTY_LANES" - commit_push "$msg" + commit_publish "$msg" ;; # AMENDMENT 8, R-A8-4 — THE UUID APPEND IS CELL-SCOPED. @@ -16759,7 +17582,8 @@ Nothing was written." 2 fi lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 managed_seam_refuse "$lane" "append-session-id" # Copilot round 3 on #97 - acquire_lock; handle_preexisting + acquire_lock; checkout_sync append-session-id "$lane" write "$LANES_PATH" # Amendment 23 S3 + managed_seam_refuse "$lane" "append-session-id" # 283: again, on what S3 brought in n="$(row_line "$lane")" || exit 2 row="$(sed -n -e "${n}p" "$LANES_FILE")" row_split_session_cell "$row" || die "lane $lane's row (line $n) does not have three '|' before its session cell ends; refusing to touch it" 2 @@ -16770,17 +17594,16 @@ Nothing was written." 2 fi replace_line "$n" "$RSC_HEAD$(rstrip_spaces "$RSC_CELL") $add $RSC_TAIL" msg="LANES($lane@$WS): ${why:-append-session-id}" - [ -n "$PRE_DIRTY_LANES" ] && msg="$msg + sweeps uncommitted edit to row $PRE_DIRTY_LANES" - commit_push "$msg" + commit_publish "$msg" ;; append-line) text="${1-}"; [ -n "$text" ] || die "usage: append-line \"\"" 2 # ONE LINE, REFUSED A NEWLINE BEFORE THE LOCK (opensoft/openRepoTools#85). # `append_text_line` refuses it too, and before it writes — but that is - # after `acquire_lock` and `handle_preexisting`, which can COMMIT a peer's - # uncommitted register edit before this act's own write. A refusal that has - # already committed something is a refusal that wrote, so the verb asks + # after `acquire_lock` and S3, which fetches and fast-forwards this + # checkout before this act's own write. A refusal should leave the checkout + # as this command found it, so the verb asks # first, exactly where `write_event` asks for `log`: the checkout is left # as this command found it. `lane-handoff`'s file-level PAUSED line is the # caller that met it, with a `why` carrying a newline. @@ -16829,11 +17652,10 @@ Nothing was written." 2 # of #282, SHOULD 1): the router read it from the published register, and # this checkout's copy may predate the row. [ -n "$lane_tag" ] || lane_tag="${LANES_ROUTED:-}" - acquire_lock; handle_preexisting + acquire_lock; checkout_sync append-line "$lane_tag" write "$LANES_PATH" # Amendment 23 S3 append_text_line "$text" msg="LANES(${lane_tag:-unknown}@$WS): append line — ${text:0:72}" - [ -n "$PRE_DIRTY_LANES" ] && msg="$msg + sweeps uncommitted edit to row $PRE_DIRTY_LANES" - commit_push "$msg" + commit_publish "$msg" ;; add-row) @@ -16863,7 +17685,7 @@ Nothing was written." 2 # (Copilot round 4 on openRepoTools#41). The scan was of `$LANES_FILE` alone, # which a fetch does not move: on a checkout that is behind, a row `origin` # already carries as `repoCase-1` is invisible here, this appends - # `repocase-1`, and `commit_push`'s own rebase then publishes the pair — the + # `repocase-1`, and the push that follows publishes the pair — the # exact duplicate 15(a) makes this command refuse, arrived at through the one # file the check never read. BOTH sets are asked and either is the refusal: # the local scan stays because a row added here and not yet pushed is on no @@ -16920,21 +17742,22 @@ Nothing was written." 2 ar_more="" [ "$ar_n" -gt 1 ] && ar_more=" Those $ar_n rows differ only by case and are themselves the refusal: merge them into one row first (Amendment 15(d))." ar_where="Use set-row-state / replace-in-row on the row that is there." - [ "$ar_ln" = 0 ] && ar_where="That row is on origin/$LANES_BRANCH and this checkout has not pulled it, so adding one here would make two the moment this push rebases. Pull first — \`git -C $LANES_REPO pull --rebase\` — then use set-row-state / replace-in-row on the row that is there." + [ "$ar_ln" = 0 ] && ar_where="That row is on origin/$LANES_BRANCH and not yet in this checkout, so adding one here would make two the moment this push lands. Use set-row-state / replace-in-row on the row that is there: each fast-forwards this checkout to it first (Amendment 23 S3)." die "lane '$lane_new' already has a row, spelled $(printf '%s\n' "$ar_all" | tr '\n' ' ')— a lane name is ONE name under any case (Amendment 15(a)), so a row under another case IS that lane's row and this would be a second one. $ar_where$ar_more" 2 fi fi - acquire_lock; handle_preexisting + # A PEER'S RETIREMENT OR ROW LANDING FIRST IS MET BY A RE-RUN ON ORIGIN'S + # NEW TIP, whichever side of S3's fetch it lands on: before it, S3 finds + # the tip moved and runs the verb again (`CS_RECHECK`); after it, the push + # is refused and S4 replays. Either way the retirement and duplicate checks + # above refuse it with nothing written — where the old rescan after a + # `pull --rebase` left the add-row and its cancelling commit both local. + CS_RECHECK=1 + acquire_lock; checkout_sync add-row "$lane_new" write "$LANES_PATH" # Amendment 23 S3 append_text_line "$row" msg="LANES(${lane_new:-${LANES_LANE:-unknown}}@$WS): add row" - [ -n "$PRE_DIRTY_LANES" ] && msg="$msg + sweeps uncommitted edit to row $PRE_DIRTY_LANES" - ADD_ROW_LANE="$lane_new" - ADD_ROW_TEXT="$row" - CP_AFTER_REBASE=add_row_retirement_rescan - commit_push "$msg" - ar_push_rc=$? - CP_AFTER_REBASE="" - exit "$ar_push_rc" + commit_publish "$msg" + exit $? ;; commit) @@ -16944,6 +17767,19 @@ Nothing was written." 2 # the name the "touches other rows" test compares against. [ -z "${LANES_LANE:-}" ] || LANES_LANE="$(canon_lane "$LANES_LANE")" || exit 2 acquire_lock + # AMENDMENT 23 S3 — TWO FORMS, CHOSEN BY THE REGISTER. A register that + # differs from HEAD, staged or not, is the hand edit this act exists to + # commit: S3 runs with the CARRY (the edit set aside, the fast-forward, the + # edit applied again) and every row applies, unpushed commits included. + # A clean register is THE PUBLISH ACT (#194's remedy 1): it writes nothing + # new, so S3 runs with every row but the unpushed one and no fast-forward, + # and `commit_publish` publishes the queue (S4, "the publish act"). + if [ "$NO_GIT" != 1 ] && [ -n "$(cs_pending_of "$LANES_PATH")" ]; then + checkout_sync commit "${LANES_LANE:-}" carry "$LANES_PATH" + CP_REPLAY_KEEPS_EDIT=1 + else + checkout_sync commit "${LANES_LANE:-}" publish + fi # `commit` has no edit of its own to separate from a pre-existing one — # its whole job is to wrap whatever is already dirty (a hand edit made # with an allowed tool). The best it can do is warn when that dirty @@ -16964,9 +17800,9 @@ Nothing was written." 2 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. + # commits ahead of origin: it is the RECOVERY's re-run. See cp_publish_act. CP_PUBLISH_AHEAD=1 - commit_push "LANES(${LANES_LANE:-unknown}@$WS): $msg" + commit_publish "LANES(${LANES_LANE:-unknown}@$WS): $msg" ;; # AMENDMENT 19(c) — THE SWEEP'S WRITE. `lane-end --retire-dormant ` is @@ -17020,7 +17856,7 @@ Nothing was written." 2 # # AND THE `git mv` OF CLAUSES (c) AND (d) IS A PLAIN `mv` PLUS A PATHSPEC. # `git mv` is one command and it also STAGES, which is the one thing this file - # never does: `commit_push` commits from its PATHSPECS precisely so that a + # never does: `commit_publish` commits from its PATHSPECS precisely so that a # peer's uncommitted work in this shared checkout can never be swept into a # lane's commit (Amendment 5(d), and the sweep that made `--no-sweep` the # default). Both paths go in as pathspecs of the one commit and git records @@ -17108,10 +17944,14 @@ Nothing was written." 2 # releases it on every path out, the two signals included. # # ACROSS WORKSTATIONS the arbiter is the same one Rule 1's claim has: - # whichever commit lands on `main` first. `commit_push`'s rebase brings the - # other's row and log in, and the loser's next attempt meets its own - # refusals — a row under ``, a published log — with nothing written. + # whichever commit lands on `main` first. The loser's push is refused, S4 + # drops its commit and runs it again on origin's new tip, and there it meets + # its own refusals — a row under ``, a published log — with nothing + # written. S3 runs under the lock first, so every read below is of a + # checkout at origin's tip (Amendment 23 S3). acquire_lock + checkout_sync "rename lane $rl_old" "$rl_old" write "$LANES_PATH" + managed_seam_refuse "$rl_old" "rename-lane" # 283: again, on what S3 brought in # Refuse a spelling-only rename before probing both roots: on a # case-insensitive filesystem they are the same intent, whose stored lane @@ -17146,7 +17986,7 @@ Nothing was written." 2 fi if [ "$rl_ln" = 0 ]; then if [ "$rl_on" = 1 ]; then - die "lane $rl_old's row is on origin/$LANES_BRANCH and this checkout has not pulled it, so the row this rename must rewrite is not here — and a rename that appended a second row would be the very duplicate Amendment 15(a) refuses. Pull first — \`git -C $LANES_REPO pull --rebase\` — and re-run. Nothing was written." 2 + die "lane $rl_old's row is on origin/$LANES_BRANCH and not in this checkout, so the row this rename must rewrite is not here — and a rename that appended a second row would be the very duplicate Amendment 15(a) refuses. Origin moved after this write's fast-forward under its lock (Amendment 23 S3): re-run it, and it starts from origin's new tip. Nothing was written." 2 fi die "the register has no row for lane '$rl_old', in any case (Amendment 15(a)) and through no alias (Amendment 16(e)). A rename moves a row that exists; \`lanes --all\` lists the ones that do. Nothing was written." 2 fi @@ -17279,8 +18119,8 @@ EOF die "lane $rl_old's object log at $rl_log_old is not a regular file. An append-only log is read whole for the rollback copy and appended to in place, and neither can be done to that — a FIFO there would block this write, and its lock, indefinitely. Nothing was written." 2 fi # AND GIT MUST ALREADY KNOW IT (Copilot round 6 on openRepoTools#81). - # `refuse_dirty_checkout` reads `tracked_dirty`, which reports no untracked - # file, so a peer's uncommitted `lanes/log/.md` walked past it — and + # The old guard read tracked files alone, and S3's own-path test reads a + # diff, which holds no untracked file either: a peer's uncommitted `lanes/log/.md` walked past it — and # this rename would MOVE that file to a path it always stages, committing # somebody else's lines under its own message. Round 3 kept the old path # out of the pathspecs when it was untracked, which stopped `git add` @@ -17293,7 +18133,7 @@ EOF if have_remote_ref; then rl_pub="$(log_path_ci "$rl_old")" || die "lane $rl_old's object log is published twice (above), and one lane is ONE lane under any case: its log is ONE file (Amendment 15). Merge them by hand (15(d)) and re-run. Nothing was written." 2 if [ "$rl_pub" != "$rl_log_old_rel" ] && git -C "$LANES_REPO" cat-file -e "origin/$LANES_BRANCH:$rl_pub" 2>/dev/null; then - die "lane $rl_old's object log is published as $rl_pub while this checkout has ${rl_log_old_rel:-no log for it at all}: a rename made from here would move one file and leave the other, which is two logs for one lane by the path \`ensure_log\` exists to close. Pull first — \`git -C $LANES_REPO pull --rebase\` — and re-run. Nothing was written." 2 + die "lane $rl_old's object log is published as $rl_pub while this checkout has ${rl_log_old_rel:-no log for it at all}: a rename made from here would move one file and leave the other, which is two logs for one lane by the path \`ensure_log\` exists to close. Origin moved after this write's fast-forward under its lock (Amendment 23 S3): re-run it, and it starts from origin's new tip. Nothing was written." 2 fi fi @@ -17316,8 +18156,8 @@ EOF die "$LANES_ALIASES_PATH at $LANES_ALIASES_TSV is not a regular file. The alias row is appended in place and the table is read whole for the rollback copy — a FIFO there would block this write, and its lock, indefinitely. Nothing was written." 2 fi # AN EXISTING UNTRACKED TABLE IS SOMEBODY'S HAND EDIT (Copilot round 5 on - # openRepoTools#81). `refuse_dirty_checkout` reads `tracked_dirty`, which - # deliberately omits untracked files — so before this estate's FIRST rename + # openRepoTools#81). The old guard and S3's own-path test both read diffs, + # which omit untracked files — so before this estate's FIRST rename # an untracked `lanes/aliases.tsv` walked past the guard and its contents # were appended to and committed inside this lane's rename. The only writer # of that file is this command, and this command commits what it writes, so @@ -17340,12 +18180,11 @@ EOF fi if have_remote_ref && [ ! -f "$LANES_ALIASES_TSV" ] \ && git -C "$LANES_REPO" cat-file -e "origin/$LANES_BRANCH:$LANES_ALIASES_PATH" 2>/dev/null; then - die "$LANES_ALIASES_PATH is published on origin/$LANES_BRANCH and this checkout does not have it, so appending here would write a table holding only this one rename and drop every alias already published — every name renamed before today would stop resolving (Amendment 16(e)). Pull first — \`git -C $LANES_REPO pull --rebase\` — and re-run. Nothing was written." 2 + die "$LANES_ALIASES_PATH is published on origin/$LANES_BRANCH and this checkout does not have it, so appending here would write a table holding only this one rename and drop every alias already published — every name renamed before today would stop resolving (Amendment 16(e)). S3 brought this checkout to origin's tip under this write's lock, so the table was deleted here, or origin moved since: restore it — \`git -C $LANES_REPO checkout -- $LANES_ALIASES_PATH\` — or re-run. Nothing was written." 2 fi - # THE ROW, AND ITS TWO CELLS. Read before the lock because nothing between - # here and the write changes a byte of it — `capture_register_edit` and - # `handle_preexisting` commit what is already there, they do not edit it. + # THE ROW, AND ITS TWO CELLS. Nothing between here and the write changes a + # byte of it: S3 has run, and the own-path test below only reads. rl_n="$(row_line "$rl_old")" || exit 2 rl_row="$(sed -n -e "${rl_n}p" "$LANES_FILE")" case "$rl_row" in *"|"*) : ;; *) die "lane $rl_old's row (line $rl_n) has no '|' at all; refusing to touch it. Nothing was written." 2 ;; esac @@ -17513,10 +18352,10 @@ EOF rl_h_touch=0 [ -n "$rl_h_rel" ] && [ -f "$rl_hfile" ] && rl_h_touch=1 # AND AN UNTRACKED HANDOFF IS SOMEBODY'S UNCOMMITTED WORK (Copilot round 6). - # It is a pathspec of this commit the moment it exists, and `tracked_dirty` - # never reports it, so another lane's half-written handoff would be stamped - # and committed under this rename's message — which is the one thing - # `--no-sweep` was made the default for. + # It is a pathspec of this commit the moment it exists, and no diff — the + # old guard's or S3's own-path test — reports it, so another lane's + # half-written handoff would be stamped and committed under this rename's + # message, the thing the old `--no-sweep` default was made to stop. if [ "$rl_h_touch" = 1 ] && [ "$NO_GIT" != 1 ] \ && ! git -C "$LANES_REPO" ls-files --error-unmatch -- "$rl_h_rel" >/dev/null 2>&1; then die "the handoff at $rl_h_rel exists here and git does not track it, so its lines have never been committed by anyone — and this rename would stamp it, move it and commit it under its own message. Whoever wrote it commits it first: \`git -C $LANES_REPO add -- $rl_h_rel && git -C $LANES_REPO commit -m \"handoff(@): \"\`. Nothing was written." 2 @@ -17530,7 +18369,7 @@ EOF # in the index and `git add` stages its deletion; an UNTRACKED one — the log # an earlier write created and never committed — matches nothing once it is # gone, and `git add` FAILS on a pathspec that matches nothing at all, - # which would end this write inside `commit_push` with all four moves on + # which would end this write inside `commit_publish` with all four moves on # disk. Nothing in the index refers to it, so there is nothing to stage. if [ -n "$rl_log_old_rel" ] && [ "$rl_log_old_rel" != "$rl_log_new_rel" ] \ && { [ "$NO_GIT" = 1 ] || git -C "$LANES_REPO" ls-files --error-unmatch -- "$rl_log_old_rel" >/dev/null 2>&1; }; then @@ -17540,29 +18379,13 @@ EOF rl_paths+=("$rl_h_rel_new") [ "$rl_hmove" = 1 ] && rl_paths+=("$rl_h_rel") fi - # THE ALIAS TABLE IS NOT EXEMPT FROM THE DIRTY-CHECKOUT REFUSAL (Copilot - # round 4 on openRepoTools#81). Every pathspec handed here is exempted, and - # `lanes/aliases.tsv` has ONE writer — this command — so a dirty one is not - # the ordinary mid-write state `lanes/LANES.md` is in half the day and there - # is no capture for it: exempted and uncaptured, a peer's half-finished - # rename would be appended to and swept into this lane's commit. So it is - # left OUT of the exemption list and the generic refusal names it. - rl_exempt=() - for rl_p in ${rl_paths[@]+"${rl_paths[@]}"}; do - [ "$rl_p" = "$LANES_ALIASES_PATH" ] && continue - rl_exempt+=("$rl_p") - done - refuse_dirty_checkout "rename lane $rl_old" ${rl_exempt[@]+"${rl_exempt[@]}"} "$LANES_PATH" - # THE CAPTURE IS GIVEN THE REGISTER AND NOTHING ELSE (Copilot round 3 on - # openRepoTools#81). `handle_preexisting` runs `git add -- ` on the - # branch that captures a peer's uncommitted edit, and most of this write's - # pathspecs DO NOT EXIST YET — the new log, the alias table, the handoff's - # new name — so that `git add` would fail, the capture would not happen, and - # a peer's half-written row would be swept into this rename's commit. The - # register is the only file that can be dirty here at all: every OTHER - # tracked change was refused one line up, which is the same reasoning - # `capture_register_edit` states for its own single pathspec. - handle_preexisting "$LANES_PATH" + # EVERY PATH THIS RENAME COMMITS, ASKED BEFORE THE FIRST BYTE (Amendment 23 + # S3 step 4). A pending change to the register, either log, the alias + # table or the handoff refuses with nothing written: the rename is one + # commit by those pathspecs, and it would carry the change under its own + # subject. The alias table was the one path the old guard could not exempt + # (Copilot round 4 on openRepoTools#81); now no path of the write is. + cs_own_pending "rename lane $rl_old" "$rl_old" ${rl_paths[@]+"${rl_paths[@]}"} # ---- THE SNAPSHOT, BEFORE THE FIRST BYTE. Every exit path from here to the # commit restores it (`rename_undo`, hooked into `cleanup`), because the @@ -17578,7 +18401,7 @@ EOF # AND THE REGISTER IS IN THE SNAPSHOT TOO (Copilot round 4 on # openRepoTools#81). `replace_line` cannot half-write it — it proves the new # file before it writes a byte — but the row IS written before - # `commit_push`, and `commit_push` can die before it commits anything. The + # `commit_publish`, and `commit_publish` can die before it commits anything. The # undo then put the other three back and left the row renamed: a split # rename, which is the state this whole mechanism exists to prevent. RL_REG="$LANES_FILE" @@ -17704,7 +18527,7 @@ EOF # EVERY ONE OF THE FOUR IS ON DISK — AND THE UNDO STAYS ARMED THROUGH THE # COMMIT (Copilot round 3 on openRepoTools#81). It was cleared here, and - # `commit_push` can die on `update-index`, `git add` or `git commit` BEFORE + # `commit_publish` can die on `update-index`, `git add` or `git commit` BEFORE # any commit exists: the four files would then be left moved and modified # with nothing to show for them, which is the half-renamed state this whole # mechanism exists to prevent. @@ -17712,15 +18535,14 @@ EOF # WHAT DECIDES IS WHETHER A COMMIT WAS MADE, not where the failure happened. # `rename_undo` compares HEAD against the sha taken here: unchanged means no # commit carries this work and the four are restored; moved means the commit - # exists, the work is IN it, and `commit_push` owns the recovery from there + # exists, the work is IN it, and `commit_publish` owns the recovery from there # — its exit 3 says so in its own words, and undoing a committed rename # behind its back would be a second, inverse change nobody asked for. RL_HEAD_BEFORE="$(git -C "$LANES_REPO" rev-parse HEAD 2>/dev/null || printf '')" rl_msg="LANES($rl_new@$WS): RENAMED lane $rl_old → $rl_new (Amendment 16) — the row, $rl_log_new_rel, the handoff and $LANES_ALIASES_PATH in one commit" - [ -n "$PRE_DIRTY_LANES" ] && rl_msg="$rl_msg + sweeps uncommitted edit to row $PRE_DIRTY_LANES" - commit_push "$rl_msg" "${rl_paths[@]}" + commit_publish "$rl_msg" "${rl_paths[@]}" rl_rc=$? - # `commit_push` RETURNED, so either it committed or it had nothing staged; + # `commit_publish` RETURNED, so either it committed or it had nothing staged; # every other outcome exits from inside it and is the trap's business. RL_ACTIVE=0 state_events_flush @@ -17840,8 +18662,8 @@ EOF esac # R30 — THE FETCH COMES FIRST. `log` had no `log_sync` of its own at all: # it read the STARTED line that REFUSES `--home` out of an `origin/` - # nothing here had moved, and the pull inside `commit_push` comes far too - # late to be that read. + # nothing here had moved, and S3's fetch under the lock comes far too late + # to be that read. log_sync # AMENDMENT 15 — AFTER THE FETCH AND BEFORE EVERY READ AND WRITE BELOW IT. # The resolver reads the PUBLISHED register (R19), so it is asked once @@ -17938,21 +18760,18 @@ EOF done [ -n "$obj_raw" ] || die "usage: claim [--force] [--no-github] [--home owner/repo]" 2 - # THE RACE IS DECIDED BY WHICH CLAIM LANDS ON main FIRST, and a rebase is - # what makes that decidable. Amendment 5(d) SKIPS the rebase when a peer - # has left uncommitted changes to other files in this shared checkout — a - # sensible default for a row edit, and unacceptable for a claim, which - # would then race unprotected. Refuse, and name the files — BEFORE the - # reads and before anything is written, and against exactly the pathspec - # this claim will write (the lane's log; a CLAIMED is not a LANDING). - # The register is exempt HERE and only here: write_event captures a peer's - # uncommitted `lanes/LANES.md` as its own commit and re-tests the checkout - # afterwards (R11), so a claim is not refused for the one file this - # checkout is dirty in most of the day. - # AND THE EXEMPTION IS A PATH, SO ITS AMBIGUITY IS THIS CLAIM'S (Copilot - # round 4): two logs differing only by case are a refusal, not a pathspec. + # THE RACE IS DECIDED BY WHICH CLAIM LANDS ON main FIRST, and Amendment 23 + # S4 is what makes that decidable: a claim whose push is refused runs again + # on origin's new tip, where the rescan below finds the claim that landed + # first. TWO LOGS DIFFERING ONLY BY CASE are still refused before anything + # is read (Copilot round 4): which one this claim writes would be a guess. claim_log="$(log_path_ci "$lane")" || exit 2 - refuse_dirty_checkout claim "$claim_log" "$LANES_PATH" + # AMENDMENT 23 S3 NARROWS THE GUARD THAT WAS HERE (ratified choice 8): + # an unstaged change to another tracked file refused every claim, because + # it blocked the `pull --rebase` that decided the race. No writer pulls + # now; the race is decided by push order and the replay, and `write_event` + # takes S3 under the lock, so this claim refuses only where the + # fast-forward itself would overwrite such a change. # 1. FETCH FIRST OF ALL — then resolve `--home` against the lane's STARTED # line (R30), canonicalise the object, and pre-check against what has @@ -17964,6 +18783,22 @@ EOF home="$(resolve_home "$lane" "$home_override")" || exit $? obj="$(canon_object "$obj_raw" "$home")" || exit 2 ! is_lane_object "$obj" || die "a lane is not a claimable object" 2 + # AMENDMENT 23 S4 — ON A REPLAY, AMENDMENT 7(f)'s RESCAN COMES FIRST. This + # claim's push was refused because origin moved, its commit was dropped, and + # the verb runs again from the top on origin's new tip. A rival's CLAIMED or + # TAKEOVER now on main landed first, so this one is CLAIM-LOST (7) — or the + # lane a takeover was granted over is alive again (9) — and the CLAIM-LOST + # line is the only line of this claim's that origin ever gets. The takeover + # attempt 1 decided on crossed the `exec` with it. + if [ "$CP_REPLAYING" = 1 ]; then + CLAIM_OBJ="$obj"; CLAIM_LANE="$lane"; CLAIM_SKIP="$CP_CLAIM_FROM"; CLAIM_SKIP_DEAD="$CP_CLAIM_DEAD" + CLAIM_ABANDON_REVIVED="" + CP_REPLAY_ENV=("LANES__CLAIM_FROM=$CP_CLAIM_FROM" "LANES__CLAIM_DEAD=$CP_CLAIM_DEAD" + "LANES__CLAIM_READS=$CP_CLAIM_READS" "LANES__CLAIM_URL=$CP_CLAIM_URL" + "LANES__CLAIM_READS_FILE=$CP_CLAIM_READS_FILE") + crh_rc=0; claim_rescan_hook || crh_rc=$? + case "$crh_rc" in 7|9) claim_lost_write "$crh_rc" "$lane" "$obj" "$(session_for "$lane")" ;; esac + fi takeover_payload=""; takeover_note=""; takeover_from=""; takeover_reason_phrase=""; takeover_dead="" # CASE-INSENSITIVE, FOR THE SAME REASON THE KEY ABOVE IS LOWER-CASED # (Amendment 15). This is the lane REMOVING ITSELF from the holders of the @@ -18076,27 +18911,79 @@ EOF # fallback, exactly as `--no-github` already does. if [ "$NO_GITHUB" = 1 ] || [ "$h_verb" != CLAIMED ]; then takeover_payload="lane:$h_lane" + elif [ "$CP_REPLAYING" = 1 ]; then + # A RE-RUN READS NOTHING FROM GITHUB WITH THE LOCK HELD (284): the + # URL attempt 1 read crossed the `exec` with the takeover's source, + # and it is cited while the source is the same lane. + takeover_payload="lane:$h_lane" + [ "$h_lane" != "$CP_CLAIM_FROM" ] || [ -z "$CP_CLAIM_URL" ] || takeover_payload="$CP_CLAIM_URL" else takeover_payload="$(gh_stale_claim_url "$obj" "$h_lane")" [ -n "$takeover_payload" ] || takeover_payload="lane:$h_lane" fi fi - # 2. Rule 1's three reads, printed. - reads="$(gh_reads "$obj")" || exit 1 + # 2. Rule 1's three reads, printed. ON A RE-RUN, THE ONES ATTEMPT 1 MADE + # (lane openRepoTools-3's review of #307, 284). A re-run — the replay, + # or `cs_recheck`'s — holds the checkout's lock from the top, and these + # are `gh` and an ssh `ls-remote`, none bounded by `git_net`: a stalled + # one made every lane's writer behind W's lock exit 4, and past ten + # minutes `mutex_stale` calls the live holder stale. They inform, and + # the rescan above decides the race, so attempt 1's cross the `exec` + # and are printed, and cited in the comment, as they were made. + if [ "$CP_REPLAYING" = 1 ] && [ "$NO_GITHUB" != 1 ]; then + note "Rule 1's three reads were made at attempt 1, before the lock, and are printed as they were: this re-run holds the lock and makes no GitHub read with it held (Amendment 23 S4; the rescan above decides the race)." + reads="$CP_CLAIM_READS" + [ -n "$reads" ] || reads="the three reads of Rule 1: made and printed at attempt 1, before the lock" + reads_carry="$reads" + # THE COMMENT CITES THEM WHOLE (lane openRepoTools-3's re-check of #307's + # round 2, 706): where the carried copy is capped (334, below), attempt 1 + # kept them whole in a file beside the checkout's other carried state, and + # the comment's "three reads" block is taken from it. + reads_posted="$reads" + if [ -n "$CP_CLAIM_READS_FILE" ] && [ -f "$CP_CLAIM_READS_FILE" ]; then + reads_posted="$(cat -- "$CP_CLAIM_READS_FILE")" + fi + else + reads="$(gh_reads "$obj")" || exit 1 + reads_posted="$reads" + # THE COPY THAT CROSSES THE `exec` IS CAPPED (lane openRepoTools-3's + # re-check of #307, 334). It travels as ONE environment string, and + # Linux caps one at 128 KiB: past that the `exec` fails and the write + # exits 126, outside the exit table. Real reads are a few hundred bytes; + # an object with thousands of comments is not. Attempt 1 prints them in + # full below; a re-run prints this copy, cut at a line and marked. + reads_carry="$reads" + if [ "$(printf '%s' "$reads" | LC_ALL=C wc -c | tr -d ' ')" -gt 32768 ]; then + reads_carry="$(printf '%s' "$reads" | LC_ALL=C head -c 32768 | LC_ALL=C sed '$d')"$'\n'" (truncated at 32768 bytes to cross the re-run's exec: attempt 1 printed all of Rule 1's reads)" + # …and whole in a file, which a re-run's comment cites (706). Its name + # crosses the `exec`; the file goes when the claim's last run ends. + cr_gd="$(git -C "$LANES_REPO" rev-parse --absolute-git-dir 2>/dev/null || :)" + if [ -n "$cr_gd" ] && mkdir -p -- "$cr_gd/lanes-edit" 2>/dev/null; then + CP_CLAIM_READS_FILE="$cr_gd/lanes-edit/claim-reads-$(date -u +%Y%m%dT%H%M%SZ)-$$.txt" + printf '%s\n' "$reads" > "$CP_CLAIM_READS_FILE" 2>/dev/null || CP_CLAIM_READS_FILE="" + fi + fi + fi printf '%s\n' "$reads" # 3. decision 2 — a crossing warns and proceeds. cross_repo_warn "$(object_repo "$obj")" "$home" "$lane" - # 4. append, commit, push. CP_AFTER_REBASE runs on every rebase inside the - # push loop: if another lane's claim is on the rebased history, theirs - # landed first and this one has lost. + # 4. append, commit, push. A refused push replays this whole verb on + # origin's new tip (Amendment 23 S4), where the rescan above decides + # the race: if another lane's claim landed first, this one has lost. uuid="$(session_for "$lane")" utc="$(utc_now)" CLAIM_OBJ="$obj"; CLAIM_LANE="$lane"; CLAIM_SKIP="$takeover_from"; CLAIM_SKIP_DEAD="$takeover_dead" CLAIM_ABANDON_REVIVED="" - CP_AFTER_REBASE=claim_rescan_hook + CP_REPLAY_ENV=("LANES__CLAIM_FROM=$takeover_from" "LANES__CLAIM_DEAD=$takeover_dead" + "LANES__CLAIM_READS=$reads_carry" "LANES__CLAIM_URL=$takeover_payload" + "LANES__CLAIM_READS_FILE=$CP_CLAIM_READS_FILE") + # A RIVAL THAT LANDS BEFORE S3's FETCH, not only one that lands before the + # push: the checks above read what `log_sync` fetched, so a tip that moved + # since runs this claim again from the top, rescan first (`cs_recheck`). + CS_RECHECK=1 # A line written with --no-github is NOT a Rule 1 claim until its GitHub # comment exists, and it says so on its face rather than in a habit. ng=""; [ "$NO_GITHUB" = 1 ] && ng="no-github" @@ -18106,39 +18993,9 @@ EOF write_event "$lane" CLAIMED "$obj" "" "" "$ng" "$utc" "$uuid" fi wrc=$? - CP_AFTER_REBASE="" + CP_REPLAY_ENV=() + CS_RECHECK=0 CLAIM_SKIP_DEAD="" - - # 5. lost: Rule 1 says the lane STOPS AND REPORTS. The claim is abandoned, - # never queued — Rule 7's queueing is for substrates, which this - # amendment does not touch. TWO CAUSES, TWO EXIT CODES (Copilot's own - # "closer look" on 21b8e82, PR #61): 7 is the documented, pre-existing - # CLAIM-LOST — a rival's claim landed on main first; 9 is issue #30's - # own dead-lane verdict failing to reconfirm before this takeover's - # push landed. Both write the same CLAIM-LOST event (this attempt did - # not succeed, in either cause), but automation reading the PROCESS - # exit must be able to tell them apart, so the exit is never coerced - # to 7 for the second. - if [ "$wrc" = 7 ] || [ "$wrc" = 9 ]; then - # "ALIVE AGAIN" ONLY WHERE IT WAS SEEN (Copilot on 05d7889, PR #61): - # `claim_rescan_hook` sets `CLAIM_ABANDON_REVIVED` for a confirmed - # revival — a live session now backs the lane, or its log moved past - # the terminal line — and leaves it empty where the verdict simply - # could not be reconfirmed (an unreadable log, or an unreadable - # records tree). The exit is 9 either way; only the words differ. - if [ "$wrc" = 9 ] && [ -n "$CLAIM_ABANDON_REVIVED" ]; then - note "TAKEOVER ABANDONED — lane $CLAIM_WINNER is alive again. Rule 1: stop and report; do not author a successor." - write_event "$lane" CLAIM-LOST "$obj" "→" "lane:$CLAIM_WINNER" "abandoned: $CLAIM_WINNER is no longer dead, takeover withdrawn before landing" "$(utc_now)" "$uuid" - elif [ "$wrc" = 9 ]; then - note "TAKEOVER ABANDONED — lane $CLAIM_WINNER's dead-lane verdict could not be reconfirmed before this push landed (above). Rule 1: stop and report; do not author a successor." - write_event "$lane" CLAIM-LOST "$obj" "→" "lane:$CLAIM_WINNER" "abandoned: the dead verdict on $CLAIM_WINNER could not be reconfirmed; takeover withdrawn before landing" "$(utc_now)" "$uuid" - else - note "CLAIM LOST — lane $CLAIM_WINNER's claim on $obj landed on main first. Rule 1: stop and report; do not author a successor." - write_event "$lane" CLAIM-LOST "$obj" "→" "lane:$CLAIM_WINNER" "abandoned: $CLAIM_WINNER landed its claim first" "$(utc_now)" "$uuid" - fi - who_object "$obj" || : - exit "$wrc" - fi [ "$wrc" = 0 ] || exit "$wrc" # 6. the comment, AFTER the push, citing the commit that carries the line. @@ -18146,7 +19003,7 @@ EOF if [ "$NO_GITHUB" != 1 ]; then body="$(printf '%s — lane %s, session %s@%s, %s, for %s\n\nLogged in `%s` at `%s`%s.\n\nThe three reads (lane-collision-protocol Rule 1):\n\n```text\n%s\n```\n' \ "${takeover_from:+TAKEOVER}${takeover_from:-CLAIMED}" "$lane" "$uuid" "$WS" "$utc" "$obj" \ - "$(log_path_for "$lane")" "${sha:-unknown}" "$takeover_reason_phrase" "$reads")" + "$(log_path_for "$lane")" "${sha:-unknown}" "$takeover_reason_phrase" "$reads_posted")" url="$(gh_comment "$obj" "$body" 2>/dev/null || :)" if [ -n "$url" ]; then note "comment posted: $url" @@ -20501,7 +21358,7 @@ EOF ;; # opensoft/openRepoTools#162 — WHAT A HAND COMMIT OF A HANDOFF'S ATTACHMENTS - # WOULD STAGE THAT IS BYTECODE, asked BEFORE the commit. `commit_push` + # WOULD STAGE THAT IS BYTECODE, asked BEFORE the commit. `commit_publish` # refuses such a pathspec for every write this file makes; attachments are # committed by hand, so this is the same question for a person to ask first. # 0 nothing in those paths would stage bytecode, a cache or an environment diff --git a/tests/a23_box.py b/tests/a23_box.py index be3f087..bbbabd3 100644 --- a/tests/a23_box.py +++ b/tests/a23_box.py @@ -39,6 +39,22 @@ exit 90 """ +#: `LANES_GIT`: every NETWORK call the helper makes, logged one per line, then +#: an optional hook (`FAKE_NET_HOOK`, run as ` `), then the +#: real git. The hook decides: exit 0 runs the real git; exit 97 means the hook +#: ran git itself and the call succeeded; any other exit is the call's own +#: (a failure). `` is the git subcommand: fetch, push, ls-remote or clone. +NET_GIT = """#!/usr/bin/env bash +op="" +for a in "$@"; do case "$a" in fetch|push|pull|ls-remote|clone) op="$a"; break ;; esac; done +printf '%s %s\\n' "$op" "$*" >> "$FAKE_NET_LOG" +if [ -n "${FAKE_NET_HOOK:-}" ]; then + rc=0; "$FAKE_NET_HOOK" "$op" "$@" || rc=$? + case "$rc" in 0) : ;; 97) exit 0 ;; *) exit "$rc" ;; esac +fi +exec git "$@" +""" + def register_text(rows) -> str: return HEADER + "".join(f"{r}\n" for r in rows) @@ -150,6 +166,69 @@ def peer_pushes(self, rel: str, text: str) -> None: self.commit(self.seed, f"a peer writes {rel}", rel) self.git("-C", str(self.seed), "push", "-q", "origin", "main") + def peer_edits(self, rel: str, old: str, new: str, message: str = "") -> None: + """The seed clone, brought up to date, replaces `old` with `new` in one + file and publishes it: another writer's commit landing first.""" + self.git("-C", str(self.seed), "pull", "-q", "--ff-only", "origin", "main") + path = self.seed / rel + text = path.read_text(encoding="utf-8") + assert text.count(old) == 1, (rel, old) + path.write_text(text.replace(old, new), encoding="utf-8") + self.commit(self.seed, message or f"a peer edits {rel}", rel) + self.git("-C", str(self.seed), "push", "-q", "origin", "main") + + # ------------------------------------------------------------- the seam + def seam(self, name: str = "c") -> Path: + """A clone of origin named by the explicit `LANES_REPO` (S2.1, the + test seam): every write of this box then goes through it, in place.""" + checkout = self.clone(self.root / name) + self.env["LANES_REPO"] = str(checkout) + return checkout + + def use_net(self, hook: str = "") -> Path: + """Route the helper's network calls through `NET_GIT`, logged to + `self.net_log`; `hook` is the body of `FAKE_NET_HOOK`, if any.""" + shim = self.fakebin / "net-git" + shim.write_text(NET_GIT, encoding="utf-8") + shim.chmod(0o755) + self.net_log = self.root / "net.log" + self.net_log.write_text("", encoding="utf-8") + self.env["LANES_GIT"] = str(shim) + self.env["FAKE_NET_LOG"] = str(self.net_log) + if hook: + body = self.fakebin / "net-hook" + body.write_text("#!/usr/bin/env bash\n" + hook, encoding="utf-8") + body.chmod(0o755) + self.env["FAKE_NET_HOOK"] = str(body) + return shim + + def net_ops(self) -> list: + """The network operations the helper made, in order.""" + return [line.split(" ", 1)[0] for line in + self.net_log.read_text(encoding="utf-8").splitlines()] + + def snapshot(self, checkout: Path) -> tuple: + """Everything a refusal must leave as it was: HEAD and the branch it + is on, the index, the working tree, and origin's tip.""" + c = ["-C", str(checkout)] + return (self.git(*c, "rev-parse", "HEAD").strip(), + self.git(*c, "symbolic-ref", "-q", "HEAD", check=False).strip(), + self.git(*c, "status", "--porcelain", "--untracked-files=all"), + self.git(*c, "diff"), + self.git(*c, "diff", "--cached"), + self.origin_head()) + + @staticmethod + def lock_of(checkout: Path) -> Path: + return checkout / "lanes" / ".lanes-edit.lock" + + def unpublished(self, checkout: Path) -> list: + """`git cherry -v origin/main HEAD`'s `+` lines: what origin lacks.""" + self.git("-C", str(checkout), "fetch", "-q", "origin") + return [line for line in self.git("-C", str(checkout), "cherry", "-v", + "refs/remotes/origin/main", "HEAD").splitlines() + if line.startswith("+")] + # ------------------------------------------------------------- the helper def cmd(self, *args: str) -> list: return ["bash", str(REPO / "lanes-edit.sh"), *args] diff --git a/tests/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py new file mode 100644 index 0000000..fb2752d --- /dev/null +++ b/tests/test_a23_guard_refusals.py @@ -0,0 +1,868 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Amendment 23 S3: before the edit, fetch, check, fast-forward — or refuse +with nothing written. + +Every writer runs `checkout_sync` under its checkout's lock, before it reads +the file it writes. Its rows, in the amendment's order: a fetch that fails (or +`LANES_NO_FETCH=1`, which is one for a writer); an operation in progress or +unmerged entries; not on the branch; staged entries that are not the write's +own; unpushed commits by content; a diverged branch; a pending hand edit of the +register. The first that holds refuses with 2, names the checkout, says nothing +was written, prints its recovery, and releases the lock. Otherwise the checkout +is fast-forwarded and the edit is made on origin's tip — the 2026-10-13T13:12Z +shape (#198), clean and 10 behind with an adjacent row changed on origin, is +pushed instead of stranded. + +The writes go through a clone named by the explicit `LANES_REPO` (S2.1, the +test seam), and S3 applies there as in a lane's own checkout (B1). Two cases +show the same guard through a routed lane checkout and through W with routing +off. The sandbox is the real `lanes-edit.sh`; nothing reaches a real remote. +""" + +from __future__ import annotations + +import os +import re +import shlex +import shutil +from pathlib import Path + +import pytest + +from a23_box import REGISTER, RegisterBox, register_text +from conftest import 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 = "repoS3-1" +NEIGHBOUR = "repoS3-2" +NEW_LANE = "repoS3-3" +UUID = "a23b3001-a2a2-4000-8000-a23b3001a2a2" +UUID2 = "a23b3002-a2a2-4000-8000-a23b3002a2a2" +OBJ = "opensoft/repoS3#1" +HOME_FLAG = ("--home", "opensoft/repoS3") + + +def _row(lane: str, uuid: str = UUID, state: str = "LIVE · seeded") -> str: + return (f"| `{lane}` | harness `{uuid}` | Eagle / test / tester | 2026-10-20T00:00Z " + f"| — | handoffs/repoS3/{lane}.md | {state} |") + + +class Box(RegisterBox): + """Two adjacent rows, the lane's log, and a handoffs file a peer may hold.""" + + def __init__(self, root: Path, **kw) -> None: + root = Path(os.path.realpath(root)) + files = { + REGISTER: register_text([_row(LANE), _row(NEIGHBOUR, uuid=UUID2)]), + f"lanes/log/{LANE}.md": + f"# lane {LANE} — object log (lane-collision-protocol Amendment 7)\n", + "handoffs/README.md": "# handoffs\n", + } + super().__init__(root, files, **kw) + + def hand_edit(self, checkout: Path, lane: str = LANE, state: str = "LIVE · a hand edit") -> None: + """An uncommitted edit of one row's state cell, as a person makes it.""" + path = checkout / REGISTER + text = path.read_text(encoding="utf-8") + uuid = UUID if lane == LANE else UUID2 + old = _row(lane, uuid=uuid) + assert old in text, text + path.write_text(text.replace(old, _row(lane, uuid=uuid, state=state)), encoding="utf-8") + + +@pytest.fixture +def box(tmp_path: Path) -> Box: + return Box(tmp_path) + + +# -------------------------------------------------------- the writing verbs + +#: Each writing verb, as a caller runs it: (argv, extra environment). +VERBS = { + "set-row-state": (("set-row-state", LANE, "LIVE · the guarded write"), {}), + "replace-in-row": (("replace-in-row", LANE, "seeded", "reseeded", "the guarded write"), {}), + "append-session-id": (("append-session-id", LANE, UUID, "harness `a23b3009-a2a2-4000-8000-a23b3009a2a2`"), {}), + "append-line": ((f"append-line", f"LANDING — lane {LANE}, session {UUID}@Eagle, " + "2026-10-20T00:00:00Z, PR #7 into opensoft/repoS3 main"), {"LANES_LANE": LANE}), + "add-row": (("add-row", _row(NEW_LANE, uuid="a23b3003-a2a2-4000-8000-a23b3003a2a2")), {}), + "log": (("log", "NOTED", f"lane:{LANE}", "the guarded note", *HOME_FLAG), + {"LANES_LANE": LANE, "LANES_SESSION": UUID}), + "claim": (("claim", OBJ, "--no-github", *HOME_FLAG), {"LANES_LANE": LANE, "LANES_SESSION": UUID}), + "release": (("release", OBJ, "the guarded release", "--no-github", *HOME_FLAG), + {"LANES_LANE": LANE, "LANES_SESSION": UUID}), + "commit": (("commit", "the guarded hand edit"), {"LANES_LANE": LANE}), +} + + +def run_verb(box: Box, verb: str, **extra: str): + argv, env = VERBS[verb] + return box.helper(*argv, **dict(env, **extra)) + + +# -------------------------------------------------------- the rows of S3 +# +# Each setup puts the checkout `c` in one state and returns the extra +# environment the write runs with and the words its refusal must carry: the +# row's own reason and its recovery. + +def _local_commit(box: Box, c: Path, rel: str, text: str, message: str) -> str: + box.write(c, rel, text) + box.commit(c, message, rel) + return box.git("-C", str(c), "rev-parse", "HEAD").strip() + + +def _rebase(box: Box, c: Path): + """A rebase stopped on a conflict, in a file the write does not touch.""" + _local_commit(box, c, "handoffs/README.md", "# handoffs\nmine\n", "a local change") + box.peer_pushes("handoffs/README.md", "# handoffs\ntheirs\n") + box.git("-C", str(c), "fetch", "-q", "origin") + box.git("-C", str(c), "rebase", "refs/remotes/origin/main", check=False) + assert (c / ".git" / "rebase-merge").is_dir() or (c / ".git" / "rebase-apply").is_dir() + return {}, ["a rebase is in progress", "rebase --abort", "never --continue what you did not start"] + + +def _merge(box: Box, c: Path): + box.git("-C", str(c), "switch", "-q", "-c", "side") + _local_commit(box, c, "handoffs/side.md", "side\n", "a side line") + box.git("-C", str(c), "switch", "-q", "main") + box.git("-C", str(c), "merge", "-q", "--no-ff", "--no-commit", "side") + assert (c / ".git" / "MERGE_HEAD").exists() + return {}, ["a merge is in progress", "merge --abort"] + + +def _cherry_pick(box: Box, c: Path): + head = box.git("-C", str(c), "rev-parse", "HEAD").strip() + (c / ".git" / "CHERRY_PICK_HEAD").write_text(head + "\n", encoding="utf-8") + return {}, ["a cherry-pick is in progress", "cherry-pick --abort"] + + +def _revert(box: Box, c: Path): + head = box.git("-C", str(c), "rev-parse", "HEAD").strip() + (c / ".git" / "REVERT_HEAD").write_text(head + "\n", encoding="utf-8") + return {}, ["a revert is in progress", "revert --abort"] + + +def _unmerged(box: Box, c: Path): + """Unmerged entries with no operation recorded: a conflicted merge whose + MERGE_HEAD is gone.""" + box.git("-C", str(c), "switch", "-q", "-c", "side") + _local_commit(box, c, "handoffs/README.md", "# handoffs\nside\n", "side") + box.git("-C", str(c), "switch", "-q", "main") + _local_commit(box, c, "handoffs/README.md", "# handoffs\nmain\n", "main") + box.git("-C", str(c), "merge", "-q", "side", check=False) + for name in ("MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"): + (c / ".git" / name).unlink(missing_ok=True) + assert box.git("-C", str(c), "ls-files", "-u").strip() + return {}, ["unmerged entries: handoffs/README.md", "git -C"] + + +def _detached(box: Box, c: Path): + box.git("-C", str(c), "checkout", "-q", "--detach") + return {}, ["HEAD is detached, not refs/heads/main", "switch main"] + + +def _branch(box: Box, c: Path): + box.git("-C", str(c), "switch", "-q", "-c", "elsewhere") + return {}, ["HEAD is refs/heads/elsewhere, not refs/heads/main", "switch main"] + + +def _staged(box: Box, c: Path): + box.write(c, "handoffs/README.md", "# handoffs\na peer's staged line\n") + box.git("-C", str(c), "add", "--", "handoffs/README.md") + return {}, ["staged entries this write did not make: handoffs/README.md", + "restore --staged -- handoffs/README.md", + "never commit staged entries you did not make"] + + +def _unpushed(box: Box, c: Path): + sha = _local_commit(box, c, "handoffs/repoS3/note.md", "a note\n", "an unpushed note") + return {}, ["1 unpublished commit(s)", f"+ {sha}", "cherry -v refs/remotes/origin/main HEAD", + "the publish act", "reset --keep refs/remotes/origin/main"] + + +def _diverged(box: Box, c: Path): + """Every commit on HEAD already on origin by content, under another sha.""" + _local_commit(box, c, "handoffs/README.md", "# handoffs\nthe same line\n", "mine") + box.peer_pushes("handoffs/README.md", "# handoffs\nthe same line\n") + return {}, ["this checkout has diverged", "reset --keep refs/remotes/origin/main"] + + +def _fetch_fails(box: Box, c: Path): + box.use_net('[ "$1" = fetch ] && { echo "fatal: unable to access origin" >&2; exit 128; }\nexit 0\n') + return {}, ["the fetch of origin main failed", "unable to access origin", + f"git -C {c} fetch origin main"] + + +def _no_fetch(box: Box, c: Path): + return {"LANES_NO_FETCH": "1"}, ["LANES_NO_FETCH=1 is set", + "a fetch that did not run is a fetch that failed"] + + +def _pending_register(box: Box, c: Path): + box.hand_edit(c, lane=NEIGHBOUR, state="LIVE · a peer's half-written cell") + return {}, ["lanes/LANES.md holds an uncommitted edit", "which may be a peer's", + " commit \"\""] + + +ROWS = { + "rebase": _rebase, + "merge": _merge, + "cherry-pick": _cherry_pick, + "revert": _revert, + "unmerged": _unmerged, + "detached": _detached, + "branch": _branch, + "staged": _staged, + "unpushed": _unpushed, + "diverged": _diverged, + "fetch-fails": _fetch_fails, + "no-fetch": _no_fetch, + "pending-register": _pending_register, +} + +#: `commit` CARRIES a pending register hand edit (S3 step 4); that row is +#: `test_a23_publish_replay`'s, and here it is the one pair left out. +CASES = [(v, r) for r in ROWS for v in VERBS if not (v == "commit" and r == "pending-register")] + + +@pytest.mark.parametrize("verb,row", CASES, ids=[f"{v}--{r}" for v, r in CASES]) +def test_every_S3_row_refuses_every_writer_with_nothing_written(box: Box, verb: str, row: str): + c = box.seam() + extra, words = ROWS[row](box, c) + if verb == "commit": + # `commit` with nothing of its own is the publish act, which the + # unpushed row exempts; the guard is asked of its hand-edit form. + box.hand_edit(c) + before = box.snapshot(c) + w_before = box.w_state() + result = run_verb(box, verb, **extra) + err = result.stderr + assert result.returncode == 2, (result.stdout, err) + assert f"in {c}:" in err, "the refusal names the checkout" + assert "Nothing was written." in err, err + for phrase in words: + assert phrase in err, (phrase, err) + if row == "pending-register": + # The recovery names the lane the write is for: `add-row`'s is the row's. + assert f"LANES_LANE={NEW_LANE if verb == 'add-row' else LANE} " in err, err + assert box.snapshot(c) == before, "HEAD, the branch, the index, the tree and origin are as they were" + assert box.w_state() == w_before, "W is untouched" + assert not box.lock_of(c).exists(), "the lock is released" + + +# -------------------------------------------------------- the fast-forward + +def test_ff_refusal_names_the_file(box: Box): + """An unstaged change to a file origin has since changed: git refuses the + fast-forward rather than overwrite it, and the refusal is S3's, quoting + git and naming the file.""" + c = box.seam() + box.write(c, "handoffs/README.md", "# handoffs\na peer's line, mid-write\n") + box.peer_pushes("handoffs/README.md", "# handoffs\norigin's newer line\n") + before = box.snapshot(c) + result = run_verb(box, "set-row-state") + assert result.returncode == 2, result.stderr + assert "the fast-forward to origin/main refused" in result.stderr + assert "handoffs/README.md" in result.stderr, "git's words name the file" + assert "Nothing was written." in result.stderr + assert box.snapshot(c) == before + assert (c / "handoffs/README.md").read_text(encoding="utf-8") == "# handoffs\na peer's line, mid-write\n" + assert not box.lock_of(c).exists() + + +def test_unstaged_change_origin_did_not_touch_does_not_block(box: Box): + """Ratified choice 8: an unstaged change to another tracked file is not a + refusal on its own. It blocks only where the fast-forward would overwrite + it — and here origin moved a row of the register, not that file.""" + c = box.seam() + box.write(c, "handoffs/README.md", "# handoffs\na peer's line, mid-write\n") + box.peer_edits(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2, state="LIVE · the neighbour moved")) + for verb in ("set-row-state", "log", "claim"): + result = run_verb(box, verb) + assert result.returncode == 0, (verb, result.stderr) + register = box.origin_register() + assert " · the guarded write |" in register + assert "LIVE · the neighbour moved" in register, "the edit was made on origin's tip" + assert "the guarded note" in box.origin_file(f"lanes/log/{LANE}.md") + assert box.git("-C", str(c), "diff", "--name-only").split() == ["handoffs/README.md"] + assert box.origin_file("handoffs/README.md") == "# handoffs\n", "the peer's line was never committed" + + +def test_the_1312Z_shape_is_pushed(box: Box): + """#198's measurement, 2026-10-13T13:12Z: a private clone, clean, 0 ahead + and 10 behind, whose adjacent row origin had changed, stranded all three + of a launch's writes within 29 seconds. Fetched and fast-forwarded first, + the row stamp and the log line are both pushed, and nothing is left + unpublished. + + The stamp must push on its FIRST attempt, and the two asserts on it say so + apart (rv1b's SHOULD 2): without S3's fast-forward the stamp edits the + stale tree, its push is refused, and the replay's `reset --keep` catches up + instead, so attempt 2 reaches the same end state and every other assert + here still holds (m1b's S10 survived that way).""" + c = box.seam() + for n in range(9): + box.peer_pushes(f"handoffs/repoS3/peer-{n}.md", f"peer {n}\n") + box.peer_edits(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2, state="LIVE · the adjacent row, changed on origin")) + box.git("-C", str(c), "fetch", "-q", "origin") + assert box.git("-C", str(c), "rev-list", "--left-right", "--count", + "HEAD...refs/remotes/origin/main").split() == ["0", "10"] + stamp = run_verb(box, "set-row-state") + assert stamp.returncode == 0, stamp.stderr + assert "pushed origin/main (attempt 1)" in stamp.stderr, stamp.stderr + assert "push refused" not in stamp.stderr, stamp.stderr + note = run_verb(box, "log") + assert note.returncode == 0, note.stderr + register = box.origin_register() + assert " · the guarded write |" in register + assert "the adjacent row, changed on origin" in register + assert "the guarded note" in box.origin_file(f"lanes/log/{LANE}.md") + assert box.unpublished(c) == [], "no `+` under git cherry: nothing stranded" + assert box.git("-C", str(c), "rev-parse", "HEAD") == box.origin_head() + "\n" + + +def test_a_stray_copy_from_a_rename_that_never_landed_is_named_as_one(box: Box): + """rv1b's NIT 2: S3's fast-forward brings an old-cased PUBLISHED log into a + checkout that holds the canonical file untracked — what a case-only rename + whose commit never landed leaves. `ensure_log` then sees two files, and + 15(d)'s hand merge is the wrong recovery for a stray copy: a commit of it + would publish a second file for one lane. The refusal names the tracked + log and the untracked copy, and the cure is to remove the copy and re-run, + after which the write renames the published log once, in its own commit.""" + c = box.seam() + old_cased = f"lanes/log/{NEIGHBOUR.lower()}.md" + canonical = f"lanes/log/{NEIGHBOUR}.md" + header = f"# lane {NEIGHBOUR} — object log (lane-collision-protocol Amendment 7)\n" + box.peer_pushes(old_cased, header) + stray = box.write(c, canonical, header) + origin_before = box.origin_head() + note = ("log", "NOTED", f"lane:{NEIGHBOUR}", "the stray-copy note", *HOME_FLAG) + env = {"LANES_LANE": NEIGHBOUR, "LANES_SESSION": UUID2} + result = box.helper(*note, **env) + err = result.stderr + assert result.returncode == 2, (result.stdout, err) + assert f"{stray} is NOT TRACKED" in err, err + assert "a case-only rename whose commit never landed" in err, err + assert f'rm -- "{stray}"' in err, err + assert "renames the tracked log once, inside its own commit" in err, err + assert "Merge them by hand" not in err, "15(d)'s hand merge is not this state's recovery" + assert "Nothing was written." in err, err + assert box.origin_head() == origin_before, "nothing was published" + assert stray.read_text(encoding="utf-8") == header, "the copy is left as it was" + assert not box.lock_of(c).exists(), "the lock is released" + stray.unlink() + again = box.helper(*note, **env) + assert again.returncode == 0, again.stderr + assert "the stray-copy note" in box.origin_file(canonical) + logs = box.git("--git-dir", str(box.origin), "ls-tree", "--name-only", "main", "lanes/log/").split() + assert canonical in logs, logs + assert old_cased not in logs, "the rename landed in the write's own commit" + + +# -------------------------------------------------------- every writer, routed or not + +def test_S3_applies_through_a_routed_lane_checkout(box: Box): + """B1: the lane's own checkout, made by routing, takes the same guard.""" + first = run_verb(box, "set-row-state") + assert first.returncode == 0, first.stderr + lc = box.checkout(LANE) + assert lc.is_dir() + _local_commit(box, lc, "handoffs/repoS3/note.md", "a note\n", "an unpushed note") + before = box.snapshot(lc) + result = run_verb(box, "log") + assert result.returncode == 2, result.stderr + assert f"in {lc}:" in result.stderr + assert "unpublished commit" in result.stderr + assert box.snapshot(lc) == before + assert not box.lock_of(lc).exists() + + +def test_S3_applies_without_the_opt_in(tmp_path: Path): + """B1: with routing off a write goes through W, as on main — and S3 and S4 + are #198's fix for exactly that checkout, so they apply there too.""" + box = Box(tmp_path, route=False) + _local_commit(box, box.w, "handoffs/repoS3/note.md", "a note\n", "an unpushed note in W") + before = box.snapshot(box.w) + result = run_verb(box, "set-row-state") + assert result.returncode == 2, result.stderr + assert f"in {box.w}:" in result.stderr + assert "unpublished commit" in result.stderr + assert box.snapshot(box.w) == before + assert not box.lanes_root.exists(), "nothing was routed" + + +def test_W_behind_is_fast_forwarded_by_a_write_through_it(tmp_path: Path): + """With routing off, the write through W fast-forwards W first (S3): the + edit is made on origin's tip and W is left at it, with nothing ahead.""" + box = Box(tmp_path, route=False) + box.peer_edits(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2, state="LIVE · moved on origin")) + result = run_verb(box, "set-row-state") + assert result.returncode == 0, result.stderr + assert "LIVE · moved on origin" in box.origin_register() + assert box.unpublished(box.w) == [] + assert box.git("-C", str(box.w), "rev-parse", "HEAD") == box.origin_head() + "\n" + + +@pytest.mark.parametrize("state", ["untracked", "committed"]) +def test_a_migration_dry_run_names_a_rename_that_never_landed_and_never_says_pull(box: Box, state: str): + """rv1b-c2's NIT 4: migrate-state-cells' dry run met a lane whose log is + published under one case while this checkout has it under another, and + said "Pull first, or merge them (15(d))". No writer pulls now, a hand pull + in a shared checkout is the 1312Z hazard, and a hand merge of a stray copy + would publish a second file for one lane. The skip line now says which + state it is: the copy a rename's two `mv`s left, NOT TRACKED, with + `ensure_log`'s recovery (compare, remove the copy, put the published log + back, re-run); or a rename committed here and never published, with the + publish act. The dry run writes nothing either way.""" + c = box.seam() + old_cased = f"lanes/log/{NEIGHBOUR.lower()}.md" + canonical = f"lanes/log/{NEIGHBOUR}.md" + header = f"# lane {NEIGHBOUR} — object log (lane-collision-protocol Amendment 7)\n" + box.peer_pushes(old_cased, header) + box.git("-C", str(c), "fetch", "-q", "origin") + box.git("-C", str(c), "merge", "-q", "--ff-only", "refs/remotes/origin/main") + (c / old_cased).rename(c / canonical) + if state == "committed": + box.git("-C", str(c), "add", "-A", "--", "lanes/log") + box.git("-C", str(c), "commit", "-q", "-m", f"LANES({NEIGHBOUR}@Eagle): a rename that never landed") + before = box.snapshot(c) + result = box.helper("migrate-state-cells") + out = result.stdout + assert result.returncode == 0, (out, result.stderr) + assert "DRY RUN — nothing was written" in out, out + line = next((l for l in out.splitlines() if l.strip().startswith(f"{NEIGHBOUR} ")), "") + assert "a case-only rename whose commit never landed" in line, out + assert "Pull first" not in out, "no writer pulls, so no recovery is a pull" + assert "merge them" not in line, "15(d)'s hand merge is not this state's recovery" + if state == "untracked": + assert f"{NEIGHBOUR}.md NOT TRACKED" in line, line + assert f'rm -- "{c / canonical}"' in line, line + assert f"git -C {c} checkout HEAD -- {old_cased}" in line, line + else: + assert "committed here and not on origin" in line, line + assert "The publish act refuses it" in line, "#214's fence takes no rename, so it is not the recovery" + assert f"git -C {c} push origin HEAD:refs/heads/archive/{NEIGHBOUR}-stranded-" in line, line + assert f"git -C {c} reset --keep refs/remotes/origin/main" in line, line # 703 + assert box.snapshot(c) == before, "a dry run writes nothing" + if state == "untracked": + (c / canonical).unlink() + box.git("-C", str(c), "checkout", "HEAD", "--", old_cased) + else: + box.git("-C", str(c), "push", "-q", "origin", f"HEAD:refs/heads/archive/{NEIGHBOUR}-stranded-test") + box.git("-C", str(c), "reset", "-q", "--keep", "refs/remotes/origin/main") + again = box.helper("migrate-state-cells") + assert again.returncode == 0, (again.stdout, again.stderr) + assert "SKIPPED" not in again.stdout, "the recovery the line gives settles it" + + +def test_a_migration_dry_run_never_calls_a_staged_rename_committed(box: Box): + """rv1b-c3's NIT D (its probe P1): the dry run asked `ls-files + --error-unmatch` whether the rename was committed, and that reads the + INDEX. A case-only rename that was STAGED and never committed (a write's + `git commit` failed after its `add`) was called "committed here and not + on origin" and given the archive recovery: an archive branch identical to + main on origin, and a `reset --keep` that only unstaged it, which left the + stray copy for the next dry run to name. "Committed" is said only where + HEAD holds the file; a rename that is only staged is unstaged + (`restore --staged`), with no archive step, and the stray-copy recovery + that follows is the one the next line gives. The dry run writes nothing.""" + c = box.seam() + old_cased = f"lanes/log/{NEIGHBOUR.lower()}.md" + canonical = f"lanes/log/{NEIGHBOUR}.md" + header = f"# lane {NEIGHBOUR} — object log (lane-collision-protocol Amendment 7)\n" + box.peer_pushes(old_cased, header) + box.git("-C", str(c), "fetch", "-q", "origin") + box.git("-C", str(c), "merge", "-q", "--ff-only", "refs/remotes/origin/main") + (c / old_cased).rename(c / canonical) + box.git("-C", str(c), "add", "-A", "--", "lanes/log") + assert box.git("-C", str(c), "rev-parse", "HEAD").strip() == box.origin_head(), "nothing is committed" + before = box.snapshot(c) + result = box.helper("migrate-state-cells") + out = result.stdout + assert result.returncode == 0, (out, result.stderr) + line = next((l for l in out.splitlines() if l.strip().startswith(f"{NEIGHBOUR} ")), "") + assert line, out + assert "committed here" not in line, "a staged rename is not a committed one" + assert f"{NEIGHBOUR}.md STAGED and not committed" in line, line + assert "a case-only rename whose commit never landed" in line, line + assert "push origin HEAD:refs/heads/archive/" not in line, "there is no commit to archive" + assert "reset --keep" not in line, "that only unstages it, after an archive nobody needed" + unstage = f"git -C {c} restore --staged -- {old_cased} {canonical}" + assert unstage in line, line + assert box.snapshot(c) == before, "a dry run writes nothing" + # The recovery the line gives, step by step: unstaged, the copy is the + # NOT TRACKED state and the next line says so; its own recovery then + # settles it. + box.git(*shlex.split(unstage)[1:]) + mid = box.helper("migrate-state-cells") + mid_line = next((l for l in mid.stdout.splitlines() if l.strip().startswith(f"{NEIGHBOUR} ")), "") + assert f"{NEIGHBOUR}.md NOT TRACKED" in mid_line, mid.stdout + (c / canonical).unlink() + box.git("-C", str(c), "checkout", "HEAD", "--", old_cased) + again = box.helper("migrate-state-cells") + assert again.returncode == 0, (again.stdout, again.stderr) + assert "SKIPPED" not in again.stdout, "the recovery the line gives settles it" + + +@pytest.mark.parametrize("pair", ["stray-copy", "committed-rename"]) +def test_a_migration_dry_run_names_a_stray_copy_beside_the_published_log_as_one(box: Box, pair: str): + """NIT 4's neighbour: the same scan's line for a checkout holding two logs + whose names differ only by case said "15(d)'s hand merge" of the + stray-copy state, `ensure_log`'s B36 state: the published log tracked + and, beside it, the untracked copy a case-only rename whose commit never + landed leaves. A hand merge would commit the copy as a second published + file for one lane. The line now gives B36's recovery (compare, remove + the copy, re-run), the dry run writes nothing, and the recovery settles + it. Where the tracked one is NOT the published log (a rename committed + here, the published spelling back beside it untracked), removing the + untracked one would remove the published spelling, so that recovery is + never given there.""" + c = box.seam() + old_cased = f"lanes/log/{NEIGHBOUR.lower()}.md" + canonical = f"lanes/log/{NEIGHBOUR}.md" + header = f"# lane {NEIGHBOUR} — object log (lane-collision-protocol Amendment 7)\n" + box.peer_pushes(old_cased, header) + box.git("-C", str(c), "fetch", "-q", "origin") + box.git("-C", str(c), "merge", "-q", "--ff-only", "refs/remotes/origin/main") + if pair == "stray-copy": + untracked = box.write(c, canonical, header) + else: + (c / old_cased).rename(c / canonical) + box.git("-C", str(c), "add", "-A", "--", "lanes/log") + box.git("-C", str(c), "commit", "-q", "-m", f"LANES({NEIGHBOUR}@Eagle): a rename that never landed") + untracked = box.write(c, old_cased, header) + before = box.snapshot(c) + result = box.helper("migrate-state-cells") + out = result.stdout + assert result.returncode == 0, (out, result.stderr) + assert "DRY RUN — nothing was written" in out, out + line = next((l for l in out.splitlines() if l.strip().startswith(f"{NEIGHBOUR} ")), "") + assert line, out + assert box.snapshot(c) == before, "a dry run writes nothing" + if pair == "committed-rename": + assert "NOT TRACKED" not in line, line + assert "rm --" not in line, "the untracked file is the published spelling, never to be removed" + return + assert "hand merge" not in line, "15(d)'s hand merge is not this state's recovery" + assert "a case-only rename whose commit never landed" in line, line + assert f"published as {old_cased}, and this checkout also has {NEIGHBOUR}.md beside it, NOT TRACKED" in line, line + assert f'diff -- "{c / old_cased}" "{untracked}"' in line, line + assert f'rm -- "{untracked}"' in line, line + untracked.unlink() + again = box.helper("migrate-state-cells") + assert again.returncode == 0, (again.stdout, again.stderr) + assert "SKIPPED" not in again.stdout, "the recovery the line gives settles it" + + +# -------------------------------------------- the managed seam, after S3 too +# +# 283 of lane openRepoTools-3's review of #307 (its P2). The legacy row verbs, +# `rename-lane` and `write_event`'s lane verbs asked the managed seam before +# the lock, on the ref as last fetched (`set-row-state` runs no `log_sync` at +# all), and S3's fetch and fast-forward then brought in what the seam never +# saw: the managed ledger's marker in the lane's state cell. `set-row-state` +# wrote over it (0), and the marker was gone from origin. Ruled 2026-10-04: +# "managed ledger owns enrolled lanes; #97 owns legacy". + +MARKER = "MANAGED OWNER · ledger-1" +S3_REFSPEC = "+refs/heads/main:refs/remotes/origin/main" + +#: Each legacy act the seam guards, on lane LANE: (argv, extra environment, +#: what must not be on origin after it). +SEAM_ACTS = { + "set-row-state": (("set-row-state", LANE, "LIVE · a legacy write"), {}, " · a legacy write |"), + "replace-in-row": (("replace-in-row", LANE, "tester", "tester-x", "a legacy replace"), {}, "tester-x"), + "append-session-id": (("append-session-id", LANE, UUID, "harness `a23b3009-a2a2-4000-8000-a23b3009a2a2`"), + {}, "a23b3009"), + "rename-lane": (("rename-lane", LANE, "repoS3-9", "a legacy rename", "--no-github"), + {"LANES_SESSION": UUID}, "repoS3-9"), + "log-PAUSED": (("log", "PAUSED", f"lane:{LANE}", "→", "swap; dir /nowhere", "a legacy pause"), + {"LANES_LANE": LANE, "LANES_SESSION": UUID}, "a legacy pause"), +} + + +def _the_ledger_enrols_at_s3s_fetch(box: Box) -> str: + """The managed ledger writes its marker into lane LANE's state cell, and + its commit lands on origin at this write's S3 fetch (its refspec): after + the write's own seam check before the lock, before the fast-forward. + Returns the row as the ledger left it.""" + seed = shlex.quote(str(box.seed)) + box.git("-C", str(box.seed), "pull", "-q", "--ff-only", "origin", "main") + path = box.seed / REGISTER + text = path.read_text(encoding="utf-8") + enrolled = _row(LANE, state=MARKER) + path.write_text(text.replace(_row(LANE), enrolled), encoding="utf-8") + box.commit(box.seed, "the ledger enrols the lane", REGISTER) + count = shlex.quote(str(box.root / "s3hook.count")) + box.use_net('[ "$1" = fetch ] || exit 0' + "\n" + + f'case "$*" in *"{S3_REFSPEC}"*) : ;; *) exit 0 ;; esac' + "\n" + + f"[ -e {count} ] && exit 0" + "\n" + + f"echo 1 > {count}" + "\n" + + f"git -C {seed} push -q origin main" + "\n" + "exit 0" + "\n") + return enrolled + + +@pytest.mark.parametrize("act", sorted(SEAM_ACTS)) +def test_a_marker_S3_brings_in_is_asked_again_and_refuses_the_legacy_act(box: Box, act: str): + """283: the seam is asked again right after S3, on what the fast-forward + brought in. A marker that landed under the write refuses it (2), nothing + is written, and origin keeps the ledger's row as the ledger wrote it.""" + c = box.seam() + enrolled = _the_ledger_enrols_at_s3s_fetch(box) + argv, env, never = SEAM_ACTS[act] + result = box.helper(*argv, **env) + assert (box.root / "s3hook.count").exists(), "the marker landed at S3's fetch" + assert result.returncode == 2, result.stderr + assert f"lane {LANE} is owned by the managed ledger" in result.stderr, result.stderr + assert "Nothing was written" in result.stderr + register = box.origin_register() + assert enrolled in register.splitlines(), "the ledger's row is as the ledger wrote it" + assert never not in register and never not in box.origin_file(f"lanes/log/{LANE}.md") + assert "push" not in box.net_ops(), "refused before any push" + assert box.unpublished(c) == [] + assert not box.lock_of(c).exists() + + +def test_a_diverged_checkout_holding_a_hand_merge_is_told_to_archive_first(box: Box): + """NIT 287 of lane openRepoTools-3's review of #307: S3 step 6 said + "every commit on it is on origin already by content" and printed + `reset --keep origin/main`. `git cherry` skips merge commits, so a hand + merge carrying a line of its own read as nothing unpublished, and the + printed reset would drop that line. Where HEAD holds a merge origin + lacks, the refusal (2, nothing written) now says so, and gives S6.3's + archive before the reset.""" + c = box.seam() + box.peer_pushes("handoffs/repoS3/peer.md", "a peer's file" + "\n") + box.git("-C", str(c), "fetch", "-q", "origin") + box.git("-C", str(c), "merge", "-q", "--no-ff", "-m", "a hand merge", "refs/remotes/origin/main") + box.write(c, "handoffs/repoS3/only-in-the-merge.md", "a line the merge alone carries" + "\n") + box.git("-C", str(c), "add", "--", "handoffs/repoS3/only-in-the-merge.md") + box.git("-C", str(c), "commit", "-q", "--amend", "--no-edit") + assert box.unpublished(c) == [], "cherry calls the merge published" + before = box.snapshot(c) + result = run_verb(box, "set-row-state") + assert result.returncode == 2, result.stderr + err = result.stderr + assert "holds 1 merge commit(s) origin lacks" in err, err + archive = err.find(f"git -C {c} push origin HEAD:refs/heads/archive/{LANE}-stranded-") + reset = err.find(f"git -C {c} reset --keep refs/remotes/origin/main") + assert 0 <= archive < reset, err + assert "STOP and take it to the lane its subject names" in err + assert "the content is already published" not in err, "a merge's content is not" + assert box.snapshot(c) == before, "nothing was written" + assert not box.lock_of(c).exists() + + +@pytest.mark.parametrize("row", ["unpushed", "diverged"]) +def test_s3s_recovery_commands_read_origin_past_a_local_branch_named_origin_main(box: Box, row: str): + """333 and 342 of lane openRepoTools-3's re-check of #307: a local + branch named `origin/main` wins the short name over the tracking ref. S3 + compares by the full ref, but step 5's list line said `cherry -v + origin/main HEAD`, which then listed nothing, and steps 5 and 6 said + `reset --keep origin/main`, which reset to that branch, not to origin. + They name `refs/remotes/origin/main` now, and followed word for word + they read origin.""" + c = box.seam() + if row == "unpushed": + sha = _local_commit(box, c, "handoffs/repoS3/note.md", "a note\n", "an unpushed note") + else: + _local_commit(box, c, "handoffs/README.md", "# handoffs\nthe same line\n", "mine") + box.peer_pushes("handoffs/README.md", "# handoffs\nthe same line\n") + box.git("-C", str(c), "branch", "origin/main", "HEAD") # the shadow + result = run_verb(box, "set-row-state") + err = result.stderr + assert result.returncode == 2, err + if row == "unpushed": + listed = re.search(r"list them: git (-C \S+ cherry -v \S+ HEAD)", err) + assert listed, err + out = box.git(*shlex.split(listed.group(1))) + assert f"+ {sha}" in out, (out, err) + reset = re.search(r"git (-C \S+ reset --keep \S+)", err) + assert reset, err + box.git(*shlex.split(reset.group(1))) + assert box.git("-C", str(c), "rev-parse", "HEAD").strip() == box.origin_head(), "the reset reached origin's tip" + + +def test_a_migration_dry_run_gives_an_untracked_log_a_recovery_that_works(box: Box): + """NIT 288 of lane openRepoTools-3's review of #307 (pre-existing): the + migration's line for a lane whose log is here and NOT TRACKED said + "Commit it first (`git -C commit -m ... -- `)", and git answers + that pathspec of an untracked file with "did not match any file(s) known + to git" (1). And such a commit is an unpublished one, which S3 then + refuses every writer over. The line now says `git add`, then the pathspec + commit in this checkout, then the publish act, and following it settles + the skip.""" + c = box.seam() + rel = f"lanes/log/{NEIGHBOUR}.md" + box.write(c, rel, f"# lane {NEIGHBOUR} — object log (lane-collision-protocol Amendment 7)" + "\n") + result = box.helper("migrate-state-cells") + out = result.stdout + assert result.returncode == 0, (out, result.stderr) + assert "DRY RUN — nothing was written" in out, out + line = next((l for l in out.splitlines() if l.strip().startswith(f"{NEIGHBOUR} ")), "") + assert "NOT TRACKED" in line, out + assert f"git -C {c} add -- {rel} && git -C {c} commit -m" in line, line + assert "the publish act (LANES_LANE=" in line, line + box.git("-C", str(c), "add", "--", rel) + box.git("-C", str(c), "commit", "-q", "-m", f"{NEIGHBOUR}(its log)", "--", rel) + published = box.helper("commit", "its log", LANES_LANE=NEIGHBOUR) + assert published.returncode == 0, published.stderr + assert box.origin_file(rel).startswith(f"# lane {NEIGHBOUR}"), "the publish act sent it" + again = box.helper("migrate-state-cells") + assert again.returncode == 0, (again.stdout, again.stderr) + assert "NOT TRACKED" not in again.stdout, "the recovery the line gives settles it" + + +def test_retire_rows_gives_an_untracked_log_the_same_recovery_and_it_works(box: Box): + """NIT 288's siblings: `retire-rows` (and `archive-rows`, for the archive + file) refused an untracked file with the same `commit -m … -- ` + that git refuses. They say what the migration now says, and following it + lets the sweep run.""" + c = box.seam() + rel = f"lanes/log/{NEIGHBOUR}.md" + box.write(c, rel, f"# lane {NEIGHBOUR} — object log (lane-collision-protocol Amendment 7)" + "\n") + result = box.helper("retire-rows", NEIGHBOUR, LANES_LANE=LANE, LANES_SESSION=UUID) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "NOT TRACKED" in result.stderr, result.stderr + assert f"git -C {c} add -- {rel} && git -C {c} commit -m" in result.stderr, result.stderr + assert f"the publish act (LANES_LANE={LANE} " in result.stderr, "331: the lane it ran for" + assert "Nothing was written" in result.stderr + box.git("-C", str(c), "add", "--", rel) + box.git("-C", str(c), "commit", "-q", "-m", f"{NEIGHBOUR}(its log)", "--", rel) + published = box.helper("commit", "its log", LANES_LANE=LANE) + assert published.returncode == 0, published.stderr + again = box.helper("retire-rows", NEIGHBOUR, LANES_LANE=LANE, LANES_SESSION=UUID) + assert again.returncode == 0, (again.stdout, again.stderr) + assert f"RETIRED — lane {NEIGHBOUR}" in box.origin_file(rel) + + +def _a_log_nobody_tracks(checkout: Path) -> str: + rel = f"lanes/log/{NEIGHBOUR}.md" + RegisterBox.write(checkout, rel, f"# lane {NEIGHBOUR} — object log (lane-collision-protocol Amendment 7)" + "\n") + return rel + + +def test_under_the_opt_in_a_migration_dry_run_in_W_gives_no_commit_in_W(box: Box): + """331 of lane openRepoTools-3's re-check of #307: under the opt-in the + migration's dry run is not routed, so it reads W, and 288's recovery said + to commit there. Followed word for word, the commit went into W; the + publish act routed to the lane's own checkout, found nothing to send + there and exited 0; origin never got the log; and W was left holding a + commit that S5 and `refresh-workspace` then refused. The line now says W + takes no commit and gives the move into its maker's own lane checkout, + and following that, with the maker's lane for ``, publishes the + log and leaves W with nothing of its own.""" + rel = _a_log_nobody_tracks(box.w) + result = box.helper("migrate-state-cells") + out = result.stdout + assert result.returncode == 0, (out, result.stderr) + line = next((l for l in out.splitlines() if l.strip().startswith(f"{NEIGHBOUR} ")), "") + assert "NOT TRACKED" in line, out + assert "takes no commit (Amendment 23 S5)" in line, line + assert f"git -C {box.w} add" not in line and f"git -C {box.w} commit" not in line, line + assert f"mv -- {box.w}/{rel} {box.lanes_root}//{rel}" in line, line + assert f"git -C {box.lanes_root}/ add -- {rel} && git -C {box.lanes_root}/ commit -m" in line, line + assert "the publish act (LANES_LANE=" in line, line + assert f"rm -- {box.w}/{rel}" in line, line + # Word for word, with the maker's own lane for . + lc = box.checkout(NEIGHBOUR) + made = box.helper("lane-checkout", NEIGHBOUR, "--create") + assert made.returncode == 0 and made.stdout.strip() == str(lc), (made.stdout, made.stderr) + (box.w / rel).rename(lc / rel) + box.git("-C", str(lc), "add", "--", rel) + box.git("-C", str(lc), "commit", "-q", "-m", f"{NEIGHBOUR}(its log)", "--", rel) + published = box.helper("commit", "its log", LANES_LANE=NEIGHBOUR) + assert published.returncode == 0, published.stderr + assert box.origin_file(rel).startswith(f"# lane {NEIGHBOUR}"), "the publish act sent it" + assert box.unpublished(box.w) == [], "W holds no commit of its own" + assert box.git("-C", str(box.w), "status", "--porcelain", "--untracked-files=all") == "" + refreshed = box.helper("refresh-workspace") + assert refreshed.returncode == 0, refreshed.stderr + again = box.helper("migrate-state-cells") + assert again.returncode == 0, (again.stdout, again.stderr) + assert "NOT TRACKED" not in again.stdout, "the recovery the line gives settles it" + + +def test_a_routed_sweep_names_its_runners_lane_in_the_publish_act(box: Box): + """331's other half: a routed `retire-rows` goes through its runner's own + checkout, and the publish act it printed said `LANES_LANE=`, which + routes back to that checkout only if the reader puts in the runner's + lane. It now prints that lane, and following the line word for word + publishes the log and lets the sweep run.""" + made = box.helper("lane-checkout", LANE, "--create") + lc = box.checkout(LANE) + assert made.returncode == 0 and made.stdout.strip() == str(lc), (made.stdout, made.stderr) + rel = _a_log_nobody_tracks(lc) + result = box.helper("retire-rows", NEIGHBOUR, LANES_LANE=LANE, LANES_SESSION=UUID) + assert result.returncode == 2, (result.stdout, result.stderr) + err = result.stderr + assert "NOT TRACKED" in err and "Nothing was written" in err, err + assert f"git -C {lc} add -- {rel} && git -C {lc} commit -m" in err, err + assert f"the publish act (LANES_LANE={LANE} " in err, err + box.git("-C", str(lc), "add", "--", rel) + box.git("-C", str(lc), "commit", "-q", "-m", f"{NEIGHBOUR}(its log)", "--", rel) + published = box.helper("commit", "its log", LANES_LANE=LANE) + assert published.returncode == 0, published.stderr + assert box.origin_file(rel).startswith(f"# lane {NEIGHBOUR}"), "the publish act sent it" + again = box.helper("retire-rows", NEIGHBOUR, LANES_LANE=LANE, LANES_SESSION=UUID) + assert again.returncode == 0, (again.stdout, again.stderr) + assert f"RETIRED — lane {NEIGHBOUR}" in box.origin_file(rel) + + +# -------------------------------------------------------- the top level + +@pytest.mark.parametrize("verb", sorted(VERBS)) +def test_a_lanes_repo_inside_the_checkout_refuses_before_any_edit(box: Box, verb: str): + """`LANES_REPO` naming a directory INSIDE the checkout, with `LANES_FILE` + naming the register (slice 2a's writer w2a-c10, on slice 1b's + `commit_publish`). Every pathspec the helper gives git is spelled from the + top level, and git reads one relative to the directory it runs in: the + write's `git add` failed (6) after the edit was made, and S3's own + pending-path check asked about a register path that is not there. It + refuses (2) before any edit, naming the top level and the register.""" + c = box.seam() + inside = c / "handoffs" + if verb == "commit": + # `commit`'s hand-edit form; with nothing of its own it is the + # publish act, which reaches the same check. + box.hand_edit(c) + before = box.snapshot(c) + w_before = box.w_state() + result = run_verb(box, verb, LANES_REPO=str(inside), LANES_FILE=str(c / REGISTER)) + err = result.stderr + assert result.returncode == 2, (result.stdout, err) + assert f"in {inside}:" in err, "the refusal names the directory it was given" + assert f"LANES_REPO names handoffs/ inside the checkout {c}, not its top level" in err, err + assert f"LANES_REPO={c} LANES_FILE={c / REGISTER}" in err, "the recovery names both" + assert "Nothing was written." in err, err + assert "git add failed" not in err, err + assert box.snapshot(c) == before, "HEAD, the branch, the index, the tree and origin are as they were" + assert box.w_state() == w_before, "W is untouched" + assert not box.lock_of(c).exists(), "the lock is released" + + +def test_the_top_level_the_refusal_names_carries_the_hand_edit(box: Box): + """The recovery the refusal above prints, followed word for word: the + same `commit` through the top level carries the pending hand edit and + publishes it, and the edit was never lost to the refusal.""" + c = box.seam() + box.hand_edit(c) + refused = box.helper("commit", "the guarded hand edit", LANES_LANE=LANE, + LANES_REPO=str(c / "handoffs"), LANES_FILE=str(c / REGISTER)) + assert refused.returncode == 2, (refused.stdout, refused.stderr) + assert "LIVE · a hand edit" in (c / REGISTER).read_text(encoding="utf-8"), "the edit is still in place" + line = re.search(r"name the checkout itself: (LANES_REPO=\S+ LANES_FILE=\S+)", refused.stderr) + assert line, refused.stderr + named = dict(word.split("=", 1) for word in line.group(1).split()) + again = box.helper("commit", "the guarded hand edit", LANES_LANE=LANE, **named) + assert again.returncode == 0, (again.stdout, again.stderr) + assert "LIVE · a hand edit" in box.origin_register(), "the hand edit is published" + assert box.unpublished(c) == [], "nothing is left unpublished" diff --git a/tests/test_a23_lock.py b/tests/test_a23_lock.py index 262abb5..32ddc79 100644 --- a/tests/test_a23_lock.py +++ b/tests/test_a23_lock.py @@ -229,7 +229,7 @@ def test_holder_refreshes_its_lock_before_every_network_call(box: Box): shim = box.fakebin / "heartbeat-git" shim.write_text(f"""#!/usr/bin/env bash case " $* " in - *" pull "*) python3 -c 'import os,sys,time; t=time.time()-3600; os.utime(sys.argv[1], (t, t))' '{lock}' ;; + *" fetch "*) python3 -c 'import os,sys,time; t=time.time()-3600; os.utime(sys.argv[1], (t, t))' '{lock}' 2>/dev/null ;; *" push "*) python3 -c 'import os,sys,time; print(int(time.time()-os.stat(sys.argv[1]).st_mtime))' '{lock}' >> '{out}' ;; esac exec git "$@" @@ -454,13 +454,15 @@ def test_a_lock_that_vanished_under_its_holder_is_not_made_again(box: Box): box.write(peer, "handoffs/peer.md", "from a peer\n") box.commit(peer, "a peer writes", "handoffs/peer.md") box.git("-C", str(peer), "push", "-q", "origin", "main") - # The lane's checkout is now behind, so its write pulls and then pushes: - # two network calls under the lock, and the lock is taken away between. + # The lane's checkout is now behind, so its write fetches (S3), pushes and + # reads back (S4): three network calls under the lock, and the lock is + # taken away at the push, before the read-back's heartbeat (#198 1b: no + # writer pulls any more). real = shutil.which("git") wrapper = box.root / "takes-the-lock" mark = box.root / "taken" wrapper.write_text(f"""#!/usr/bin/env bash -case " $* " in *" pull "*) +case " $* " in *" push "*) if [ ! -e '{mark}' ]; then : > '{mark}'; rm -f '{lock}/pid'; rmdir '{lock}'; fi ;; esac exec '{real}' "$@" @@ -537,3 +539,132 @@ def test_without_the_opt_in_a_live_holder_is_aged_out_at_ten_minutes(box: Box, l assert " · past a quiet holder |" in box.origin_register() assert not w_lock.exists(), "the writer released the lock it took over" assert not box.lanes_root.exists(), "and no lane checkout or -lanes/ was made" + + +# ----------------------------------------------------- #307's round 2 (360) + +def _ended_within(proc: subprocess.Popen, seconds: float) -> tuple: + """`(rc, out, err, took)` for a process that ends within `seconds`; one + still running then is stopped, and the case fails saying so.""" + started = time.monotonic() + try: + out, err = proc.communicate(timeout=seconds) + except subprocess.TimeoutExpired: + stop(proc) + pytest.fail(f"still waiting {seconds:.0f}s after its lock's directory went: " + "a missing directory was waited on as a held lock") + return proc.returncode, out, err, time.monotonic() - started + + +@pytest.mark.parametrize("how", ["removed", "moved-aside", "its-lanes-directory-removed"]) +def test_a_writer_waiting_on_a_checkout_that_is_removed_refuses_at_once(box: Box, how: str): + """360 (lane openRepoTools-3's review of #308, for #307): lane-end's sweep + removes a CLOSED lane's checkout, and a writer for that lane may be waiting + on the checkout's lock. Its `mkdir` of the lock then fails on a missing + parent, which `mutex_stale` read as a lock naming no holder and over ten + minutes old, and `mutex_steal` could not take: the writer waited out its + minute and exited 4, "another lanes-edit run is active (holder pid + unrecorded)". It now refuses at once, naming the checkout as removed (or, + where only its `lanes/` went, that directory), and writes nothing.""" + lock = box.lock() + c = box.checkout(LANE) + before = box.origin_head() + holder = subprocess.Popen(["sleep", "300"]) + try: + hold(lock, f"{holder.pid} {ME}") + writer = box.popen("set-row-state", LANE, "LIVE · never written") + assert still_waiting(writer), "the writer waits on the held lock" + if how == "removed": + shutil.rmtree(c) + elif how == "moved-aside": + c.rename(c.parent / f".{LANE}.moved-aside") + else: + shutil.rmtree(c / "lanes") + rc, out, err, took = _ended_within(writer, 90) + finally: + holder.kill() + holder.wait() + assert took < 15, f"it refused at once, not after its minute's wait: {took:.0f}s, exit {rc}: {err}" + assert rc == 1, (out, err) + assert "another lanes-edit run is active" not in err, err + assert "unrecorded" not in err, err + if how == "its-lanes-directory-removed": + assert (f"{c / 'lanes'}, where the lock {lock} lives, is gone from the checkout {c}") in err, err + assert f"restore it (git -C {c} checkout HEAD -- {c / 'lanes'})" in err, err + assert not (c / "lanes").exists(), "nothing made it again" + else: + assert f"the checkout {c}, which this write goes through, was removed" in err, err + assert not c.exists(), "nothing made the checkout again" + assert "Nothing was written" in err, err + assert box.origin_head() == before, "nothing was published" + + +def test_a_writer_waiting_to_create_a_checkout_whose_directory_is_removed_refuses_at_once(box: Box): + """360's other lock of the same shape: the creation lock + `-lanes/..lock`, which a writer for a lane with no checkout waits + on while another writer clones it. Where `-lanes/` itself goes, it is + said at once, never as "another writer is creating" the checkout after + the clone's eleven-minute budget.""" + box.lanes_root.mkdir(parents=True) + create = box.lanes_root / f".{LANE}.lock" + before = box.origin_head() + holder = subprocess.Popen(["sleep", "300"]) + try: + hold(create, f"{holder.pid} {ME}") + writer = box.popen("set-row-state", LANE, "LIVE · never written") + assert still_waiting(writer), "the writer waits on the held creation lock" + aside = box.lanes_root.parent / "brett-wip-lanes.moved-aside" + box.lanes_root.rename(aside) + rc, out, err, took = _ended_within(writer, 20) + finally: + holder.kill() + holder.wait() + assert rc == 1, (out, err) + assert took < 15, f"{took:.0f}s" + assert "another writer is creating" not in err, err + assert f"{box.lanes_root}, where this workstation's lane checkouts live, is gone" in err, err + assert "Nothing was written" in err, err + assert not box.lanes_root.exists(), "nothing made it again" + assert not (aside / LANE).exists(), "and nothing was cloned" + assert box.origin_head() == before, "nothing was published" + + +# ----------------------------------------------------- #307's round 3 (701) + +def test_a_sidecar_act_whose_lock_directory_is_moved_aside_names_it_and_a_restore_that_works(box: Box): + """701 (lane openRepoTools-3's re-check of #307's round 2): the + workstation's sidecar lock lives in `-lanes/`, which is no part of + any checkout. Where that folder went while a sidecar act waited on its + lock, W was still there, so the refusal said W's `lanes/` had gone and + gave `git -C checkout HEAD -- /lanes`, which restores nothing. It + now names the folder that went, in `mutex_try`'s words, and its restore: + moved back, the act runs.""" + box.lanes_root.mkdir(parents=True, exist_ok=True) + sidecar = box.lanes_root / ".lane-state.lock" + args = ("set-lane-state", LANE, "RUNNING", "--expect", "none", "--owner", UUID) + written = box.state / LANE / "lane-state.yaml" + aside = box.lanes_root.parent / "brett-wip-lanes.moved-aside" + holder = subprocess.Popen(["sleep", "300"]) + try: + hold(sidecar, f"{holder.pid} {ME}") + waiter = box.popen(*args) + assert still_waiting(waiter), "the act waits on the workstation's sidecar lock" + box.lanes_root.rename(aside) + rc, out, err, took = _ended_within(waiter, 20) + finally: + holder.kill() + holder.wait() + assert rc == 1, (out, err) + assert took < 15, f"{took:.0f}s" + assert f"{box.lanes_root}, where the lock {sidecar} lives, is gone" in err, err + assert f"{box.w / 'lanes'}, where this write's lock lives" not in err, "W's lanes/ is still there" + assert f"checkout HEAD -- {box.w / 'lanes'}" not in err, "git restores nothing that is not in the checkout" + assert f"the checkout {box.w}, which this write goes through, was removed" not in err, "W is still there" + assert f"move it back to {box.lanes_root}" in err, err + assert "Nothing was written" in err, err + assert not written.exists() + assert box.w.is_dir() and (box.w / "lanes").is_dir() + aside.rename(box.lanes_root) # the restore it names + again = box.helper(*args, timeout=45) + assert again.returncode == 0, (again.stdout, again.stderr) + assert written.is_file() diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py new file mode 100644 index 0000000..1115ed3 --- /dev/null +++ b/tests/test_a23_publish_replay.py @@ -0,0 +1,2374 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Amendment 23 S4: after the edit, commit by pathspec, push with no pull, and +read back — and a refused push replays the one edit. + +`commit_publish` commits the write's own pathspecs, pushes `main` with no +pull, and fetches origin to confirm the commit is on it: confirmed is 0 and +`pushed`; a push that went (or timed out) and could not be confirmed is 3, +`pushed, not confirmed: `, never success. A push refused because origin +moved drops this write's commit (only while the branch still holds it, the +only unpublished commit S3 allows) and runs the verb again from the top on origin's new tip, so the +verb's own checks re-run on the new content: `claim`'s rescan (CLAIM-LOST, 7), +the one-match guard, `add-row`'s retirement and duplicate checks. Six attempts +in all; at the bound the commit is kept on HEAD and named (6), and every later +write from that checkout refuses on S3's unpushed row (ratified choices 4 and +5). `commit` carries a hand edit of the register across the fast-forward, and +with nothing of its own is the publish act: exempt from the unpushed row +alone, listing the queue by content before it pushes, and refusing (2) a stack +origin has moved past (Open question 2). `--sweep` is withdrawn (choice 9). + +Origin's moves are made by a `LANES_GIT` hook (`tests/a23_box.py`'s `NET_GIT`) +at the moment of the push, so the race is exact and needs no timing. The +sandbox is the real `lanes-edit.sh`; nothing reaches a real remote. +""" + +from __future__ import annotations + +import os +import re +import shlex +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +from a23_box import REGISTER, RegisterBox, register_text +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 = "repoS4-1" +NEIGHBOUR = "repoS4-2" +NEW_LANE = "repoS4-3" +UUID = "a23b4001-a2a2-4000-8000-a23b4001a2a2" +UUID2 = "a23b4002-a2a2-4000-8000-a23b4002a2a2" +UUID3 = "a23b4003-a2a2-4000-8000-a23b4003a2a2" +OBJ = "opensoft/repoS4#1" +HOME_FLAG = ("--home", "opensoft/repoS4") +ARCHIVE = "lanes/archive/LANES-retired.md" + + +def _row(lane: str, uuid: str = UUID, state: str = "LIVE · seeded") -> str: + return (f"| `{lane}` | harness `{uuid}` | Eagle / test / tester | 2026-10-20T00:00Z " + f"| — | handoffs/repoS4/{lane}.md | {state} |") + + +def _set(state: str) -> str: + """How `set-row-state "LIVE · "` reads in the row: the cell is + stamped, `LIVE · · `, so a case looks for ` · |`.""" + return f" · {state.split(' · ', 1)[1]} |" + + +def _log_head(lane: str) -> str: + return f"# lane {lane} — object log (lane-collision-protocol Amendment 7)\n" + + +class Box(RegisterBox): + """Two adjacent rows, both lanes' logs, and a peer clone that lands commits + on origin at the moment this write pushes.""" + + def __init__(self, root: Path, **kw) -> None: + root = Path(os.path.realpath(root)) + files = { + REGISTER: register_text([_row(LANE), _row(NEIGHBOUR, uuid=UUID2)]), + f"lanes/log/{LANE}.md": _log_head(LANE), + f"lanes/log/{NEIGHBOUR}.md": _log_head(NEIGHBOUR), + "handoffs/README.md": "# handoffs\n", + } + super().__init__(root, files, **kw) + self.peer = self.clone(self.root / "peer") + self.count = self.root / "hook.count" + + # ------------------------------------------------------------ the peer + def peer_prepares(self, rel: str, old: str | None, new: str, message: str) -> None: + """The peer clone commits one change WITHOUT pushing it: the hook + pushes it at the moment this write pushes. `old` None writes `new` + as the whole file.""" + self.git("-C", str(self.peer), "fetch", "-q", "origin") + self.git("-C", str(self.peer), "merge", "-q", "--ff-only", "refs/remotes/origin/main") + path = self.peer / rel + if old is None: + self.write(self.peer, rel, new) + else: + text = path.read_text(encoding="utf-8") + assert text.count(old) == 1, (rel, old) + path.write_text(text.replace(old, new), encoding="utf-8") + self.commit(self.peer, message, rel) + + def race(self, *, times: int = 1, prepared: bool = False) -> None: + """A hook that lands a peer's commit on origin before each of the + first `times` pushes of the helper: the prepared one, or an empty + commit of its own (a moved origin is all a lost race needs).""" + peer = shlex.quote(str(self.peer)) + count = shlex.quote(str(self.count)) + land = (f"git -C {peer} push -q origin main\n" if prepared else + f"git -C {peer} fetch -q origin\n" + f"git -C {peer} merge -q --ff-only refs/remotes/origin/main\n" + f"git -C {peer} commit -q --allow-empty -m \"a peer's commit lands first ($((n + 1)))\"\n" + f"git -C {peer} push -q origin main\n") + self.use_net( + '[ "$1" = push ] || exit 0\n' + f"n=$(cat {count} 2>/dev/null || echo 0)\n" + f'[ "$n" -lt {times} ] || exit 0\n' + f"echo $((n + 1)) > {count}\n" + + land + + "exit 0\n") + + def pushes(self) -> int: + return self.net_ops().count("push") + + def subjects(self) -> list: + return self.git("--git-dir", str(self.origin), "log", "--format=%s", "main").splitlines() + + def head(self, checkout: Path) -> str: + return self.git("-C", str(checkout), "rev-parse", "HEAD").strip() + + def subject(self, checkout: Path, rev: str = "HEAD") -> str: + return self.git("-C", str(checkout), "log", "-1", "--format=%s", rev).strip() + + def hand_edit(self, checkout: Path, lane: str = LANE, state: str = "LIVE · a hand edit") -> None: + path = checkout / REGISTER + text = path.read_text(encoding="utf-8") + uuid = UUID if lane == LANE else UUID2 + old = _row(lane, uuid=uuid) + assert old in text, text + path.write_text(text.replace(old, _row(lane, uuid=uuid, state=state)), encoding="utf-8") + + +@pytest.fixture +def box(tmp_path: Path) -> Box: + return Box(tmp_path) + + +def _settled(box: Box, c: Path) -> None: + """What every write that ended must leave: the lock released and nothing + of a replay's markers behind.""" + assert not box.lock_of(c).exists(), "the lock is released" + + +# ------------------------------------------------------------ the replay + +def test_refused_push_replays_on_a_fresh_fast_forward(box: Box): + """A peer's commit lands at the moment of the push: the push is refused, + the write's commit is dropped, and the verb runs again on origin's new tip. + One commit of the subject reaches origin, made on the new content.""" + c = box.seam() + box.peer_prepares(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2, state="LIVE · the neighbour moved first"), + "a peer's row lands first") + box.race(prepared=True) + before = box.subjects() + result = box.helper("set-row-state", LANE, "LIVE · the replayed write") + assert result.returncode == 0, result.stderr + err = result.stderr + assert "push refused (attempt 1 of 6)" in err, err + assert "pushed origin/main (attempt 2)" in err, err + assert "pushed origin/main (attempt 1)" not in err, "attempt 1 never pushed it" + register = box.origin_register() + assert "LIVE · the neighbour moved first" in register, "the peer's row is kept" + assert _set("LIVE · the replayed write") in register, "the write was made on the new tip" + new = box.subjects()[: len(box.subjects()) - len(before)] + assert len(new) == 2, new + assert new[1] == "a peer's row lands first", new + assert box.head(c) == box.origin_head(), "one commit of this write, and it is origin's tip" + assert box.pushes() == 2 + assert box.unpublished(c) == [] + _settled(box, c) + + +def test_replay_through_a_routed_lane_checkout(box: Box): + """The replay re-runs inside the lane's own checkout, with the lock it + held adopted across the `exec`, and releases it at the end.""" + first = box.helper("set-row-state", LANE, "LIVE · the first write") + assert first.returncode == 0, first.stderr + lc = box.checkout(LANE) + box.race() + result = box.helper("set-row-state", LANE, "LIVE · the routed replay") + assert result.returncode == 0, result.stderr + assert "push refused (attempt 1 of 6)" in result.stderr + assert _set("LIVE · the routed replay") in box.origin_register() + assert box.head(lc) == box.origin_head() + _settled(box, lc) + + +def _rival_claim_hook(box: Box) -> tuple: + """A hook that, at this claim's first push, lands a rival lane's claim on + the same object through a checkout of its own.""" + c2 = box.clone(box.root / "c2") + env = {k: v for k, v in box.env.items() + if k not in ("LANES_GIT", "FAKE_NET_LOG", "FAKE_NET_HOOK")} + env.update({"LANES_REPO": str(c2), "LANES_LANE": NEIGHBOUR, "LANES_SESSION": UUID2}) + rc = box.root / "rival.rc" + out = box.root / "rival.out" + words = " ".join(shlex.quote(f"{k}={v}") for k, v in sorted(env.items())) + cmd = " ".join(shlex.quote(a) for a in box.cmd("claim", OBJ, "--no-github", *HOME_FLAG)) + count = shlex.quote(str(box.count)) + box.use_net( + '[ "$1" = push ] || exit 0\n' + f"[ -e {count} ] && exit 0\n" + f"echo 1 > {count}\n" + f"rc=0; env -i {words} {cmd} > {shlex.quote(str(out))} 2>&1 || rc=$?\n" + f"echo $rc > {shlex.quote(str(rc))}\n" + "exit 0\n") + return rc, out + + +def test_replay_reruns_claim_to_claim_lost_exit_7(box: Box): + """Amendment 7(f) on the replay: a rival's CLAIMED landed first, so the + re-run's rescan finds it before anything else, and this claim is + CLAIM-LOST (7). Its CLAIMED commit was dropped, so origin gets the + CLAIM-LOST line alone: "this one wrote nothing" is true again.""" + c = box.seam() + rc, out = _rival_claim_hook(box) + result = box.helper("claim", OBJ, "--no-github", *HOME_FLAG, + LANES_LANE=LANE, LANES_SESSION=UUID) + assert rc.read_text(encoding="utf-8").strip() == "0", out.read_text(encoding="utf-8") + assert result.returncode == 7, result.stderr + assert "push refused (attempt 1 of 6)" in result.stderr + assert "CLAIM LOST" in result.stderr + mine = box.origin_file(f"lanes/log/{LANE}.md") + assert f"CLAIM-LOST — lane {LANE}" in mine, mine + assert f"CLAIMED — lane {LANE}" not in mine, "the dropped CLAIMED never reached origin" + assert f"CLAIMED — lane {NEIGHBOUR}" in box.origin_file(f"lanes/log/{NEIGHBOUR}.md") + assert box.unpublished(c) == [] + _settled(box, c) + + +def test_replay_reruns_the_one_match_guard(box: Box): + """A peer rewrote this lane's row at the moment of the push, so the text + `replace-in-row` replaces is no longer there. The re-run's own guard + refuses on the new content, with nothing of this write on origin.""" + c = box.seam() + box.peer_prepares(REGISTER, _row(LANE), _row(LANE, state="LIVE · rewritten by a peer"), + "a peer rewrites the row") + box.race(prepared=True) + result = box.helper("replace-in-row", LANE, "seeded", "reseeded", "the guarded replace") + assert result.returncode == 2, result.stderr + assert "push refused (attempt 1 of 6)" in result.stderr + assert "'seeded' occurs 0 times" in result.stderr, result.stderr + register = box.origin_register() + assert "LIVE · rewritten by a peer" in register + assert "reseeded" not in register + assert box.head(c) == box.origin_head(), "the dropped commit is gone and nothing is left ahead" + assert box.unpublished(c) == [] + _settled(box, c) + + +@pytest.mark.parametrize("peer", ["retires-it", "adds-it"]) +def test_add_row_replay_refuses_on_peer_retirement(box: Box, peer: str): + """`add-row`'s checks run before the lock, so a peer's retirement of the + name (Amendment 19(b)) or its own row for it (15(a)) that lands at the + moment of the push is met by the re-run: refused (2), nothing written — + where the old rescan after a `pull --rebase` left two local commits.""" + c = box.seam() + row = _row(NEW_LANE, uuid=UUID3) + if peer == "retires-it": + box.peer_prepares(ARCHIVE, None, + "# retired rows\n\n| lane | session id |\n|---|---|\n" + f"| `{NEW_LANE}` | harness `{UUID3}` |\n", "a peer retires the name") + words = f"lane '{NEW_LANE}' is RETIRED" + else: + box.peer_prepares(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2) + "\n" + row, "a peer adds the row") + words = f"lane '{NEW_LANE}' already has a row" + box.race(prepared=True) + result = box.helper("add-row", row) + assert result.returncode == 2, result.stderr + assert "push refused (attempt 1 of 6)" in result.stderr + assert words in result.stderr, result.stderr + assert box.origin_register().count(f"`{NEW_LANE}`") == (1 if peer == "adds-it" else 0) + assert box.head(c) == box.origin_head() + assert box.unpublished(c) == [] + _settled(box, c) + + +# ------------------------------------------------------------ the bound + +def test_bound_keeps_the_commit_on_head_exit_6_and_next_write_refuses(box: Box): + """Six refused pushes: the sixth does not reset. The commit is kept on + HEAD and named (6), and the next write from that checkout refuses on S3's + unpushed row until the lane publishes or archives it (choice 5).""" + c = box.seam() + box.race(times=99) + result = box.helper("set-row-state", LANE, "LIVE · six times beaten") + assert result.returncode == 6, result.stderr + err = result.stderr + for n in range(1, 6): + assert f"push refused (attempt {n} of 6)" in err, (n, err) + assert "push refused (attempt 6 of 6)" not in err + sha = box.head(c) + assert f"could not push origin/main after six attempts; the commit is local and kept on HEAD: {sha}" in err + assert f"git -C {c} show {sha}" in err + assert f"git -C {c} reset --keep refs/remotes/origin/main" in err, err # 703 + _recovery_is_s63s_archive(err, c, "it is") # 705 + assert box.pushes() == 6 + assert _set("LIVE · six times beaten") in (c / REGISTER).read_text(encoding="utf-8") + assert _set("LIVE · six times beaten") not in box.origin_register() + assert [line.split()[1] for line in box.unpublished(c)] == [sha] + _settled(box, c) + del box.env["FAKE_NET_HOOK"] + after = box.helper("log", "NOTED", f"lane:{LANE}", "a later write", *HOME_FLAG, + LANES_LANE=LANE, LANES_SESSION=UUID) + assert after.returncode == 2, after.stderr + assert "1 unpublished commit(s)" in after.stderr + assert f"+ {sha}" in after.stderr + assert box.head(c) == sha + + +def test_push_refused_with_origin_unmoved_is_kept_exit_6(box: Box): + """A refusal that is no lost race — origin did not move, so a replay would + meet it again: the commit is kept on HEAD and named (6), with one push.""" + c = box.seam() + box.use_net('[ "$1" = push ] && { echo "remote: refusing this push" >&2; exit 1; }\nexit 0\n') + tip = box.origin_head() + result = box.helper("set-row-state", LANE, "LIVE · refused for another reason") + assert result.returncode == 6, result.stderr + sha = box.head(c) + assert f"refused with origin/main unmoved" in result.stderr + assert f"kept on HEAD: {sha}" in result.stderr + assert "push refused (attempt" not in result.stderr, "no replay" + assert box.pushes() == 1 + assert box.origin_head() == tip + _settled(box, c) + + +def _refuse_push_after(box: Box, body: str) -> str: + """A push hook that runs `body` (another writer in this write's + checkout, with no lock) while the push is in flight, then refuses the + push with origin unmoved, as a hook or a protected branch would. Returns + origin's tip, which nothing moves.""" + box.use_net('[ "$1" = push ] || exit 0' + "\n" + body + + 'echo "remote: refusing this push" >&2; exit 1' + "\n") + return box.origin_head() + + +def test_a_push_refused_with_origin_unmoved_names_a_commit_on_top_exit_6(box: Box): + """w2a-c6's note on slice 1b: the refusal with origin unmoved said "the + commit is local and kept on HEAD: " without reading HEAD, the shape + slice 2a's A41 took out of its S4.4. An unlocked writer's stamp committed + on top while the push was in flight then sat at HEAD, and the words named + this write's commit as HEAD. Now HEAD is read first, and `cp_not_head`'s + words name both, kept and neither dropped, with no replay (6).""" + c = box.seam() + cq = shlex.quote(str(c)) + tip = _refuse_push_after(box, + f"printf 'stamp' > {cq}/handoffs/stamp.md" + "\n" + + f"git -C {cq} add -- handoffs/stamp.md" + "\n" + + f"git -C {cq} commit -q -m 'handoff(repoS4-2@Eagle): a stamp from an unlocked writer' -- handoffs/stamp.md" + "\n") + result = box.helper("set-row-state", LANE, "LIVE · refused, a stamp on top") + assert result.returncode == 6, result.stderr + err = result.stderr + assert "refused with origin/main unmoved (no lost race: a replay would meet the same refusal)" in err + assert "is no longer HEAD, so it is not replayed" in err + assert "in this checkout before or while its push was in flight." in err, "no drop is offered as the reason" + assert "kept on HEAD: " not in err, "HEAD is not this write's commit" + assert box.subject(c) == "handoff(repoS4-2@Eagle): a stamp from an unlocked writer" + assert box.subject(c, "HEAD~1").startswith(f"LANES({LANE}@"), "this write's commit is under it" + mine = _mine_named_on_head(box, c, err) + assert mine and all(on for _, on in mine), mine + assert "push refused (attempt" not in err, "no replay" + assert box.pushes() == 1 + assert box.origin_head() == tip + assert len(box.unpublished(c)) == 2, "both kept on HEAD, for the publish act" + assert 'commit "", the publish act' in err and "# the archive, on origin" not in err, \ + "origin is unmoved: the publish act is offered, not S6.3's archive (705)" + _settled(box, c) + + +def test_a_push_refused_with_origin_unmoved_names_a_rewritten_commit_exit_6(box: Box): + """w2a-c6's note, its second case: another writer rewrote this write's + commit while the push was in flight (here an amend, a rewrite that keeps + the tree). The refusal said "kept on HEAD: " of a sha HEAD no longer + holds. Now it says the commit was rewritten, in `cp_rewritten`'s words, + names no commit of this write's as on HEAD, and replays nothing (6).""" + c = box.seam() + cq = shlex.quote(str(c)) + tip = _refuse_push_after(box, + f"git -C {cq} commit -q --amend -m 'an unlocked writer reworded it'" + "\n") + result = box.helper("set-row-state", LANE, "LIVE · refused, then rewritten") + assert result.returncode == 6, result.stderr + err = result.stderr + assert "refused with origin/main unmoved (no lost race: a replay would meet the same refusal)" in err + assert "rewrote this write's commit" in err + assert "kept on HEAD" not in err, "nothing of this write's is on HEAD under its own sha" + named = re.search(r"so it is not replayed: ([0-9a-f]{40}) ", err) + assert named, err + assert box.git("-C", str(c), "log", "-1", "--format=%s", named.group(1)).startswith(f"LANES({LANE}@") + on_head = subprocess.run(["git", "-C", str(c), "merge-base", "--is-ancestor", named.group(1), "HEAD"], + env=box.env, capture_output=True, check=False) + assert on_head.returncode == 1, "the named commit is not on HEAD" + assert box.subject(c) == "an unlocked writer reworded it" + assert "push refused (attempt" not in err, "no replay" + assert box.pushes() == 1 + assert box.origin_head() == tip + _settled(box, c) + + +def test_replay_refused_by_a_change_in_the_way_keeps_the_commit_exit_6(box: Box): + """The drop never moves the tree over an uncommitted change: a hand edit of + the register made while the push was in flight stops the replay, and the + commit is kept on HEAD and named (6), with the edit untouched.""" + c = box.seam() + peer = shlex.quote(str(box.peer)) + reg = shlex.quote(str(c / REGISTER)) + count = shlex.quote(str(box.count)) + box.use_net('[ "$1" = push ] || exit 0\n' + f"[ -e {count} ] && exit 0\n" + f"echo 1 > {count}\n" + f"git -C {peer} commit -q --allow-empty -m \"a peer's commit lands first (1)\"\n" + f"git -C {peer} push -q origin main\n" + f"printf 'a person types here\\n' >> {reg}\n" + "exit 0\n") + result = box.helper("set-row-state", LANE, "LIVE · in the way") + assert result.returncode == 6, result.stderr + sha = box.head(c) + assert "could not be dropped to replay it" in result.stderr + assert f"kept on HEAD: {sha}" in result.stderr + assert (c / REGISTER).read_text(encoding="utf-8").endswith("a person types here\n") + assert box.subject(c) != "a peer's commit lands first (1)" + _settled(box, c) + + +# ------------------------------------------------------------ the read-back + +def test_unconfirmed_push_exits_3(box: Box): + """The push lands and the read-back's fetch fails: 3, `pushed, not + confirmed: `, never success, and the commit kept on HEAD. The next + write's S3 finds it on origin by content and goes on.""" + c = box.seam() + mark = shlex.quote(str(box.root / "pushed")) + box.use_net(f'[ "$1" = push ] && {{ touch {mark}; exit 0; }}\n' + f'[ "$1" = fetch ] && [ -e {mark} ] && {{ echo "fatal: the read-back cannot reach origin" >&2; exit 128; }}\n' + "exit 0\n") + result = box.helper("set-row-state", LANE, "LIVE · sent and not confirmed") + assert result.returncode == 3, result.stderr + sha = box.head(c) + assert f"pushed, not confirmed: {sha}" in result.stderr + assert "the read-back fetch of origin main failed" in result.stderr + assert f"git -C {c} cherry -v refs/remotes/origin/main HEAD" in result.stderr, result.stderr # 703 + assert "pushed origin/main (attempt" not in result.stderr, "never the success line" + assert box.origin_head() == sha, "it did land: exit 3 never says it did not" + del box.env["FAKE_NET_HOOK"] + nxt = box.helper("log", "NOTED", f"lane:{LANE}", "the next write", *HOME_FLAG, + LANES_LANE=LANE, LANES_SESSION=UUID) + assert nxt.returncode == 0, nxt.stderr + _settled(box, c) + + +@pytest.mark.parametrize("landed", [True, False], ids=["landed", "lost"]) +def test_timed_out_push_is_read_back(box: Box, landed: bool): + """A push that times out is not a failure: the read-back decides. Landed + is 0 and confirmed; not landed is 3, naming the timeout — and never a + replay, because a hung push may still land.""" + c = box.seam() + box.env["LANES_GIT_TIMEOUT"] = "4" + land = 'shift; git "$@"\n' if landed else "" + box.use_net('[ "$1" = push ] || exit 0\n' + land + "exec sleep 30 /dev/null 2>&1\n") + result = box.helper("set-row-state", LANE, "LIVE · a hung push") + sha = box.head(c) + if landed: + assert result.returncode == 0, result.stderr + assert "pushed origin/main (attempt 1)" in result.stderr + assert box.origin_head() == sha + else: + assert result.returncode == 3, result.stderr + assert f"pushed, not confirmed: {sha}" in result.stderr + assert "the push timed out after 4s" in result.stderr + assert "origin/main, read back, does not hold it" in result.stderr + assert box.pushes() == 1 + assert "push refused (attempt" not in result.stderr + _settled(box, c) + + +def test_a_push_git_called_failed_that_landed_under_a_peers_commit_is_written_once(box: Box): + """293 of lane openRepoTools-3's review of #307 (its E17): the read-back + after a refused push ("it may have landed", decision 5) had no case, and + its mutant PB-2 (`0) cp_pushed "$cp_sha"; return 0 ;;` made `0) : ;;`) + survived all 80 cases of the replay and #194 modules. A `log NOTED` + whose push lands, a peer's commit lands on top of it at once, and git + still reports the push as failed ("the remote end hung up"): origin holds + this write's commit, so it is pushed (0), and the line is in the + append-only log ONCE. Without the read-back, origin's tip is not under + this write's commit, so it replays, and the line is written twice.""" + c = box.seam() + peer = shlex.quote(str(box.peer)) + box.use_net('if [ "$1" = push ]; then' + "\n" + + ' shift; git "$@" || exit $?' + "\n" + + f" git -C {peer} fetch -q origin" + "\n" + + f" git -C {peer} merge -q --ff-only refs/remotes/origin/main" + "\n" + + f" git -C {peer} commit -q --allow-empty -m \"a peer's commit lands on top at once\"" + "\n" + + f" git -C {peer} push -q origin main" + "\n" + + ' echo "fatal: the remote end hung up unexpectedly" >&2; exit 1' + "\n" + + "fi" + "\n" + "exit 0" + "\n") + text = "a note whose push landed and was called failed" + result = box.helper("log", "NOTED", f"lane:{LANE}", text, *HOME_FLAG, + LANES_LANE=LANE, LANES_SESSION=UUID) + assert result.returncode == 0, result.stderr + assert "pushed origin/main (attempt 1)" in result.stderr + assert "push refused (attempt" not in result.stderr, "it landed, so nothing is replayed" + assert box.origin_file(f"lanes/log/{LANE}.md").count(text) == 1, "the append-only log has it once" + assert box.subjects()[0] == "a peer's commit lands on top at once" + assert box.pushes() == 1 + assert box.unpublished(c) == [] + _settled(box, c) + + +def _another_writer(c: Path, state: str) -> str: + """What an unlocked writer in `c` does while a push is in flight, as the + lines of a hook: a stamp committed on top of this write's commit, or an + amend that rewrites it (a rebase's effect, with the tree kept).""" + cq = shlex.quote(str(c)) + if state == "on-top": + return (f"printf 'stamp' > {cq}/handoffs/stamp.md" + "\n" + + f"git -C {cq} add -- handoffs/stamp.md" + "\n" + + f"git -C {cq} commit -q -m 'handoff(repoS4-2@Eagle): a stamp from an unlocked writer' -- handoffs/stamp.md" + "\n") + return f"git -C {cq} commit -q --amend -m 'an unlocked writer reworded it'" + "\n" + + +@pytest.mark.parametrize("state", ["on-top", "rewritten"]) +def test_an_unconfirmed_push_reads_head_before_it_says_kept_on_head_exit_3(box: Box, state: str): + """rv1b-c3's neighbour of NIT 4, found by w1b-c6 with item 1: the exit-3 + die said "the commit is kept on HEAD" without reading HEAD. Another + writer's stamp committed on top while the push was in flight sat at HEAD, + and an amend took this write's commit off the branch altogether. The push + landed and the read-back's fetch fails, so the number stays 3 (the one + cause the table gives it: pushed, not confirmed), and HEAD is read first: + `cp_not_head`'s words for a commit on top, `cp_rewritten`'s for a rewrite, + each with this refusal's own clause in place of "origin moved".""" + c = box.seam() + mark = shlex.quote(str(box.root / "pushed")) + box.use_net('if [ "$1" = push ]; then\n' + ' shift; git "$@" || exit $?\n' + _another_writer(c, state) + + f" touch {mark}; exit 97\n" + "fi\n" + f'[ "$1" = fetch ] && [ -e {mark} ] && {{ echo "fatal: the read-back cannot reach origin" >&2; exit 128; }}\n' + "exit 0\n") + result = box.helper("set-row-state", LANE, "LIVE · sent, then something moved under it") + assert result.returncode == 3, result.stderr + err = result.stderr + named = re.search(r"pushed, not confirmed: ([0-9a-f]{40}) ", err) + assert named, err + sha = named.group(1) + assert box.origin_head() == sha, "it did land: exit 3 never says it did not" + assert "the read-back fetch of origin main failed" in err + assert "pushed origin/main (attempt" not in err, "never the success line" + assert "the commit is kept on HEAD, and this is never reported as success" not in err, \ + "the old words claim a HEAD nobody read" + assert "push refused (attempt" not in err, "no replay" + on_head = subprocess.run(["git", "-C", str(c), "merge-base", "--is-ancestor", sha, "HEAD"], + env=box.env, capture_output=True, check=False).returncode + if state == "on-top": + assert on_head == 0, "this write's commit is under the stamp" + assert f"HEAD is {box.head(c)}, not this write's commit {sha}" in err, err + assert "in this checkout before or while its push was in flight." in err + assert "it and the commit on top of it are kept on HEAD" in err + assert len(box.unpublished(c)) == 1, "the stamp is not on origin; this write's commit is" + else: + assert on_head == 1, "this write's commit is not on HEAD" + assert "rewrote this write's commit" in err + assert "kept on HEAD" not in err, "nothing of this write's is on HEAD under its own sha" + assert box.pushes() == 1 + _settled(box, c) + + +# ------------------------------------------------------------ `commit`'s carry + +def test_commit_carries_a_hand_edit_across_the_ff(box: Box): + """Choice 9: `commit` is the one writer a pending register hand edit does + not refuse. It sets the edit aside, fast-forwards, applies it again, and + publishes it on origin's tip.""" + c = box.seam() + box.peer_edits(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2, state="LIVE · moved on origin")) + box.hand_edit(c) + result = box.helper("commit", "the hand edit", LANES_LANE=LANE) + assert result.returncode == 0, result.stderr + assert "carried the hand edit of lanes/LANES.md across the fast-forward" in result.stderr + assert "anchored by those lines alone" in result.stderr, "the neighbour's row is the edit's context" + register = box.origin_register() + assert "LIVE · moved on origin" in register + assert "LIVE · a hand edit" in register + assert box.head(c) == box.origin_head() + assert box.git("-C", str(c), "status", "--porcelain").strip() == "" + # 393 of lane openRepoTools-3 (#307): the copy goes once origin has the + # edit; left, it held the checkout in lane-end's sweep for good. + copies = _copies_named(result.stderr) + assert len(copies) == 1, result.stderr + assert f"its copy is removed: {copies[0]}" in result.stderr + assert not Path(copies[0]).exists(), "origin has the edit, so its copy is gone" + assert list((c / ".git" / "lanes-edit").glob("hand-edit-*.diff")) == [] + _settled(box, c) + + +def _copies_named(stderr: str) -> list: + """The hand-edit copies a `commit` saved, as its carry notes name them + (the note's words after the path are not what is tested).""" + return re.findall(r"\(a copy is (\S+\.diff)[ )]", stderr) + + +def test_commit_keeps_the_diff_when_it_no_longer_applies(box: Box): + """The hand edit and origin changed the same row: the edit no longer + applies after the fast-forward, so `commit` refuses (2), names the file + that keeps it, and leaves the register at origin's version.""" + c = box.seam() + box.peer_edits(REGISTER, _row(LANE), _row(LANE, state="LIVE · origin's own edit")) + box.hand_edit(c) + tip = box.origin_head() + result = box.helper("commit", "the hand edit", LANES_LANE=LANE) + assert result.returncode == 2, result.stderr + saved = list((c / ".git" / "lanes-edit").glob("hand-edit-*.diff")) + assert len(saved) == 1 + assert "no longer applies" in result.stderr + assert str(saved[0]) in result.stderr + assert "LIVE · a hand edit" in saved[0].read_text(encoding="utf-8") + assert (c / REGISTER).read_text(encoding="utf-8") == box.origin_register() + assert box.origin_head() == tip, "nothing was pushed" + assert box.git("-C", str(c), "status", "--porcelain").strip() == "" + _settled(box, c) + + +def test_commit_refuses_an_insertion_whose_context_moved(box: Box): + """A hand edit that only INSERTS a line has no line of its own to anchor + it: where origin changed the line beside it, it is refused (2) and kept, + never dropped in at a line number.""" + c = box.seam() + box.peer_edits(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2, state="LIVE · moved on origin")) + path = c / REGISTER + path.write_text(path.read_text(encoding="utf-8").replace( + _row(LANE) + "\n", _row(LANE) + "\n| `repoS4-9` | a line typed in by hand |\n"), encoding="utf-8") + tip = box.origin_head() + result = box.helper("commit", "an inserted line", LANES_LANE=LANE) + assert result.returncode == 2, result.stderr + assert "no longer applies" in result.stderr + assert "anchored by those lines alone" not in result.stderr + assert box.origin_head() == tip + _settled(box, c) + + +TWICE = "LANDING — lane repoS4-7, session x@Eagle, 2026-10-20T00:00:00Z, PR #7 into main" +LANDED_TWICE = "LANDED — lane repoS4-7, session x@Eagle, 2026-10-20T00:10:00Z, PR #7 → 9bf26db" +EDITED = "LANDED — the second copy, edited by hand" + + +@pytest.mark.parametrize("dup", ["a-repeated-line", "a-blank-line", + "a-copy-a-peer-changed", "the-registers-own-shape", + "the-registers-own-shape-staged"]) +def test_commit_refuses_a_hand_edit_whose_line_occurs_twice_on_origin(box: Box, dup: str): + """292 of lane openRepoTools-3's review of #307, its answer to Q3: B22 + retried a failed carry with `git apply -C0` wherever every hunk removes + a line, and `-C0` drops the context and edits the first copy of that line + it finds. A hand edit of the SECOND of two identical lines, with origin + changing the line after it, was pushed onto the first copy (0), "anchored + by those lines alone"; a replaced blank line, of which the register has + dozens, the same. Now `-C0` is tried only where every line the edit + replaces occurs once in origin's version; otherwise the edit is refused + (2), kept in its file and named, and the register is left at origin's. + + 341 of lane 3's re-check of #307: the count was asked of origin's version + alone. A line twice in the version the edit was made on is once on origin + where a peer changed or removed the copy the person edited, and `-C0` then + edited the copy nobody touched: a correction made after a peer changed + that copy left both values on origin (0), where decision 9 refuses; and + the register's own shape, a LANDING then two identical LANDED lines, each + side removing one LANDED, kept the LANDING with no LANDED (0), which + reopens Rule 6's hold. The line must now occur once in both versions. + + 382 of lane 3's re-check at `15e71d9`: the version the edit was made on is + HEAD's blob, the same version the kept diff is taken against (`diff + HEAD`), never the index's. A hand edit that is also STAGED is the same + edit: read from the index, the base would be the edited version, the + LANDED line would count once there, and the register's own shape would + be pushed onto the wrong copy again (`-staged`).""" + line = "" if dup == "a-blank-line" else TWICE + after = "| `repoS4-8` | the line after the second copy |" + if dup.startswith("the-registers-own-shape"): + block = TWICE + "\n" + LANDED_TWICE + "\n" + LANDED_TWICE + "\n" + after + else: + block = line + "\n" + "| `repoS4-6` | a line between the copies |" + "\n" + line + "\n" + after + box.peer_edits(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2) + "\n" + block, "the same line twice") + c = box.seam() + path = c / REGISTER + text = path.read_text(encoding="utf-8") + if dup.startswith("the-registers-own-shape"): + second = text.rindex("\n" + LANDED_TWICE + "\n" + after) + 1 + path.write_text(text[:second] + text[second + len(LANDED_TWICE) + 1:], encoding="utf-8") + if dup.endswith("-staged"): # the diff is HEAD's, not the index's: a staged edit is the same edit + box.git("-C", str(c), "add", "--", REGISTER) + box.peer_edits(REGISTER, LANDED_TWICE + "\n" + LANDED_TWICE + "\n" + after, + LANDED_TWICE + "\n" + after, "a peer removes one LANDED line too") + kept = "\n-" + LANDED_TWICE + "\n" + else: + second = text.rindex("\n" + line + "\n" + after) + 1 + path.write_text(text[:second] + EDITED + text[second + len(line):], encoding="utf-8") + if dup == "a-copy-a-peer-changed": + box.peer_edits(REGISTER, "\n" + line + "\n" + after, + "\nLANDED — the second copy, as a peer changed it\n" + after, + "origin changes the second copy itself") + else: + box.peer_edits(REGISTER, after, "| `repoS4-8` | the line after it, changed on origin |", + "origin changes the line after the second copy") + kept = EDITED + tip = box.origin_head() + result = box.helper("commit", "the second copy, by hand", LANES_LANE=LANE) + assert result.returncode == 2, (result.stderr, box.origin_register()) + err = result.stderr + assert "no longer applies" in err + assert "occurs more than once" in err, err + assert "anchored by those lines alone" not in err + saved = list((c / ".git" / "lanes-edit").glob("hand-edit-*.diff")) + assert len(saved) == 1 and str(saved[0]) in err, "the edit is kept and named" + assert kept in saved[0].read_text(encoding="utf-8") + assert (c / REGISTER).read_text(encoding="utf-8") == box.origin_register() + assert box.origin_head() == tip, "nothing was pushed" + assert box.git("-C", str(c), "status", "--porcelain").strip() == "" + _settled(box, c) + + +def test_commit_replay_keeps_the_hand_edit(box: Box): + """`commit`'s edit is the hand edit, which no argv makes again: a refused + push gives it back to the working tree, and the re-run carries it onto + origin's new tip — it is never dropped with the commit.""" + c = box.seam() + box.peer_prepares(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2, state="LIVE · landed mid-push"), + "a peer's row lands first") + box.race(prepared=True) + box.hand_edit(c) + result = box.helper("commit", "the hand edit", LANES_LANE=LANE) + assert result.returncode == 0, result.stderr + assert "left in the working tree as the hand edit it was" in result.stderr + register = box.origin_register() + assert "LIVE · landed mid-push" in register + assert "LIVE · a hand edit" in register + assert box.head(c) == box.origin_head() + assert box.git("-C", str(c), "status", "--porcelain").strip() == "" + _settled(box, c) + + +def _one_second_for_the_copies(box: Box) -> None: + """704: a `date` first on PATH that answers the copies' own format with + one fixed second, and hands every other call to the real `date`.""" + real = shutil.which("date", path=box.env["PATH"].split(os.pathsep, 1)[1]) + assert real, "a real date" + shim = box.fakebin / "date" + shim.write_text("#!/usr/bin/env bash\n" + '[ "$*" = "-u +%Y%m%dT%H%M%SZ" ] && { echo 20261023T120000Z; exit 0; }\n' + f'exec {shlex.quote(real)} "$@"\n', encoding="utf-8") + shim.chmod(0o755) + + +@pytest.mark.parametrize("clock", ["unforced", "same-second"]) +def test_a_lost_races_two_copies_of_the_hand_edit_are_both_removed_once_origin_has_it(box: Box, clock: str): + """393 of lane openRepoTools-3 (#307): a `commit` that loses one push race + replays its carry under the same pid, and the replay saves the hand edit + again, so one run leaves two copies while `CS_CARRY_DIFF` names only the + last. Every copy the run wrote is recorded, the record crosses the + replay's `exec`, and both go once the read-back finds the edit on origin. + + 704 of lane 3's re-check of round 2: a copy's name carries the time to + the second, so a replay in the same second gives both copies one name: + one file, written twice and removed once origin has the edit, which is + right. The test asserted two distinct names; it asserts on the removal + notes now, and [same-second] gives both runs one second with a `date` + shim, which the replay's three-second pause hides unforced.""" + c = box.seam() + if clock == "same-second": + _one_second_for_the_copies(box) + box.peer_prepares(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2, state="LIVE · landed mid-push"), + "a peer's row lands first") + box.race(prepared=True) + box.hand_edit(c) + result = box.helper("commit", "the hand edit", LANES_LANE=LANE) + assert result.returncode == 0, result.stderr + assert "push refused (attempt 1 of 6)" in result.stderr, "the race was lost once" + copies = _copies_named(result.stderr) + assert len(copies) == 2, ("attempt 1 and its replay each saved a copy", result.stderr) + if clock == "same-second": + assert len(set(copies)) == 1, ("the shim gave both one second", copies) + removed = re.findall(r"its copy is removed: (\S+\.diff)", result.stderr) + assert sorted(removed) == sorted(copies), ("one removal note per copy recorded", removed, copies) + for copy in copies: + assert not Path(copy).exists(), copy + assert list((c / ".git" / "lanes-edit").glob("hand-edit-*.diff")) == [] + assert "LIVE · a hand edit" in box.origin_register() + _settled(box, c) + + +def test_a_lost_races_refused_re_run_carry_names_every_copy_it_keeps(box: Box): + """707 of lane openRepoTools-3's re-check of #307's round 2, the small + follow-up the body disclosed: a `commit` that loses a push race replays + its carry, which saves the hand edit again; where the peer that won + changed the same row, the re-run's carry no longer applies, and the + refusal (2) kept both copies and named only the last. It names each copy + once now, by the names the carry notes printed, so it holds for one name + or two (704: a replay in the same second reuses the name).""" + c = box.seam() + box.peer_prepares(REGISTER, _row(LANE), _row(LANE, state="LIVE · the peer's own edit of this row"), + "a peer edits the same row and lands first") + box.race(prepared=True) + box.hand_edit(c) + result = box.helper("commit", "the hand edit", LANES_LANE=LANE) + err = result.stderr + assert result.returncode == 2, err + assert "push refused (attempt 1 of 6)" in err, "the race was lost once" + refusal = [l for l in err.splitlines() if "no longer applies" in l] + assert len(refusal) == 1, err + copies = set(_copies_named(err)) | {str(p) for p in (c / ".git" / "lanes-edit").glob("hand-edit-*.diff")} + assert copies, err + for copy in copies: + assert Path(copy).is_file(), ("every copy is kept", copy) + assert refusal[0].count(copy) == 1, ("the refusal names each copy once", copy, refusal[0]) + assert "LIVE · a hand edit" not in box.origin_register(), "nothing of the hand edit was pushed" + _settled(box, c) + + +def test_a_carry_removes_only_the_copies_it_wrote_never_another_runs_under_the_same_pid(box: Box): + """393's trap: pids recur, so an older run's KEPT copy (an edit that did + not carry, a person's only copy of it) can bear this run's pid. A glob + on the pid, or on every copy, would delete it with this run's. Two kept + copies are there when this run carries: one under another pid, and one + written under this run's own pid by a `git` first on PATH at the carry's + own `diff` (its parent is this run). Only this run's copy goes.""" + c = box.seam() + kept = c / ".git" / "lanes-edit" + kept.mkdir(parents=True, exist_ok=True) + (kept / "hand-edit-20261001T000000Z-1.diff").write_text("a kept hand edit of another run\n", encoding="utf-8") + plant = (f" printf 'a kept hand edit of an older run under this pid' > " + f"{shlex.quote(str(kept))}/hand-edit-20261001T000000Z-$PPID.diff" + "\n") + env = _git_shim(box, '*" --no-pager diff --binary HEAD "*', before=plant) + box.hand_edit(c) + result = box.helper("commit", "the hand edit", LANES_LANE=LANE, **env) + assert (box.root / "fired").exists(), "the older run's copy was planted under this run's pid" + assert result.returncode == 0, result.stderr + copies = _copies_named(result.stderr) + assert len(copies) == 1, result.stderr + pid = re.fullmatch(r".*/hand-edit-\d{8}T\d{6}Z-(\d+)\.diff", copies[0]) + assert pid, copies[0] + assert not Path(copies[0]).exists(), "this run's copy is gone" + assert sorted(p.name for p in kept.glob("hand-edit-*.diff")) == \ + sorted(["hand-edit-20261001T000000Z-1.diff", f"hand-edit-20261001T000000Z-{pid.group(1)}.diff"]), \ + "both older copies stay, the one under this run's pid included" + assert "LIVE · a hand edit" in box.origin_register() + _settled(box, c) + + +# ------------------------------------------------------------ the publish act + +def _queue(box: Box, c: Path, n: int = 1) -> list: + """`n` register and handoff commits made here and never pushed.""" + shas = [] + for i in range(n): + if i == 0: + box.hand_edit(c, state=f"LIVE · queued {i}") + box.commit(c, f"LANES({LANE}@Eagle): queued {i}", REGISTER) + else: + box.write(c, f"handoffs/repoS4/q{i}.md", f"queued {i}\n") + box.commit(c, f"LANES({LANE}@Eagle): queued {i}", f"handoffs/repoS4/q{i}.md") + shas.append(box.head(c)) + return shas + + +def _detached(box: Box, c: Path): + box.git("-C", str(c), "checkout", "-q", "--detach") + return {}, "HEAD is detached, not refs/heads/main" + + +def _fetch_fails(box: Box, c: Path): + box.use_net('[ "$1" = fetch ] && { echo "fatal: unable to access origin" >&2; exit 128; }\nexit 0\n') + return {}, "the fetch of origin main failed" + + +def _no_fetch(box: Box, c: Path): + return {"LANES_NO_FETCH": "1"}, "LANES_NO_FETCH=1 is set" + + +def _cherry_pick(box: Box, c: Path): + (c / ".git" / "CHERRY_PICK_HEAD").write_text(box.head(c) + "\n", encoding="utf-8") + return {}, "a cherry-pick is in progress" + + +PUBLISH_ROWS = {"detached": _detached, "fetch-fails": _fetch_fails, + "no-fetch": _no_fetch, "cherry-pick": _cherry_pick} + + +@pytest.mark.parametrize("row", [None, *PUBLISH_ROWS], ids=["unpushed-only", *PUBLISH_ROWS]) +def test_publish_act_is_exempt_from_unpushed_only(box: Box, row): + """`commit` with nothing of its own publishes the queue S3 would refuse a + write over (#194's remedy 1). That is its ONE exemption: every other row + still refuses it, with nothing pushed.""" + c = box.seam() + shas = _queue(box, c) + extra, words = ({}, None) if row is None else PUBLISH_ROWS[row](box, c) + tip = box.origin_head() + result = box.helper("commit", "publish the queue", LANES_LANE=LANE, **extra) + if row is None: + assert result.returncode == 0, result.stderr + assert box.origin_head() == shas[-1] + assert "pushed origin/main (attempt 1)" in result.stderr + assert box.unpublished(c) == [] + else: + assert result.returncode == 2, result.stderr + assert words in result.stderr + assert "Nothing was written." in result.stderr + assert box.origin_head() == tip + _settled(box, c) + + +def test_publish_act_refused_by_staged_entry(box: Box): + """A staged entry the publish did not make refuses it (S3's staged row): + a queue is never published with someone's half-made commit beside it.""" + c = box.seam() + _queue(box, c) + box.write(c, "handoffs/README.md", "# handoffs\na peer's staged line\n") + box.git("-C", str(c), "add", "--", "handoffs/README.md") + before = box.snapshot(c) + result = box.helper("commit", "publish the queue", LANES_LANE=LANE) + assert result.returncode == 2, result.stderr + assert "staged entries this write did not make: handoffs/README.md" in result.stderr + assert box.snapshot(c) == before + _settled(box, c) + + +def test_publish_act_lists_cherry_before_push(box: Box): + """The queue is listed by content — `git cherry -v`'s `+` lines — before + anything is pushed, so the person sees what is being published.""" + c = box.seam() + shas = _queue(box, c, n=2) + result = box.helper("commit", "publish the queue", LANES_LANE=LANE) + assert result.returncode == 0, result.stderr + err = result.stderr + listed = err.index("the 2 commit(s) origin/main lacks by content, listed before anything is pushed") + pushing = err.index("pushing the 2 local commit(s) origin/main does not have") + for i, sha in enumerate(shas): + at = err.index(f"+ {sha} LANES({LANE}@Eagle): queued {i}") + assert listed < at < pushing, (i, err) + assert box.origin_head() == shas[-1] + _settled(box, c) + + +def test_publish_act_on_moved_origin_refuses(box: Box): + """Open question 2: the publish act makes only a fast-forward push. Where + origin has moved past the stack it refuses (2), naming S6.3's archive and + then `reset --keep`, and pushes nothing.""" + c = box.seam() + box.use_net() + shas = _queue(box, c) + box.peer_pushes("handoffs/repoS4/elsewhere.md", "a peer's file\n") + tip = box.origin_head() + result = box.helper("commit", "publish the queue", LANES_LANE=LANE) + assert result.returncode == 2, result.stderr + err = result.stderr + assert "origin/main has moved past the local commits" in err + assert "only a fast-forward push" in err + assert "refs/heads/archive/" in err + assert f"git -C {c} reset --keep refs/remotes/origin/main" in err + assert "push" not in box.net_ops() + assert box.origin_head() == tip + assert box.head(c) == shas[-1] + _settled(box, c) + + +def test_the_publish_acts_moved_origin_recovery_names_the_lane_and_stops_first(box: Box): + """NIT 294 of lane openRepoTools-3's review of #307 (its E13), and its + answer to Q2: the publish act's refusal over a stack origin has moved + past printed a literal `archive/-stranded-…` where S3's own refusal + fills the lane in, and it lacked the line S3, S6.3 and the manual give + before the archive: if any `+` line is not your own write, STOP. Now the + lane is filled in, the list and the stop line come first, and the reset + names `refs/remotes/origin/main` in full.""" + c = box.seam() + box.use_net() + _queue(box, c) + box.peer_pushes("handoffs/repoS4/elsewhere.md", "a peer's file" + "\n") + result = box.helper("commit", "publish the queue", LANES_LANE=LANE) + assert result.returncode == 2, result.stderr + err = result.stderr + assert "archive/-stranded-" not in err, "the lane is filled in" + stop = err.find("if any '+' line is not your own write, STOP and take it to the lane its subject names") + archive = err.find(f"git -C {c} push origin HEAD:refs/heads/archive/{LANE}-stranded-") + reset = err.find(f"git -C {c} reset --keep refs/remotes/origin/main") + assert 0 <= stop < archive < reset, err + assert "push" not in box.net_ops() + _settled(box, c) + + +def test_the_publish_acts_list_line_reads_origin_past_a_local_branch_named_origin_main(box: Box): + """333 of lane openRepoTools-3's re-check of #307: the publish act's + list line said `cherry -v origin/main HEAD`, and with a local branch + named `origin/main` that short name is the branch, so the line listed 0 + `+` lines where origin lacks the whole queue, and the STOP check it + exists for read nothing. It names `refs/remotes/origin/main` now.""" + c = box.seam() + box.use_net() + shas = _queue(box, c, 2) + box.git("-C", str(c), "branch", "origin/main", "HEAD") # the shadow + box.peer_pushes("handoffs/repoS4/elsewhere.md", "a peer's file" + "\n") + result = box.helper("commit", "publish the queue", LANES_LANE=LANE) + err = result.stderr + assert result.returncode == 2, err + listed = re.search(r"list them: git (-C \S+ cherry -v \S+ HEAD);", err) + assert listed, err + out = box.git(*shlex.split(listed.group(1))) + assert [l.split()[1] for l in out.splitlines() if l.startswith("+ ")] == shas, (out, err) + assert "push" not in box.net_ops() + _settled(box, c) + + +@pytest.mark.parametrize("state", ["head-is-the-tip", "on-top", "rewritten"]) +def test_a_publish_act_push_refused_with_origin_unmoved_reads_head_exit_6(box: Box, state: str): + """w1b-c6's second find, with item 1: the publish act's refusal with + origin unmoved (a hook, a permission, a protected branch) said "they are + kept on HEAD" without reading HEAD, the shape 638b13a took out of + `commit_publish`'s. Where HEAD is still the commit the fence read, the + words are what they were. Where an unlocked writer's stamp is on top, or + an amend took the queue's tip off the branch while the push was in + flight, `cp_not_head`'s and `cp_rewritten`'s words answer, with this + refusal's own clause (6). Nothing is pushed twice, and nothing replayed.""" + c = box.seam() + shas = _queue(box, c, n=2) + tip = _refuse_push_after(box, "" if state == "head-is-the-tip" else _another_writer(c, state)) + result = box.helper("commit", "publish the queue", LANES_LANE=LANE) + assert result.returncode == 6, result.stderr + err = result.stderr + assert "failed with origin/main unmoved" in err + assert "push refused (attempt" not in err, "no replay: there is no edit to repeat" + assert box.pushes() == 1 + assert box.origin_head() == tip + if state == "head-is-the-tip": + assert "they are kept on HEAD, and nothing of them reached origin by this push" in err + assert f"Look: git -C {c} cherry -v refs/remotes/origin/main HEAD" in err, err # 703 + assert "is no longer HEAD" not in err and "rewrote" not in err + assert box.head(c) == shas[-1] + assert len(box.unpublished(c)) == 2 + elif state == "on-top": + assert f"HEAD is {box.head(c)}, not this write's commit {shas[-1]}" in err, err + assert "it and the commit on top of it are kept on HEAD" in err + assert "they are kept on HEAD, and nothing of them reached origin" not in err + assert len(box.unpublished(c)) == 3, "the queue's two and the stamp, all kept for the publish act" + else: + assert "rewrote this write's commit" in err + assert "kept on HEAD" not in err, "the queue's tip is not on HEAD under its own sha" + on_head = subprocess.run(["git", "-C", str(c), "merge-base", "--is-ancestor", shas[-1], "HEAD"], + env=box.env, capture_output=True, check=False).returncode + assert on_head == 1, "the commit the words name is not on HEAD" + assert shas[-1] in err + _settled(box, c) + + +# ------------------------------------------------------------ --sweep + +@pytest.mark.parametrize("argv", [ + ("set-row-state", LANE, "LIVE · swept"), + ("log", "NOTED", f"lane:{LANE}", "swept", *HOME_FLAG), + ("commit", "swept"), +], ids=["set-row-state", "log", "commit"]) +def test_sweep_flag_is_refused(box: Box, argv): + """Choice 9 withdraws `--sweep`: a pending register edit is committed by + its own act and never swept into another write. Refused (2) before + anything is read or fetched.""" + c = box.seam() + box.use_net() + box.hand_edit(c) + before = box.snapshot(c) + result = box.helper("--sweep", *argv, LANES_LANE=LANE, LANES_SESSION=UUID) + assert result.returncode == 2, result.stderr + assert "--sweep is withdrawn" in result.stderr + assert box.snapshot(c) == before + assert box.net_ops() == [], "refused before any network call" + _settled(box, c) + + +def test_no_sweep_is_still_accepted(box: Box): + """`--no-sweep`, the old default, changes nothing.""" + c = box.seam() + result = box.helper("--no-sweep", "set-row-state", LANE, "LIVE · not swept") + assert result.returncode == 0, result.stderr + assert _set("LIVE · not swept") in box.origin_register() + _settled(box, c) + + +# ------------------------------------- the window before S3 (rv1b's pre-review) + +S3_REFSPEC = "+refs/heads/main:refs/remotes/origin/main" + + +def _at_s3_fetch(box: Box, body: str) -> None: + """Run `body` once, at the helper's first fetch with S3's own refspec: + after the verb's checks before the lock, and before S3's fetch under it.""" + count = shlex.quote(str(box.root / "s3hook.count")) + box.use_net( + '[ "$1" = fetch ] || exit 0' + "\n" + + f'case "$*" in *"{S3_REFSPEC}"*) : ;; *) exit 0 ;; esac' + "\n" + + f"[ -e {count} ] && exit 0" + "\n" + + f"echo 1 > {count}" + "\n" + body + "\n" + "exit 0" + "\n") + + +def test_a_rival_claim_landing_before_S3s_fetch_loses_exit_7(box: Box): + """MUST 1 of rv1b's pre-review: `claim`'s checks run before the lock on + what `log_sync` fetched, and a rival's CLAIMED can land between that + fetch and S3's. S3 then fast-forwarded onto it and this claim was pushed + on top, so both lanes held the object. Now a fast-forward that moved + origin's tip under this write's checks runs the verb again from the top + on the new tip, with the lock held, and the replay's rescan finds the + rival first: CLAIM-LOST (7), and one holder.""" + box.seam() + c2 = box.clone(box.root / "c2") + env = {k: v for k, v in box.env.items() if k not in ("LANES_GIT", "FAKE_NET_LOG", "FAKE_NET_HOOK")} + env.update({"LANES_REPO": str(c2), "LANES_LANE": NEIGHBOUR, "LANES_SESSION": UUID2}) + words = " ".join(shlex.quote(f"{k}={v}") for k, v in sorted(env.items())) + rival = " ".join(shlex.quote(a) for a in box.cmd("claim", OBJ, "--no-github", *HOME_FLAG)) + out = shlex.quote(str(box.root / "rival.out")) + _at_s3_fetch(box, f"env -i {words} {rival} > {out} 2>&1; echo $? > {out}.rc") + result = box.helper("claim", OBJ, "--no-github", *HOME_FLAG, LANES_LANE=LANE, LANES_SESSION=UUID) + assert (box.root / "rival.out.rc").read_text(encoding="utf-8").strip() == "0", \ + (box.root / "rival.out").read_text(encoding="utf-8") + assert result.returncode == 7, result.stderr + assert "moved while this write's checks ran" in result.stderr + assert f"another lane's claim landed on main before this one: @{NEIGHBOUR}" in result.stderr + mine = box.origin_file(f"lanes/log/{LANE}.md") + assert f"CLAIMED — lane {LANE}" not in mine, "this lane never holds it" + assert f"CLAIM-LOST — lane {LANE}" in mine + assert f"CLAIMED — lane {NEIGHBOUR}" in box.origin_file(f"lanes/log/{NEIGHBOUR}.md") + + +@pytest.mark.parametrize("peer", ["retires-it", "adds-it"]) +def test_an_add_row_whose_name_lands_before_S3s_fetch_refuses(box: Box, peer: str): + """MUST 2 of rv1b's pre-review: `add-row`'s retirement and duplicate + checks run before the lock. A peer's retirement of the name, or its own + row for it, landing before S3's fetch was fast-forwarded in and the row + pushed on top, at attempt 1. The re-run on the new tip refuses (2), and + nothing is written.""" + c = box.seam() + row = _row(NEW_LANE, uuid=UUID3) + if peer == "retires-it": + box.peer_prepares(ARCHIVE, None, + "# retired rows" + "\n" + "\n" + "| lane | session id |" + "\n" + "|---|---|" + "\n" + + f"| `{NEW_LANE}` | harness `{UUID3}` |" + "\n", "a peer retires the name") + words = f"lane '{NEW_LANE}' is RETIRED" + else: + box.peer_prepares(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2) + "\n" + row, "a peer adds the row") + words = f"lane '{NEW_LANE}' already has a row" + _at_s3_fetch(box, f"git -C {shlex.quote(str(box.peer))} push -q origin main") + result = box.helper("add-row", row) + assert result.returncode == 2, result.stderr + assert "moved while this write's checks ran" in result.stderr + assert words in result.stderr, result.stderr + assert box.origin_register().count(f"`{NEW_LANE}`") == (1 if peer == "adds-it" else 0) + assert "push" not in box.net_ops(), "refused before any push" + assert box.unpublished(c) == [] + _settled(box, c) + + +def test_a_commit_made_on_top_before_the_replay_is_kept_exit_6(box: Box): + """MUST 4 of rv1b's pre-review: until slices 2b and 3, lane-start's + stamp, lane-handoff's commit and the skill's commit are made in the same + checkout with no lock. One landing on top of this write's commit while its + push is in flight was dropped by the replay's `reset --keep`, with exit 0 + and nothing said. Now the replay asks HEAD first: both commits are kept + on HEAD and named (6), and nothing is dropped.""" + c = box.seam() + peer = shlex.quote(str(box.peer)) + cq = shlex.quote(str(c)) + count = shlex.quote(str(box.root / "push.count")) + box.use_net( + '[ "$1" = push ] || exit 0' + "\n" + + f"[ -e {count} ] && exit 0" + "\n" + + f"echo 1 > {count}" + "\n" + + f"git -C {peer} commit -q --allow-empty -m 'a peer lands first'" + "\n" + + f"git -C {peer} push -q origin main" + "\n" + + f"printf 'stamp' > {cq}/handoffs/stamp.md" + "\n" + + f"git -C {cq} add -- handoffs/stamp.md" + "\n" + + f"git -C {cq} commit -q -m 'handoff(repoS4-2@Eagle): a stamp from an unlocked writer' -- handoffs/stamp.md" + "\n" + + "exit 0" + "\n") + result = box.helper("set-row-state", LANE, "LIVE · the write under it") + assert result.returncode == 6, result.stderr + assert "is no longer HEAD, so it is not replayed" in result.stderr + assert "in this checkout before or while its push was in flight" in result.stderr, "the HEAD check fired, not the drop's" + _recovery_is_s63s_archive(result.stderr, c, "they are") # 705 + assert "push refused (attempt 1 of 6)" not in result.stderr, "no reset was made" + assert box.subject(c) == "handoff(repoS4-2@Eagle): a stamp from an unlocked writer" + assert box.subject(c, "HEAD~1").startswith(f"LANES({LANE}@"), "this write's commit is under it" + assert (c / "handoffs" / "stamp.md").is_file(), "the other writer's file is still there" + assert len(box.unpublished(c)) == 2, "both kept on HEAD, for the publish act" + _settled(box, c) + + +def test_a_commit_made_on_top_at_the_sixth_push_is_named_before_the_bound_exit_6(box: Box): + """m1b-c2's mutant B04 (the replay's HEAD check removed) survived every + case, because the drop's compare-and-swap refuses a commit on top as + well, in other words. The HEAD check comes before the six-attempt bound, + so a commit that lands on top during the sixth push is named as not this + write's, and is never folded into the bound's "kept on HEAD".""" + c = box.seam() + peer = shlex.quote(str(box.peer)) + cq = shlex.quote(str(c)) + count = shlex.quote(str(box.root / "push.count")) + box.use_net( + '[ "$1" = push ] || exit 0' + "\n" + + f"n=$(cat {count} 2>/dev/null || echo 0)" + "\n" + + f"echo $((n + 1)) > {count}" + "\n" + + f"git -C {peer} fetch -q origin" + "\n" + + f"git -C {peer} merge -q --ff-only refs/remotes/origin/main" + "\n" + + f"git -C {peer} commit -q --allow-empty -m \"a peer's commit lands first ($((n + 1)))\"" + "\n" + + f"git -C {peer} push -q origin main" + "\n" + + '[ "$n" = 5 ] || exit 0' + "\n" + + f"printf 'stamp' > {cq}/handoffs/stamp.md" + "\n" + + f"git -C {cq} add -- handoffs/stamp.md" + "\n" + + f"git -C {cq} commit -q -m 'handoff(repoS4-2@Eagle): a stamp from an unlocked writer' -- handoffs/stamp.md" + "\n" + + "exit 0" + "\n") + result = box.helper("set-row-state", LANE, "LIVE · beaten five times, then a stamp on top") + assert result.returncode == 6, result.stderr + assert "push refused (attempt 5 of 6)" in result.stderr + assert "is no longer HEAD, so it is not replayed" in result.stderr + assert "after six attempts" not in result.stderr, "the HEAD check comes before the bound" + assert box.subject(c) == "handoff(repoS4-2@Eagle): a stamp from an unlocked writer" + assert len(box.unpublished(c)) == 2, "both kept on HEAD, for the publish act" + _settled(box, c) + + +# ------------------------ one checkout, several writers (rv1b-c's re-check) + +def _spawn(box: Box, name: str, *args: str, **extra: str) -> subprocess.Popen: + """The helper in the background, its output in `.out` and + `.err` under the box (files, so no pipe can fill and stall it).""" + out = (box.root / f"{name}.out").open("w", encoding="utf-8") + err = (box.root / f"{name}.err").open("w", encoding="utf-8") + try: + return subprocess.Popen(box.cmd(*args), env=dict(box.env, **extra), cwd=str(box.root), + stdout=out, stderr=err, text=True) + finally: + out.close() + err.close() + + +def _err(box: Box, name: str) -> str: + return (box.root / f"{name}.err").read_text(encoding="utf-8") + + +def _wait_for(pred, what: str, secs: float = 60.0) -> None: + end = time.monotonic() + secs + while time.monotonic() < end: + if pred(): + return + time.sleep(0.05) + raise AssertionError(f"timed out waiting for {what}") + + +def _waits_on_the_lock(pid: int) -> bool: + """True while process `pid` is in `mutex_try`'s wait for the checkout's + lock: a `sleep` it started is running. Nothing a write does before the + lock starts one, so its own checks have all run.""" + for stat in Path("/proc").glob("[0-9]*/stat"): + try: + text = stat.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + comm = text[text.find("(") + 1:text.rfind(")")] + fields = text[text.rfind(")") + 2:].split() + if comm == "sleep" and len(fields) > 1 and fields[1] == str(pid): + return True + return False + + +def _another_write_holds_the_lock(box: Box, c: Path, gate: Path) -> subprocess.Popen: + """P3, any other write through the same checkout: it takes the lock and + holds it at its push until the case writes `gate`.""" + hook = box.fakebin / "hold-at-push" + hook.write_text( + "#!/usr/bin/env bash" + "\n" + + '[ "$1" = push ] || exit 0' + "\n" + + f"i=0; while [ ! -e {shlex.quote(str(gate))} ] && [ $i -lt 500 ]; do sleep 0.1; i=$((i + 1)); done" + "\n" + + "exit 0" + "\n", encoding="utf-8") + hook.chmod(0o755) + p3 = _spawn(box, "p3", "set-row-state", LANE, "LIVE · another write in this checkout", + FAKE_NET_HOOK=str(hook)) + _wait_for(lambda: box.lock_of(c).exists(), "P3 to take the checkout's lock") + return p3 + + +def _a_reader_fetches_in(box: Box, c: Path) -> None: + """Any reader's `log_sync` in the shared checkout (a peer's push or its + read-back does the same): it moves the checkout's own + `refs/remotes/origin/main` to origin's tip, takes no lock, and writes + nothing.""" + read = box.helper("who", OBJ, *HOME_FLAG) + assert read.returncode in (0, 8), read.stderr + assert box.git("-C", str(c), "rev-parse", "refs/remotes/origin/main").strip() == box.origin_head(), \ + "the reader moved the shared checkout's origin/main" + + +def test_a_rival_claim_a_reader_fetched_into_the_shared_checkout_loses_exit_7(box: Box): + """MUST-N of rv1b-c's re-check: B35 asked only whether S3's OWN fetch + moved origin/main. In a checkout several writers share — W, every lane's + while routing is off — another process moves that same ref while this + claim waits on the lock after its checks: a reader's `log_sync`, a peer's + push, its read-back. S3's fetch then found nothing new, this claim was + pushed on top of the rival's, and two lanes held the object. Now S3 + compares with the tip the checks read, so the claim runs again from the + top on the new tip, and the replay's rescan finds the rival: CLAIM-LOST + (7), one holder.""" + c = box.seam() + box.use_net() + gate = box.root / "go" + p3 = _another_write_holds_the_lock(box, c, gate) + a = _spawn(box, "a", "claim", OBJ, "--no-github", *HOME_FLAG, LANES_LANE=LANE, LANES_SESSION=UUID) + _wait_for(lambda: _waits_on_the_lock(a.pid), "the claim to finish its checks and wait on the lock") + c2 = box.clone(box.root / "c2") + rival = box.helper("claim", OBJ, "--no-github", *HOME_FLAG, + LANES_REPO=str(c2), LANES_LANE=NEIGHBOUR, LANES_SESSION=UUID2) + assert rival.returncode == 0, rival.stderr + _a_reader_fetches_in(box, c) + gate.write_text("go" + "\n", encoding="utf-8") + assert p3.wait(timeout=180) == 0, _err(box, "p3") + rc = a.wait(timeout=180) + stderr = _err(box, "a") + assert rc == 7, stderr + assert "moved while this write's checks ran" in stderr + assert f"another lane's claim landed on main before this one: @{NEIGHBOUR}" in stderr + mine = box.origin_file(f"lanes/log/{LANE}.md") + assert f"CLAIMED — lane {LANE}" not in mine, "this lane never holds it" + assert f"CLAIM-LOST — lane {LANE}" in mine + assert f"CLAIMED — lane {NEIGHBOUR}" in box.origin_file(f"lanes/log/{NEIGHBOUR}.md") + assert _set("LIVE · another write in this checkout") in box.origin_register(), "P3's write landed" + assert box.unpublished(c) == [] + _settled(box, c) + + +def test_an_add_row_whose_name_a_reader_fetched_into_the_shared_checkout_refuses(box: Box): + """MUST-N of rv1b-c's re-check, for `add-row`: a peer's retirement of the + name lands while the add-row waits on the shared checkout's lock after its + checks, and a reader there fetches it. S3's fetch found nothing new, and + the retired name was issued again at attempt 1. Now the re-run on the new + tip refuses (2), and nothing is written.""" + c = box.seam() + box.use_net() + gate = box.root / "go" + row = _row(NEW_LANE, uuid=UUID3) + box.peer_prepares(ARCHIVE, None, + "# retired rows" + "\n" + "\n" + "| lane | session id |" + "\n" + "|---|---|" + "\n" + + f"| `{NEW_LANE}` | harness `{UUID3}` |" + "\n", "a peer retires the name") + p3 = _another_write_holds_the_lock(box, c, gate) + a = _spawn(box, "a", "add-row", row) + _wait_for(lambda: _waits_on_the_lock(a.pid), "the add-row to finish its checks and wait on the lock") + box.git("-C", str(box.peer), "push", "-q", "origin", "main") + _a_reader_fetches_in(box, c) + gate.write_text("go" + "\n", encoding="utf-8") + assert p3.wait(timeout=180) == 0, _err(box, "p3") + rc = a.wait(timeout=180) + stderr = _err(box, "a") + assert rc == 2, stderr + assert "moved while this write's checks ran" in stderr + assert f"lane '{NEW_LANE}' is RETIRED" in stderr + assert box.origin_register().count(f"`{NEW_LANE}`") == 0, "the retired name is not issued again" + assert not [s for s in box.subjects() if s.endswith("add row")], "no add-row commit reached origin" + assert box.unpublished(c) == [] + _settled(box, c) + + +# 281 of lane openRepoTools-3's review of #307 (its P1 and P7): the window +# between S3's compare and S3's fast-forward. + +def _clean_words(box: Box, **extra: str) -> str: + """An `env -i` prefix for a nested helper run: the box's own environment, + with none of this case's shims or hooks.""" + env = {k: v for k, v in box.env.items() if k not in ("LANES_GIT", "FAKE_NET_LOG", "FAKE_NET_HOOK")} + env.update(extra) + return "env -i " + " ".join(shlex.quote(f"{k}={v}") for k, v in sorted(env.items())) + + +def _between_s3s_compare_and_its_ff(box: Box, c: Path, body: str) -> dict: + """`body` runs once, after S3's fetch and its compare (`cs_now` against + the tip the checks read) and before its fast-forward: at step 3's + `symbolic-ref -q HEAD` in `c`, the first git call after the compare. The + net hook arms it at S3's own fetch (its refspec), and a `git` first on + PATH fires it; `@GIT@` in `body` is the real git. Returns the env that + puts the shim on PATH.""" + real = shutil.which("git") + assert real, "git is on PATH" + armed = shlex.quote(str(box.root / "armed")) + fired = shlex.quote(str(box.root / "fired")) + box.use_net('[ "$1" = fetch ] || exit 0' + "\n" + + f'case "$*" in *"{S3_REFSPEC}"*) : ;; *) exit 0 ;; esac' + "\n" + + f"[ -e {fired} ] || [ -e {armed} ] || echo 1 > {armed}" + "\n" + + "exit 0" + "\n") + shim = box.root / "gitshim" + shim.mkdir() + step3 = shlex.quote(f"-C {c} symbolic-ref -q HEAD") + (shim / "git").write_text( + "#!/usr/bin/env bash" + "\n" + + f'if [ -e {armed} ] && [ "$*" = {step3} ]; then' + "\n" + + f" mv -- {armed} {fired}" + "\n" + + body.replace("@GIT@", shlex.quote(real)) + + "fi" + "\n" + + f'exec {shlex.quote(real)} "$@"' + "\n", encoding="utf-8") + (shim / "git").chmod(0o755) + return {"PATH": f"{shim}{os.pathsep}{box.env['PATH']}"} + + +def _a_reader_runs_in(box: Box, c: Path) -> str: + """Any reader's `log_sync` in the shared checkout, as a line of the shim: + it fetches, which moves `c`'s own `refs/remotes/origin/main`, takes no + lock, and writes nothing.""" + who = " ".join(shlex.quote(a) for a in box.cmd("who", OBJ, *HOME_FLAG)) + return f" {_clean_words(box, LANES_REPO=str(c))} {who} >/dev/null 2>&1 || :" + "\n" + + +def test_a_rival_claim_a_reader_fetched_in_after_S3s_compare_loses_exit_7(box: Box): + """281 of lane openRepoTools-3's review of #307 (its P1): S3 compared the + tip the claim's checks read with the one its own fetch found (decision + 22), and then fast-forwarded to `refs/remotes/origin/main` BY NAME, about + ten git calls later. A reader's `log_sync` in the shared checkout moved + that ref in between, to a rival's CLAIMED that had just landed: it was + fast-forwarded in unchecked, this claim was written on top of it, and its + push, a plain fast-forward, landed at attempt 1 (exit 0). Two lanes held + one object. Now the fast-forward goes to the sha S3's fetch read, so the + push is refused, and the replay's rescan finds the rival: CLAIM-LOST (7), + and one holder.""" + c = box.seam() + c2 = box.clone(box.root / "c2") + rival = " ".join(shlex.quote(a) for a in box.cmd("claim", OBJ, "--no-github", *HOME_FLAG)) + out = shlex.quote(str(box.root / "rival.out")) + env = _between_s3s_compare_and_its_ff(box, c, + f" {_clean_words(box, LANES_REPO=str(c2), LANES_LANE=NEIGHBOUR, LANES_SESSION=UUID2)} {rival} > {out} 2>&1" + "\n" + + f" echo $? > {out}.rc" + "\n" + _a_reader_runs_in(box, c)) + result = box.helper("claim", OBJ, "--no-github", *HOME_FLAG, LANES_LANE=LANE, LANES_SESSION=UUID, **env) + assert (box.root / "fired").exists(), "the rival landed, and a reader fetched it in, inside the window" + assert (box.root / "rival.out.rc").read_text(encoding="utf-8").strip() == "0", \ + (box.root / "rival.out").read_text(encoding="utf-8") + assert f"CLAIMED — lane {NEIGHBOUR}" in box.origin_file(f"lanes/log/{NEIGHBOUR}.md") + mine = box.origin_file(f"lanes/log/{LANE}.md") + assert f"CLAIMED — lane {LANE}" not in mine, "two lanes hold one object" + assert result.returncode == 7, result.stderr + assert "push refused (attempt 1 of 6)" in result.stderr + assert f"another lane's claim landed on main before this one: @{NEIGHBOUR}" in result.stderr + assert f"CLAIM-LOST — lane {LANE}" in mine + assert box.unpublished(c) == [] + _settled(box, c) + + +def test_an_add_row_whose_retirement_a_reader_fetched_in_after_S3s_compare_refuses(box: Box): + """281's P7: the same window for `add-row`. A peer's retirement of the + name lands and a reader fetches it into the shared checkout after S3's + compare and before its fast-forward. It was fast-forwarded in unchecked, + and the retired name was issued again at attempt 1 (exit 0). Now the row + is made on the sha S3's fetch read, its push is refused, and the re-run + on origin's new tip refuses (2): the name is not issued again.""" + c = box.seam() + row = _row(NEW_LANE, uuid=UUID3) + box.peer_prepares(ARCHIVE, None, + "# retired rows" + "\n" + "\n" + "| lane | session id |" + "\n" + "|---|---|" + "\n" + + f"| `{NEW_LANE}` | harness `{UUID3}` |" + "\n", "a peer retires the name") + env = _between_s3s_compare_and_its_ff(box, c, + f" @GIT@ -C {shlex.quote(str(box.peer))} push -q origin main" + "\n" + _a_reader_runs_in(box, c)) + result = box.helper("add-row", row, **env) + assert (box.root / "fired").exists(), "the retirement landed, and a reader fetched it in, inside the window" + assert f"`{NEW_LANE}`" in box.origin_file(ARCHIVE), "the retirement landed" + assert box.origin_register().count(f"`{NEW_LANE}`") == 0, "a retired name was issued again" + assert result.returncode == 2, result.stderr + assert "push refused (attempt 1 of 6)" in result.stderr + assert f"lane '{NEW_LANE}' is RETIRED" in result.stderr, result.stderr + assert box.unpublished(c) == [] + _settled(box, c) + + +# 284 of lane openRepoTools-3's review of #307 (its P3): a claim that runs +# again from the top runs with the checkout's lock held, and its top held +# Rule 1's network reads, none bounded by `git_net`. + +CLAIM_URL = "https://github.com/opensoft/repoS4/issues/1#issuecomment-284" +POSTED_URL = "https://github.com/opensoft/repoS4/issues/1#issuecomment-706" + + +def _network_reads_logged(box: Box, c: Path, bulk: int = 0, posted: Path | None = None) -> tuple: + """`gh`, and `git ls-remote` of a GitHub URL, each logged with whether + `c`'s lock is held at that moment; `gh issue view ... --json comments` + answers a claim comment's URL, and with `bulk`, `gh issue view + --comments` answers that many `CLAIMED —` lines of about 100 bytes each. + With `posted`, `gh issue comment` keeps the body it is given there and + answers a comment's URL. Nothing reaches the network. Returns the log and + the env that puts both first and lets the claim ask GitHub.""" + lock = shlex.quote(str(box.lock_of(c))) + log = shlex.quote(str(box.root / "net-reads.log")) + held = f'if [ -d {lock} ]; then s="LOCK HELD"; else s="no lock"; fi' + many = (f'*"issue view"*"--comments"*) i=0; while [ "$i" -lt {bulk} ]; do i=$((i + 1)); ' + f'printf "CLAIMED — lane bulk-%05d, session {UUID2}@Eagle, 2026-10-01T00:00:00Z, {OBJ}\\n" "$i"; ' + f'done ;; ') if bulk else "" + if posted is not None: + many += f'*"issue comment"*) cat > {shlex.quote(str(posted))}; echo "{POSTED_URL}" ;; ' + gh = box.fakebin / "gh" + gh.write_text("#!/usr/bin/env bash" + "\n" + held + "\n" + + f'printf "gh %s :: %s\\n" "$*" "$s" >> {log}' + "\n" + + f'case "$*" in *"--json comments"*) echo "{CLAIM_URL}" ;; {many}esac' + "\n" + + "exit 0" + "\n", encoding="utf-8") + gh.chmod(0o755) + real = shutil.which("git") + assert real, "git is on PATH" + shim = box.root / "gitshim" + shim.mkdir() + (shim / "git").write_text( + "#!/usr/bin/env bash" + "\n" + + 'case " $* " in *" ls-remote --heads git@github.com:"*)' + "\n" + + " " + held + "\n" + + f' printf "git %s :: %s\\n" "$*" "$s" >> {log}' + "\n" + + " exit 0 ;;" + "\n" + "esac" + "\n" + + f'exec {shlex.quote(real)} "$@"' + "\n", encoding="utf-8") + (shim / "git").chmod(0o755) + return box.root / "net-reads.log", {"PATH": f"{shim}{os.pathsep}{box.env['PATH']}", "LANES_NO_GITHUB": "0"} + + +def _origin_moves_once_at_s3s_fetch(box: Box) -> None: + peer = shlex.quote(str(box.peer)) + _at_s3_fetch(box, f"git -C {peer} fetch -q origin && git -C {peer} merge -q --ff-only refs/remotes/origin/main" + "\n" + + f"git -C {peer} commit -q --allow-empty -m 'a peer moves origin'" + "\n" + + f"git -C {peer} push -q origin main") + + +def test_a_claims_re_run_makes_no_network_read_while_it_holds_the_lock(box: Box): + """284 (its P3): origin moves at S3's fetch, so the claim runs again from + the top with the lock held (decision 19/22), and all four of Rule 1's + reads (`gh issue view`, two `gh pr list`, `git ls-remote` of GitHub) ran + again under it, with no time limit and no lock refresh: a stalled one + held W's lock for every lane. Now the re-run makes none: the reads made + at attempt 1, before the lock, cross the `exec` and are printed, and + cited, as they were. The rescan decides the race.""" + c = box.seam() + log, env = _network_reads_logged(box, c) + _origin_moves_once_at_s3s_fetch(box) + result = box.helper("claim", OBJ, *HOME_FLAG, LANES_LANE=LANE, LANES_SESSION=UUID, **env) + reads = log.read_text(encoding="utf-8").splitlines() + assert result.returncode == 0, result.stderr + assert "moved while this write's checks ran" in result.stderr, "the claim ran again with the lock held" + assert any(r.startswith("gh issue view") for r in reads), "attempt 1 made Rule 1's reads" + assert any(r.startswith("git ls-remote --heads git@github.com:") for r in reads), reads + assert [r for r in reads if "LOCK HELD" in r] == [], reads + assert "made at attempt 1, before the lock" in result.stderr + # 332 of lane 3's re-check: S3's fetch, the push and its read-back still + # run on the re-run, each bounded by `git_net`; what it makes none of is + # a GitHub read, and the note says that, not "no network read". + assert "makes no GitHub read with it held" in result.stderr, result.stderr + assert "no network read" not in result.stderr + assert result.stdout.count("1. existing `CLAIMED —` comments on") == 2, "printed at both attempts" + assert f"CLAIMED — lane {LANE}" in box.origin_file(f"lanes/log/{LANE}.md") + _settled(box, c) + + +def test_a_takeovers_re_run_cites_the_claim_url_attempt_1_read_and_reads_nothing_with_the_lock(box: Box): + """284's takeover half: `gh_stale_claim_url`, the read of the stale + claim's comment a TAKEOVER cites, ran again on the re-run with the lock + held. Now the URL attempt 1 read crosses the `exec`, as the takeover's + source does, and the TAKEOVER line cites it, with no read under the + lock.""" + stale = f"CLAIMED — lane {NEIGHBOUR}, session {UUID2}@Eagle, 2026-10-01T00:00:00Z, {OBJ} — no-github" + "\n" + box.peer_pushes(f"lanes/log/{NEIGHBOUR}.md", _log_head(NEIGHBOUR) + stale) + c = box.seam() + log, env = _network_reads_logged(box, c) + _origin_moves_once_at_s3s_fetch(box) + result = box.helper("claim", OBJ, "--force", *HOME_FLAG, LANES_LANE=LANE, LANES_SESSION=UUID, **env) + reads = log.read_text(encoding="utf-8").splitlines() + assert result.returncode == 0, result.stderr + assert "moved while this write's checks ran" in result.stderr, "the takeover ran again with the lock held" + assert any("--json comments" in r for r in reads), "attempt 1 read the stale claim's URL" + assert [r for r in reads if "LOCK HELD" in r] == [], reads + mine = box.origin_file(f"lanes/log/{LANE}.md") + assert f"TAKEOVER — lane {LANE}" in mine and CLAIM_URL in mine, mine + _settled(box, c) + + +def test_a_claims_re_run_carries_reads_too_long_for_one_environment_string(box: Box): + """334 of lane openRepoTools-3's re-check of #307: 284 carries attempt 1's + whole `gh_reads` output across the `exec` as one environment string, and + Linux caps one at 128 KiB: past that the `exec` failed and the write + exited 126, outside the exit table, with no "Nothing was written". Here + the object has 1,800 `CLAIMED —` comments (about 190 KB of reads). The + carried copy is now capped at 32,768 bytes and says so; attempt 1 still + prints the reads in full, and the re-run lands the claim.""" + c = box.seam() + log, env = _network_reads_logged(box, c, bulk=1800) + _origin_moves_once_at_s3s_fetch(box) + result = box.helper("claim", OBJ, *HOME_FLAG, LANES_LANE=LANE, LANES_SESSION=UUID, **env) + assert result.returncode == 0, result.stderr[-2000:] + assert "moved while this write's checks ran" in result.stderr, "the claim ran again from the top" + assert "(truncated at 32768 bytes" in result.stdout, "the re-run printed the capped copy and says so" + assert result.stdout.count("lane bulk-01800,") == 1, "attempt 1 printed every read; the re-run's copy is cut" + assert result.stdout.count("lane bulk-00001,") == 2, "both attempts printed the head of the reads" + assert [r for r in log.read_text(encoding="utf-8").splitlines() if "LOCK HELD" in r] == [] + assert f"CLAIMED — lane {LANE}" in box.origin_file(f"lanes/log/{LANE}.md") + _settled(box, c) + + +def test_a_claim_that_meets_a_move_at_every_S3_fetch_stops_at_six_exit_2(box: Box): + """SHOULD B of rv1b-c's re-check: `cs_recheck`'s six-attempt bound. A + peer moves origin at every one of S3's fetches, so every attempt finds + the tip moved under its checks. The sixth refuses (2) with nothing + written, rather than re-running for as long as origin is busy.""" + c = box.seam() + peer = shlex.quote(str(box.peer)) + count = shlex.quote(str(box.root / "moves")) + box.use_net( + '[ "$1" = fetch ] || exit 0' + "\n" + + f'case "$*" in *"{S3_REFSPEC}"*) : ;; *) exit 0 ;; esac' + "\n" + + f'n=$(cat {count} 2>/dev/null || echo 0); [ "$n" -lt 8 ] || exit 0; echo $((n + 1)) > {count}' + "\n" + + f"git -C {peer} fetch -q origin && git -C {peer} merge -q --ff-only refs/remotes/origin/main" + "\n" + + f'git -C {peer} commit -q --allow-empty -m "a peer moves origin ($((n + 1)))"' + "\n" + + f"git -C {peer} push -q origin main" + "\n" + + "exit 0" + "\n") + result = box.helper("claim", OBJ, "--no-github", *HOME_FLAG, LANES_LANE=LANE, LANES_SESSION=UUID) + assert result.returncode == 2, result.stderr + assert "on each of six attempts" in result.stderr + assert (box.root / "moves").read_text(encoding="utf-8").strip() == "6", "one move per attempt, six in all" + assert re.findall(r"checks ran \([^)]*\): nothing is written yet.*?attempt (\d) of 6", result.stderr) == \ + ["1", "2", "3", "4", "5"] + assert f"lane {LANE}" not in box.origin_file(f"lanes/log/{LANE}.md").split("\n", 1)[1], \ + "nothing of this claim's reached origin" + assert "push" not in box.net_ops(), "refused before any push" + assert box.unpublished(c) == [] + _settled(box, c) + + +def test_a_commit_another_writer_rewrote_is_named_as_rewritten_exit_6(box: Box): + """SHOULD A of rv1b-c's re-check: until slices 2b and 3, lane-start's + stamp and lane-handoff run `pull --rebase` and push in the same checkout + with no lock. One that runs while this write's push is in flight rebases + this write's commit, and here pushes its copy. Exit 6 said the commit and + the one on top of it were "kept on HEAD"; its sha is on no branch here, + and its change is on origin. Now the words say another writer rewrote it + and point to the read by content, and still nothing is replayed.""" + c = box.seam() + peer = shlex.quote(str(box.peer)) + cq = shlex.quote(str(c)) + pushed = shlex.quote(str(box.root / "pushed")) + done = shlex.quote(str(box.root / "rebased")) + box.use_net( + f'if [ "$1" = push ] && [ ! -e {pushed} ]; then echo 1 > {pushed}' + "\n" + + f" git -C {peer} commit -q --allow-empty -m 'a peer lands first'" + "\n" + + f" git -C {peer} push -q origin main" + "\n" + + " exit 0" + "\n" + "fi" + "\n" + + f'if [ "$1" = fetch ] && [ -e {pushed} ] && [ ! -e {done} ]; then echo 1 > {done}' + "\n" + + f" printf 'stamp' > {cq}/handoffs/stamp.md" + "\n" + + f" git -C {cq} add -- handoffs/stamp.md" + "\n" + + f" git -C {cq} commit -q -m 'handoff(repoS4-2@Eagle): a stamp, rebased and pushed with no lock' -- handoffs/stamp.md" + "\n" + + f" git -C {cq} pull --rebase -q origin main" + "\n" + + f" git -C {cq} push -q origin main" + "\n" + + "fi" + "\n" + "exit 0" + "\n") + result = box.helper("set-row-state", LANE, "LIVE · rewritten under it") + assert result.returncode == 6, result.stderr + assert "rewrote this write's commit" in result.stderr + assert "kept on HEAD" not in result.stderr, "nothing of this write's is on HEAD under its own sha" + assert "cherry -v refs/remotes/origin/main HEAD" in result.stderr # 703 + assert "log --oneline -5 refs/remotes/origin/main" in result.stderr + named = re.search(r"so it is not replayed: ([0-9a-f]{40}) ", result.stderr) + assert named, result.stderr + on_head = subprocess.run(["git", "-C", str(c), "merge-base", "--is-ancestor", named.group(1), "HEAD"], + env=box.env, capture_output=True, check=False) + assert on_head.returncode == 1, "the named commit is not on HEAD" + assert _set("LIVE · rewritten under it") in box.origin_register(), "the rewritten copy reached origin" + assert "push refused (attempt 1 of 6)" not in result.stderr, "no reset was made" + assert box.unpublished(c) == [] + _settled(box, c) + + +# The drop's two moments, as `git` first on PATH sees them: the helper at +# d9ff4e6 dropped with one `reset -q --keep`; since SHOULD C the branch moves +# first (`update-ref`, a compare-and-swap) and the tree after it +# (`read-tree -m -u`). Each pattern names both, so a case runs on either. +DROP_STARTS = '*" reset -q --keep "*|*" update-ref -m lanes-edit: the replay drops "*' +DROP_ENDS = '*" reset -q --keep "*|*" read-tree -m -u "*' +STATE = "LIVE · the write under it" + + +def _git_shim(box: Box, pattern: str, *, before: str = "", after: str = "") -> dict: + """A `git` first on PATH for the helper: the first call matching + `pattern` runs `before`, then the real command, then `after`, and keeps + the real command's status; every other call is the real git. `{git}` in + the snippets is the real git. Returns the env that puts it on PATH.""" + real = shutil.which("git") + assert real, "git is on PATH" + shim = box.root / "gitshim" + shim.mkdir() + rq = shlex.quote(real) + mark = shlex.quote(str(box.root / "fired")) + (shim / "git").write_text( + "#!/usr/bin/env bash" + "\n" + + f'case " $* " in {pattern})' + "\n" + + f" if [ ! -e {mark} ]; then echo 1 > {mark}" + "\n" + + before.format(git=rq) + + f' {rq} "$@"; rc=$?' + "\n" + + after.format(git=rq) + + " exit $rc" + "\n" + + " fi ;;" + "\n" + + "esac" + "\n" + + f'exec {rq} "$@"' + "\n", encoding="utf-8") + (shim / "git").chmod(0o755) + return {"PATH": f"{shim}{os.pathsep}{box.env['PATH']}"} + + +def _stamp(c: Path) -> str: + """An unlocked writer's stamp, committed by pathspec as lane-start does.""" + cq = shlex.quote(str(c)) + return (f" printf 'stamp' > {cq}/handoffs/stamp.md" + "\n" + + f" {{git}} -C {cq} add -- handoffs/stamp.md" + "\n" + + f" {{git}} -C {cq} commit -q -m 'handoff(repoS4-2@Eagle): a stamp from an unlocked writer' -- handoffs/stamp.md" + "\n") + + +def _pull(c: Path) -> str: + """An unlocked writer's `pull --rebase` (lane-start's, lane-handoff's).""" + return f" {{git}} -C {shlex.quote(str(c))} pull --rebase -q origin main >/dev/null 2>&1" + "\n" + + +def _mine_named_on_head(box: Box, c: Path, stderr: str) -> list: + """Every sha the words name that is this write's commit, with whether + HEAD holds it.""" + out = [] + for sha in sorted(set(re.findall(r"\b[0-9a-f]{40}\b", stderr))): + subj = subprocess.run(["git", "-C", str(c), "log", "-1", "--format=%s", sha], + env=box.env, capture_output=True, text=True, check=False).stdout.strip() + if subj.startswith(f"LANES({LANE}@"): + on = subprocess.run(["git", "-C", str(c), "merge-base", "--is-ancestor", sha, "HEAD"], + env=box.env, capture_output=True, check=False).returncode == 0 + out.append((sha, on)) + return out + + +def test_a_commit_landing_before_the_replays_drop_is_never_dropped_exit_6(box: Box): + """NIT 2 of rv1b-c's re-check, and SHOULD C of rv1b-c2's: the replay's + HEAD check and its drop are two git calls, and an unlocked commit landing + between them was dropped with this write's commit, with exit 0. The window + is milliseconds, so a `git` first on PATH makes it exact: it commits on + top just before the drop runs. The drop is a compare-and-swap of the + branch from this write's own commit, so git refuses it and nothing moves: + both commits are kept on HEAD and named (6), and nothing is dropped.""" + c = box.seam() + box.race() + env = _git_shim(box, DROP_STARTS, before=_stamp(c)) + result = box.helper("set-row-state", LANE, STATE, **env) + assert (box.root / "fired").exists(), "the commit landed in the window" + assert result.returncode == 6, result.stderr + assert "between the replay's HEAD check and its drop" in result.stderr + assert "is no longer HEAD, so it is not replayed" in result.stderr + _recovery_is_s63s_archive(result.stderr, c, "they are") # 705 + assert "push refused (attempt 1 of 6)" not in result.stderr, "nothing is replayed" + assert box.subject(c) == "handoff(repoS4-2@Eagle): a stamp from an unlocked writer" + assert box.subject(c, "HEAD~1").startswith(f"LANES({LANE}@"), "this write's commit is under it" + assert (c / "handoffs" / "stamp.md").is_file(), "the other writer's file is still there" + assert len(box.unpublished(c)) == 2, "both kept on HEAD, for the publish act" + assert "the replay drops" not in box.git("-C", str(c), "reflog", "refs/heads/main"), "the branch never moved" + _settled(box, c) + + +def test_a_drop_refused_for_another_reason_names_git_and_no_commit_on_top_exit_6(box: Box): + """rv1b-c3's NIT E (its probe P2): the drop's compare-and-swap is refused + for a reason other than a moved branch: another git process holds + `refs/heads/main.lock` (a peer's commit, or a `pack-refs`, in flight), and + HEAD is still this write's commit. The refusal went to `cp_not_head`, + which said "HEAD is X, not this write's commit X: another commit was made + on top of it" and "it and the commit on top of it are kept", and git's own + words, in the file the helper read them into, were never shown. Now git's + words are printed, and where HEAD is still this write's commit the words + say that git could not move the branch and that nothing was moved (6), + with no commit on top named.""" + c = box.seam() + box.race() + gd = box.git("-C", str(c), "rev-parse", "--absolute-git-dir").strip() + lock = shlex.quote(f"{gd}/refs/heads/main.lock") + env = _git_shim(box, DROP_STARTS, before=f" : > {lock}\n") + result = box.helper("set-row-state", LANE, STATE, **env) + assert (box.root / "fired").exists(), "the lock was held at the drop" + assert result.returncode == 6, result.stderr + err = result.stderr + sha = box.head(c) + assert box.subject(c).startswith(f"LANES({LANE}@"), "HEAD is still this write's commit" + assert re.search(r"git said: .*(cannot lock ref|main\.lock)", err), "git's own words are shown" + assert "another commit was made on top" not in err, "no commit is on top" + assert "it and the commit on top of it" not in err + assert f"not this write's commit {sha}" not in err + assert f"HEAD is still this write's commit {sha}" in err + _recovery_is_s63s_archive(err, c, "it is") # 705 + assert f"git could not move the branch off this write's commit to drop it, so nothing was moved and it is kept on HEAD: {sha}" in err + assert "push refused (attempt 1 of 6)" not in err, "nothing is replayed" + assert len(box.unpublished(c)) == 1, "the one commit is kept, for the publish act" + _settled(box, c) + + +def test_an_up_to_date_pull_after_the_replays_drop_is_no_commit_on_top(box: Box): + """SHOULD C of rv1b-c2's re-check, its case A: no commit lands anywhere, + and an unlocked `pull --rebase` that is up to date runs just after the + replay's drop. It rewrites ORIG_HEAD without moving HEAD, and the helper + at d9ff4e6 read that as a commit on top: its "undo" did nothing, it said + both commits were kept on HEAD, and the write was on neither HEAD nor + origin (6). Nothing is read from ORIG_HEAD now, so the replay goes on and + the write lands, as it did before the check (0).""" + c = box.seam() + box.race() + env = _git_shim(box, DROP_ENDS, after=_pull(c)) + result = box.helper("set-row-state", LANE, STATE, **env) + assert (box.root / "fired").exists(), "the pull ran just after the drop" + assert result.returncode == 0, result.stderr + assert "push refused (attempt 1 of 6)" in result.stderr + assert "pushed origin/main (attempt 2)" in result.stderr + assert "kept on" not in result.stderr, "nothing is said to be kept" + assert box.origin_register().count(_set(STATE)) == 1, "the write landed once" + assert box.head(c) == box.origin_head() + assert box.unpublished(c) == [] + _settled(box, c) + + +def test_a_stamp_committed_and_pulled_around_the_replays_drop_is_never_lost(box: Box): + """SHOULD C's case B, lane-start's own order: its stamp is committed on + top of this write's commit just before the drop, and its `pull --rebase` + runs just after. At d9ff4e6 the reset took both commits, the pull rewrote + ORIG_HEAD, and the helper said both were kept on HEAD while neither was on + it, nor on origin. Now the drop is refused with nothing moved: the stamp + and this write's change stay on HEAD (here the pull rebases them, so the + words say rewritten), the replay is not run (6), and "kept" is said only + of a commit HEAD holds.""" + c = box.seam() + box.race() + env = _git_shim(box, DROP_STARTS, before=_stamp(c), after=_pull(c)) + result = box.helper("set-row-state", LANE, STATE, **env) + assert (box.root / "fired").exists(), "the stamp and the pull ran around the drop" + assert result.returncode == 6, result.stderr + assert "push refused (attempt 1 of 6)" not in result.stderr, "nothing is replayed" + head_register = box.git("-C", str(c), "show", f"HEAD:{REGISTER}") + assert _set(STATE) in head_register or _set(STATE) in box.origin_register(), \ + "the write is on HEAD or on origin, never lost" + assert box.git("-C", str(c), "show", "HEAD:handoffs/stamp.md") == "stamp", "the stamp is on HEAD" + if "kept on" in result.stderr: + named = _mine_named_on_head(box, c, result.stderr) + assert named, result.stderr + assert all(on for _, on in named), f"called kept, and not on HEAD: {named}" + _settled(box, c) + + +# 291 of lane openRepoTools-3's review of #307: this write's own commit is +# known by what it is, and the drop takes it alone. The window is the one +# `git commit` call: a `git` first on PATH lands an unlocked writer's commit +# just after it (E8), just before it, or with this write's own message. +COMMIT_CALL = '*" commit -q -m LANES("*' + + +def test_a_commit_landing_just_after_this_writes_commit_is_never_taken_for_it_exit_6(box: Box): + """291's E8: the write took its own sha from `rev-parse HEAD` after + `git commit`, so an unlocked writer's stamp landing between the two was + taken as this write's. The HEAD check and the drop's compare-and-swap + both passed on the stamp's sha, the replay dropped it (exit 0, "Commit + is dropped"), and `stamp.md` went from the working tree, on + no branch and not on origin. Now this write's commit is the one under + the stamp, the replay finds a commit on top of it, and both are kept on + HEAD and named (6), with nothing dropped.""" + c = box.seam() + box.race() + env = _git_shim(box, COMMIT_CALL, after=_stamp(c)) + result = box.helper("set-row-state", LANE, STATE, **env) + assert (box.root / "fired").exists(), "the stamp landed just after this write's commit" + assert result.returncode == 6, result.stderr + err = result.stderr + stamp, mine = box.head(c), box.git("-C", str(c), "rev-parse", "HEAD~1").strip() + assert box.subject(c) == STAMP_SUBJECT + assert box.subject(c, "HEAD~1").startswith(f"LANES({LANE}@"), "this write's commit is under the stamp" + assert f"Commit {stamp} is dropped" not in err, "the stamp was taken for this write's commit" + assert f"HEAD is {stamp}, not this write's commit {mine}" in err, err + _recovery_is_s63s_archive(err, c, "they are") # 705 + # 343's smaller half: the stamp came after this write's commit and BEFORE + # its push, so "while its push was in flight" alone was not true here. + assert "in this checkout before or while its push was in flight" in err, err + assert "push refused (attempt 1 of 6)" not in err, "nothing is replayed" + assert (c / "handoffs" / "stamp.md").is_file(), "the other writer's file is still there" + assert len(box.unpublished(c)) == 2, "both kept on HEAD, for the publish act" + _settled(box, c) + + +def _archive_comes_after_the_list_and_the_stop_line(err: str, base: str, mine: str) -> None: + """343: `cp_under`'s recovery is the list, the STOP line, then S6.3's + archive and the drop, in that order, and never the publish act, which + refuses there whatever is done (origin has moved past HEAD: "moved + past", 2).""" + listed = err.find("list them: git -C ") + assert listed >= 0 and f"log --oneline {base}..{mine}" in err[listed:], err + stop = err.find("STOP and take it to the lane its subject names", listed) + archive = err.find("# the archive, on origin", stop) + drop = err.find("reset --keep refs/remotes/origin/main", archive) + assert 0 <= listed < stop < archive < drop, err + assert 'commit "", the publish act' not in err, "the publish act refuses here" + + +def _recovery_is_s63s_archive(err: str, c: Path, kept: str) -> None: + """705 of lane openRepoTools-3's re-check of #307's round 2, 343's fix + carried to four sibling notes: where origin moved (`cp_replay`'s bound + and keep-if-gone notes, `cp_not_head`'s HEAD-still and on-top notes), the + publish act refuses ("moved past", 2), and they offered it first. Now + each says what is kept must be archived, then gives S6.3's recovery: the + list, the STOP line, the archive and the drop, in that order.""" + lead = err.find(f"until {kept} archived and dropped here") + rec = err.find("RECOVERY (S6.3), in this order:", max(lead, 0)) + listed = err.find(f"list them: git -C {c} cherry -v refs/remotes/origin/main HEAD", max(rec, 0)) + stop = err.find("STOP and take it to the lane its subject names", max(listed, 0)) + archive = err.find("# the archive, on origin", max(stop, 0)) + drop = err.find(f"git -C {c} reset --keep refs/remotes/origin/main", max(archive, 0)) + assert 0 <= lead < rec < listed < stop < archive < drop, err + assert '", the publish act' not in err, ("the publish act refuses here", err) + + +def test_a_commit_landing_just_before_this_writes_commit_is_never_dropped_with_it_exit_6(box: Box): + """291's other half, the window lane 3's sketch left: a stamp landing + just before `git commit` is UNDER this write's commit. HEAD is this + write's commit, so the HEAD check passed, and the drop to origin's tip + took the stamp with it: `stamp.md` gone, the write replayed (0). Now the + drop is made only where this write's commit sits on origin's history; + here it sits on a commit origin lacks and it did not make, so both are + kept on HEAD and named (6), nothing is replayed, and the recovery is + S6.3's archive after the list and the STOP line (343).""" + c = box.seam() + box.race() + env = _git_shim(box, COMMIT_CALL, before=_stamp(c)) + result = box.helper("set-row-state", LANE, STATE, **env) + assert (box.root / "fired").exists(), "the stamp landed just before this write's commit" + assert result.returncode == 6, result.stderr + err = result.stderr + mine = box.head(c) + assert box.subject(c).startswith(f"LANES({LANE}@"), "HEAD is this write's commit" + assert box.subject(c, "HEAD~1") == STAMP_SUBJECT, "the stamp is under it" + assert "sits on 1 commit(s) that origin lacks and this write did not make" in err, err + assert f"it and they are kept on HEAD (Amendment 23 S4): {mine}" in err, err + assert "push refused (attempt 1 of 6)" not in err, "nothing is replayed" + assert (c / "handoffs" / "stamp.md").is_file(), "the other writer's file is still there" + _archive_comes_after_the_list_and_the_stop_line(err, box.origin_head(), mine) + assert len(box.unpublished(c)) == 2, "both kept on HEAD, for the archive" + _settled(box, c) + + +def test_history_origin_no_longer_has_under_this_writes_commit_is_never_called_a_writer_here_exit_6(box: Box): + """343 of lane openRepoTools-3's re-check of #307 at fc6f34d: `cp_under` + said "another writer in this checkout committed while this write was + being made" of every commit under this write's that origin lacks, a cause + it never checks. Here no writer touches the checkout: S3 fast-forwards + onto a peer's commit, and at the push origin's history is rewritten + without it (a force-push). The words now name what is known, commits + origin lacks that this write did not make, with both causes, and give + the list, the STOP line and S6.3's archive, never the publish act first, + which refuses there (6, both kept, nothing replayed).""" + c = box.seam() + box.peer_pushes("handoffs/rewritten.md", "on origin until the force-push") + peer = shlex.quote(str(box.peer)) + count = shlex.quote(str(box.count)) + box.use_net('[ "$1" = push ] || exit 0\n' + f"[ -e {count} ] && exit 0\n" + f"echo 1 > {count}\n" + f"git -C {peer} commit -q --allow-empty -m 'origin rewritten without the peer commit'\n" + f"git -C {peer} push -q -f origin main\n" + "exit 0\n") + result = box.helper("set-row-state", LANE, STATE) + assert box.count.exists(), "origin was rewritten at the push" + assert result.returncode == 6, result.stderr + err = result.stderr + mine = box.head(c) + assert box.subject(c).startswith(f"LANES({LANE}@"), "HEAD is this write's commit" + assert box.subject(c, "HEAD~1") == "a peer writes handoffs/rewritten.md", "the commit origin dropped is under it" + assert box.subjects()[0] == "origin rewritten without the peer commit" + assert "another writer in this checkout committed while this write was being made" not in err, \ + "no writer here committed anything" + assert ("1 commit(s) that origin/main lacks and this write did not make: another writer's here " + "(an unlocked one: lane-start's stamp, lane-handoff), or history origin no longer has") in err, err + assert "sits on 1 commit(s) that origin lacks and this write did not make" in err, err + assert f"it and they are kept on HEAD (Amendment 23 S4): {mine}" in err, err + assert "push refused (attempt 1 of 6)" not in err, "nothing is replayed" + _archive_comes_after_the_list_and_the_stop_line(err, box.origin_head(), mine) + assert box.pushes() == 1 + assert len(box.unpublished(c)) == 2, "both kept on HEAD, for the archive" + _settled(box, c) + + +def test_a_push_refused_with_origin_unmoved_over_a_stamp_under_it_lists_them_and_stops_first_exit_6(box: Box): + """344 of lane openRepoTools-3's re-check of #307 at fc6f34d, its case + (e2): an unlocked writer's stamp lands after S3 and just before this + write's `git commit`, so it is UNDER this write's commit, and the push is + refused with origin unmoved. The refusal named this write's commit alone + and offered `reset --keep origin/main` with no STOP line, over a stamp it + never mentioned. Now, where this write's commit does not sit on origin's + history, the words list `refs/remotes/origin/main..HEAD` with the STOP + line before any reset, and the reset spells the tracking ref in full + (6, both kept, no replay).""" + c = box.seam() + tip = _refuse_push_after(box, "") + env = _git_shim(box, COMMIT_CALL, before=_stamp(c)) + result = box.helper("set-row-state", LANE, STATE, **env) + assert (box.root / "fired").exists(), "the stamp landed just before this write's commit" + assert result.returncode == 6, result.stderr + err = result.stderr + mine = box.head(c) + assert box.subject(c).startswith(f"LANES({LANE}@"), "HEAD is this write's commit" + assert box.subject(c, "HEAD~1") == STAMP_SUBJECT, "the stamp is under it" + assert "refused with origin/main unmoved" in err, err + assert f"kept on HEAD: {mine}" in err, err + listed = re.search(r"under it are 1 commit\(s\) that origin/main lacks and this write did not make; " + r"list them: git -C \S+ log --oneline refs/remotes/origin/main\.\.HEAD; if any is not " + r"your own write, STOP and take it to the lane its subject names", err) + assert listed, err + reset = err.find("reset --keep refs/remotes/origin/main", listed.end()) + assert reset > listed.end(), "the STOP line comes before the reset" + assert "reset --keep origin/main" not in err, "the reset names the tracking ref in full" + assert "push refused (attempt" not in err, "no replay" + assert box.pushes() == 1 + assert box.origin_head() == tip + assert len(box.unpublished(c)) == 2, "both kept on HEAD" + _settled(box, c) + + +def test_a_stamp_under_the_carry_forms_commit_stays_and_the_re_run_refuses_on_it_exit_2(box: Box): + """344's first half, pinned as the manual's step 5 and exit row 6 now + say it: `commit`'s carry form drops to its own commit's parent by + design (B21), which takes this write's commit alone. A stamp that lands + just before its `git commit`, with origin moved, stays: the hand edit + goes back to the working tree, the re-run's S3 refuses on the stamp, + which origin lacks (2, nothing written), and the copy of the edit is + kept. Nothing is lost, and nothing is "kept on HEAD" as row 6's 6 says.""" + c = box.seam() + box.race() + box.hand_edit(c) + env = _git_shim(box, COMMIT_CALL, before=_stamp(c)) + result = box.helper("commit", "the hand edit", LANES_LANE=LANE, **env) + assert (box.root / "fired").exists(), "the stamp landed just before the carry's commit" + assert result.returncode == 2, result.stderr + err = result.stderr + assert "push refused (attempt 1 of 6)" in err, err + assert "left in the working tree as the hand edit it was" in err, err + assert "HEAD carries 1 unpublished commit(s)" in err, err + assert "kept on HEAD" not in err, err + assert box.subject(c) == STAMP_SUBJECT, "the stamp is HEAD; the carry's commit was dropped alone" + assert (c / "handoffs" / "stamp.md").is_file(), "the other writer's file is still there" + assert "LIVE · a hand edit" in (c / REGISTER).read_text(encoding="utf-8"), "the hand edit is in the working tree" + assert "LIVE · a hand edit" not in box.origin_register() + assert list((c / ".git" / "lanes-edit").glob("hand-edit-*.diff")), "the copy of the edit is kept" + assert len(box.unpublished(c)) == 1, "the stamp alone" + _settled(box, c) + + +def test_a_commit_with_this_writes_own_message_made_with_it_is_no_answer_exit_6(box: Box): + """291's last word, "also match the subject": where another commit was + made in the moment of this write's, its own commit is the one with its + message. Two with that message are no answer, so nothing is guessed: + nothing is pushed, both are kept on HEAD, and it says so (6).""" + c = box.seam() + box.race() + cq = shlex.quote(str(c)) + same = (' m=""; prev=""; for a in "$@"; do [ "$prev" = -m ] && m="$a"; prev="$a"; done' + "\n" + + f" printf 'same' > {cq}/handoffs/same.md" + "\n" + + f" {{git}} -C {cq} add -- handoffs/same.md" + "\n" + + f' {{git}} -C {cq} commit -q -m "$m" -- handoffs/same.md' + "\n") + env = _git_shim(box, COMMIT_CALL, before=same) + result = box.helper("set-row-state", LANE, STATE, **env) + assert (box.root / "fired").exists(), "a commit with this write's message landed with it" + assert result.returncode == 6, result.stderr + err = result.stderr + assert box.subject(c) == box.subject(c, "HEAD~1"), "two commits, one message" + assert "cannot be told from another made in this checkout" in err, err + assert box.pushes() == 0, "nothing is pushed on a guess" + assert len(box.unpublished(c)) == 2, "both kept on HEAD" + _settled(box, c) + + +# NIT 3 of rv1b-c2's re-check, re-aimed by SHOULD C: the drop's two refusals +# that had no case. `commit`'s carry form dropped with `reset -q --mixed` at +# d9ff4e6; since SHOULD C it takes the same compare-and-swap, then the index +# alone (`read-tree -m -i`). The keep form's tree step (`read-tree -m -u`) is +# SHOULD C's own, so d9ff4e6 has nothing for its pattern to match. +CARRY_DROP_STARTS = '*" reset -q --mixed "*|*" update-ref -m lanes-edit: the replay drops "*' +TREE_STEP = '*" read-tree -m -u "*' +STAMP_SUBJECT = "handoff(repoS4-2@Eagle): a stamp from an unlocked writer" + + +def test_a_commit_landing_before_the_carry_forms_drop_is_never_dropped_exit_6(box: Box): + """`commit`'s carry form gives its commit back as the hand edit it was, + and its drop is guarded as the keep form's is: an unlocked commit landing + on top just before it makes the compare-and-swap fail, and nothing moves. + The hand-edit commit and the one on top of it are kept on HEAD and named + (6), the working tree is clean, and nothing is replayed. A drop that took + the carry form's branch without the compare-and-swap would drop the + stamp with it.""" + c = box.seam() + box.peer_prepares(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2, state="LIVE · landed mid-push"), + "a peer's row lands first") + box.race(prepared=True) + box.hand_edit(c) + env = _git_shim(box, CARRY_DROP_STARTS, before=_stamp(c)) + result = box.helper("commit", "the hand edit", LANES_LANE=LANE, **env) + assert (box.root / "fired").exists(), "the commit landed in the window" + assert result.returncode == 6, result.stderr + err = result.stderr + assert "between the replay's HEAD check and its drop" in err, err + assert "is no longer HEAD, so it is not replayed" in err + assert "push refused (attempt 1 of 6)" not in err, "nothing is replayed" + assert box.subject(c) == STAMP_SUBJECT + assert box.subject(c, "HEAD~1") == f"LANES({LANE}@Eagle): the hand edit", "the hand-edit commit is under it" + assert "LIVE · a hand edit" in box.git("-C", str(c), "show", f"HEAD:{REGISTER}"), "the hand edit is on HEAD" + assert box.git("-C", str(c), "status", "--porcelain").strip() == "", "nothing was turned back into an edit" + assert len(box.unpublished(c)) == 2, "both kept on HEAD, for the publish act" + assert "the replay drops" not in box.git("-C", str(c), "reflog", "refs/heads/main"), "the branch never moved" + _settled(box, c) + + +def test_a_drop_whose_tree_step_and_put_back_are_both_refused_is_named_off_HEAD_exit_6(box: Box): + """The drop moved the branch, git refused the tree step after it, and an + unlocked writer committed on the branch in that moment, so the put-back's + compare-and-swap is refused too. Nothing more is moved: the other writer's + commit stays HEAD, and this write's commit is named as off HEAD with the + reflog that names it (6), never as kept. A `git` first on PATH makes the + moment exact: at the tree step it commits the stamp, then refuses.""" + c = box.seam() + box.race() + refuse = " echo 'error: the tree step is refused here (the test)' >&2; exit 1" + "\n" + env = _git_shim(box, TREE_STEP, before=_stamp(c) + refuse) + result = box.helper("set-row-state", LANE, STATE, **env) + assert (box.root / "fired").exists(), "the drop's tree step ran, with the stamp landing first" + assert result.returncode == 6, result.stderr + err = result.stderr + assert "could be neither dropped cleanly nor put back" in err, err + assert "is off HEAD" in err + assert f"git -C {c} reflog -5" in err + assert "kept on" not in err, "nothing of this write's is said to be kept" + assert "push refused (attempt 1 of 6)" not in err, "nothing is replayed" + named = _mine_named_on_head(box, c, err) + assert named, err + assert not any(on for _, on in named), f"named, and on HEAD: {named}" + reflog = box.git("-C", str(c), "reflog", "--format=%H", "refs/heads/main").split() + assert named[0][0] in reflog, "the reflog names this write's commit" + assert box.subject(c) == STAMP_SUBJECT, "the other writer's commit is still HEAD" + assert box.git("-C", str(c), "show", "HEAD:handoffs/stamp.md") == "stamp" + assert _set(STATE) not in box.origin_register(), "nothing of this write's reached origin" + _settled(box, c) + + +CARRY_TREE_STEP = '*" read-tree -m -i "*' + + +def test_a_carry_forms_drop_whose_tree_step_is_refused_keeps_the_commit_exit_6(box: Box): + """`commit`'s carry form drops its commit to the parent in the index alone + (`read-tree -m -i`), to give the change back as the hand edit it was. + Where git refuses that step, the branch is put back on this write's + commit by a compare-and-swap from where the drop put it, and the commit + is kept on HEAD and named (6): the hand edit is in it, nothing is turned + back into an edit, and nothing is replayed. A `git` first on PATH refuses + the step.""" + c = box.seam() + box.peer_prepares(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2, state="LIVE · landed mid-push"), + "a peer's row lands first") + box.race(prepared=True) + box.hand_edit(c) + refuse = " echo 'error: the tree step is refused here (the test)' >&2; exit 1" + "\n" + env = _git_shim(box, CARRY_TREE_STEP, before=refuse) + result = box.helper("commit", "the hand edit", LANES_LANE=LANE, **env) + assert (box.root / "fired").exists(), "the carry form's tree step ran" + assert result.returncode == 6, result.stderr + err = result.stderr + assert "git said: error: the tree step is refused here (the test)" in err, err + named = re.search(r"could not be turned back into the hand edit it was to replay it, " + r"so it is kept on HEAD: ([0-9a-f]{40}) ", err) + assert named, err + assert named.group(1) == box.head(c), "the commit it names is HEAD: the branch was put back" + assert box.subject(c) == f"LANES({LANE}@Eagle): the hand edit" + assert "LIVE · a hand edit" in box.git("-C", str(c), "show", f"HEAD:{REGISTER}"), "the hand edit is on HEAD" + assert box.git("-C", str(c), "status", "--porcelain").strip() == "", "nothing was turned back into an edit" + assert "the replay puts back" in box.git("-C", str(c), "reflog", "refs/heads/main") + assert "push refused (attempt 1 of 6)" not in err, "nothing is replayed" + assert "LIVE · a hand edit" not in box.origin_register(), "nothing of this write's reached origin" + copies = _copies_named(err) + assert len(copies) == 1, err + assert Path(copies[0]).exists(), "origin does not have the edit, so its copy stays (393)" + assert len(box.unpublished(c)) == 1, "the one commit is kept, for the publish act" + _settled(box, c) + + +# ------------------------------- a file the write edits in place (slice 2a's A37) + +def _helper_naming(box: Box, rel: str) -> list: + """A copy of the helper whose one load-time line names `rel` as the file + this write edits in place, as slice 2a's `write-handoff` names its handoff + for `--stamp` and `--prepend`. No verb of slice 1b sets the global, so the + copy is how 1b tests its check: it differs from the helper in that one + line, and sits in a folder of its own with the shipped `repos.tsv` beside + it, as an install places it.""" + src = (REPO / "lanes-edit.sh").read_text(encoding="utf-8") + line = 'CP_KEEP_IF_GONE=""\n' + assert src.count(line) == 1, "the global is set at load, once" + bindir = box.root / "bin-a37" + bindir.mkdir() + shutil.copy2(REPO / "repos.tsv", bindir / "repos.tsv") + copy = bindir / "lanes-edit.sh" + copy.write_text(src.replace(line, f"CP_KEEP_IF_GONE={shlex.quote(rel)}\n"), encoding="utf-8") + copy.chmod(0o755) + return ["bash", str(copy)] + + +@pytest.mark.parametrize("peer", ["deletes-it", "keeps-it", "never-had-it", "creates-it"]) +def test_a_file_the_write_edits_in_place_deleted_on_origin_keeps_the_commit_exit_6(box: Box, peer: str): + """A37 of slice 2a, whose global is 1b's: a peer that deletes the file a + write edits in place, while that write's push is in flight, left the + replay nothing to write into, and the write only in the commit it + dropped. With `CP_KEEP_IF_GONE` naming the file, the commit is kept on + HEAD and named (6) before anything is dropped: the write is in this + checkout, origin has no such file, and one commit is unpublished. The + lane's own log stands in for the handoff here; the check is the same for + any path. A peer's commit that leaves the file where it was is an + ordinary lost race, replayed and landed (0), and so is a named file the + write's base never had: it is never "gone". That holds for a file nobody + has, and, rv1b-c3's NIT F, for one THE WRITE ITSELF CREATES (a lane's + first log line: origin lacks the log both before and after, and the + write's commit has it), which the `^` in `$1^:` is for: asked of + the commit and not its base, that file would read as deleted.""" + log = f"lanes/log/{LANE}.md" + if peer == "creates-it": + # Origin has no log for the lane when this write starts: the write + # creates it, and the peer that beats it to the push touches nothing. + box.git("-C", str(box.peer), "rm", "-q", "--", log) + box.git("-C", str(box.peer), "commit", "-q", "-m", f"the lane has no log yet: {log}") + box.git("-C", str(box.peer), "push", "-q", "origin", "main") + c = box.seam() + if peer == "creates-it": + assert not (c / log).exists(), "the write's base has no log" + rel = "handoffs/repoS4/never-had-it.md" if peer == "never-had-it" else log + if peer == "deletes-it": + box.git("-C", str(box.peer), "rm", "-q", "--", log) + box.git("-C", str(box.peer), "commit", "-q", "-m", f"a peer deletes {log}") + box.race(prepared=True) + else: + box.race() + cmd = _helper_naming(box, rel) + result = subprocess.run([*cmd, "log", "NOTED", f"lane:{LANE}", "the in-place write", *HOME_FLAG], + env=dict(box.env, LANES_LANE=LANE, LANES_SESSION=UUID), cwd=str(box.root), + capture_output=True, text=True, timeout=180, check=False) + err = result.stderr + if peer == "deletes-it": + assert result.returncode == 6, err + sha = box.head(c) + assert f"origin's copy of {log} was deleted while this write's push was in flight" in err, err + assert f"kept on HEAD: {sha}" in err + assert f"git -C {c} reset --keep refs/remotes/origin/main" in err, err # 703 + _recovery_is_s63s_archive(err, c, "it is") # 705 + assert "push refused (attempt" not in err, "nothing is dropped or replayed" + assert "the in-place write" in (c / log).read_text(encoding="utf-8"), "the write is in this checkout" + assert log not in box.git("--git-dir", str(box.origin), "ls-tree", "-r", "--name-only", "main").split(), \ + "origin has no such file" + assert [line.split()[1] for line in box.unpublished(c)] == [sha], "one commit unpublished, this write's" + else: + assert result.returncode == 0, err + assert "push refused (attempt 1 of 6)" in err + assert "pushed origin/main (attempt 2)" in err + assert "the in-place write" in box.origin_file(log) + assert box.unpublished(c) == [] + _settled(box, c) + + +# ------------------------------------------------------------ #307's round 3 + +ARCHIVE_LINE = r"(git -C \S+ push origin HEAD:refs/heads/archive/\S+)\s+# the archive, on origin" +DROP_LINE = r"(git -C \S+ reset --keep \S+)\s+# then drop them here" + + +def _run_as_printed(box: Box, err: str, pattern: str) -> subprocess.CompletedProcess: + """The command `pattern` finds in `err`, run by a shell exactly as printed.""" + found = re.search(pattern, err) + assert found, err + return subprocess.run(["bash", "-c", found.group(1)], env=box.env, cwd=str(box.root), + capture_output=True, text=True, timeout=60, check=False) + + +@pytest.mark.parametrize("named", ["by-argument", "by-nobody"]) +def test_s63s_archive_and_drop_run_as_printed(tmp_path: Path, named: str): + """702 of lane openRepoTools-3's re-check of #307's round 2: + `cp_archive_steps` printed `archive/${LANES_LANE:-}-stranded-…`. + Where the lane is named by the write's argument alone (LANES_LANE + unset, `cp_under`'s case), the branch read `archive/-stranded-…`: + git made a branch of that name, and a shell reads `` as a + redirection. It now names the lane the write was given; where no lane + is named at all (`commit` with no LANES_LANE, the publish act's + refusal; without the opt-in, since under it a write names its lane, S2), + the archive carries no lane. Run by a shell as printed, the archive lands + the stack on origin and the drop puts HEAD at origin's tip.""" + box = Box(tmp_path, route=named != "by-nobody") + c = box.seam() + if named == "by-argument": + box.race() + env = _git_shim(box, COMMIT_CALL, before=_stamp(c)) + result = box.helper("set-row-state", LANE, STATE, **env) + assert result.returncode == 6, result.stderr + branch = f"refs/heads/archive/{LANE}-stranded-" + else: + box.use_net() + _queue(box, c) + box.peer_pushes("handoffs/repoS4/elsewhere.md", "a peer's file" + "\n") + result = box.helper("commit", "publish the queue") + assert result.returncode == 2, result.stderr + branch = "refs/heads/archive/stranded-" + err = result.stderr + assert "archive/" not in err, err + stack = box.head(c) + archived = _run_as_printed(box, err, ARCHIVE_LINE) + assert archived.returncode == 0, (archived.stdout, archived.stderr) + refs = box.git("--git-dir", str(box.origin), "for-each-ref", + "--format=%(objectname) %(refname)", "refs/heads/archive/").splitlines() + assert len(refs) == 1 and refs[0].startswith(f"{stack} {branch}"), (refs, err) + dropped = _run_as_printed(box, err, DROP_LINE) + assert dropped.returncode == 0, (dropped.stdout, dropped.stderr) + assert box.head(c) == box.origin_head(), "the drop reached origin's tip" + + +def test_a_claims_re_run_posts_the_whole_reads_not_the_capped_copy(box: Box): + """706 of lane openRepoTools-3's re-check of #307's round 2: 334's cap + reached what a re-run POSTS. The claim comment's "three reads" block was + the 32,768-byte copy that crossed the `exec`, so with 1,800 comments the + posted comment held about 260 lines and the truncation marker. Attempt 1 + now keeps the reads whole in a file under the checkout's git directory, + whose name crosses the `exec`, and the re-run's comment cites them from + it; the environment string keeps 334's cap, and the file goes with the + claim's last run.""" + c = box.seam() + posted = box.root / "posted.md" + log, env = _network_reads_logged(box, c, bulk=1800, posted=posted) + _origin_moves_once_at_s3s_fetch(box) + result = box.helper("claim", OBJ, *HOME_FLAG, LANES_LANE=LANE, LANES_SESSION=UUID, **env) + assert result.returncode == 0, result.stderr[-2000:] + assert "moved while this write's checks ran" in result.stderr, "the claim ran again from the top" + assert "(truncated at 32768 bytes" in result.stdout, "the re-run still prints the capped copy (334)" + assert f"comment posted: {POSTED_URL}" in result.stderr, result.stderr[-2000:] + body = posted.read_text(encoding="utf-8") + assert "The three reads (lane-collision-protocol Rule 1)" in body, body[:500] + assert "lane bulk-00001," in body and "lane bulk-01800," in body, "the comment cites every read" + assert "(truncated at 32768 bytes" not in body, "never the capped copy" + assert [r for r in log.read_text(encoding="utf-8").splitlines() if "LOCK HELD" in r] == [] + assert list((c / ".git" / "lanes-edit").glob("claim-reads-*")) == [], "the whole reads' file goes with the run" + _settled(box, c) + + +def test_an_unconfirmed_pushs_look_line_reads_origin_past_a_local_branch_named_origin_main(box: Box): + """703 of lane openRepoTools-3's re-check of #307's round 2: the class of + 333 and 342 was open in six more printed commands (`cherry -v + origin/ HEAD`, `reset --keep origin/`), and a local branch named + `origin/main` wins the short name over the tracking ref. They name + `refs/remotes/origin/` now. Here the push landed and its read-back + failed (3): the LOOK line, run by a shell as printed past such a branch, + finds the commit on origin, where the short name listed it as missing.""" + c = box.seam() + mark = shlex.quote(str(box.root / "pushed")) + box.use_net(f'[ "$1" = push ] && {{ touch {mark}; exit 0; }}\n' + f'[ "$1" = fetch ] && [ -e {mark} ] && {{ echo "fatal: the read-back cannot reach origin" >&2; exit 128; }}\n' + "exit 0\n") + result = box.helper("set-row-state", LANE, "LIVE · sent and not confirmed") + assert result.returncode == 3, result.stderr + sha = box.head(c) + assert box.origin_head() == sha, "it landed" + box.git("-C", str(c), "branch", "origin/main", "HEAD~1") # the shadow, behind origin + look = _run_as_printed(box, result.stderr, + r"LOOK, by content: (git -C \S+ fetch origin main && git -C \S+ cherry -v \S+ HEAD)") + assert look.returncode == 0, look.stderr + assert f"+ {sha}" not in look.stdout, ("it is on origin, and the printed LOOK says so", look.stdout) diff --git a/tests/test_a23_write_routing.py b/tests/test_a23_write_routing.py index 46fa88b..a63690c 100644 --- a/tests/test_a23_write_routing.py +++ b/tests/test_a23_write_routing.py @@ -520,15 +520,132 @@ def test_a_write_that_needs_a_row_clones_nothing_for_a_lane_with_none(box: Box): def test_a_row_W_has_not_fetched_is_no_reason_to_refuse(box: Box, no_fetch: bool): """N5's gate asks W's `origin/main` again after fetching it, so a row another checkout published, which W has not fetched yet, is never refused. - Where that fetch does not run (`LANES_NO_FETCH=1`), a miss is no answer: - the write goes on, as before, and its arm decides.""" + Where that fetch does not run (`LANES_NO_FETCH=1`), a miss is no answer + about the ROW. But since slice 1b a writer whose fetch did not run refuses + (2) at Amendment 23 S3 step 1 — a fetch that did not run is a fetch that + failed (ratified choice 2) — so the gate says that refusal itself, before + the clone the write could never use (R4-1): never "has no row", nothing + cloned, nothing published.""" lane = "repoA23-7" head, sep, rest = box.origin_file(REGISTER).partition("|---|---|---|---|---|---|---|\n") box.peer_pushes(REGISTER, head + sep + _row(lane) + "\n" + rest) assert f"`{lane}`" not in box.git("-C", str(box.w), "show", f"refs/remotes/origin/main:{REGISTER}") extra = {"LANES_NO_FETCH": "1"} if no_fetch else {} result = box.helper("set-row-state", lane, "LIVE · a row W had not fetched", **extra) - assert result.returncode == 0, (result.stdout, result.stderr) assert "has no row in the register" not in result.stderr, result.stderr + if no_fetch: + assert result.returncode == 2, (result.stdout, result.stderr) + assert "LANES_NO_FETCH=1 is set" in result.stderr, result.stderr + assert "ratified choice 2" in result.stderr, result.stderr + assert " · a row W had not fetched |" not in box.origin_register() + assert not box.lanes_root.exists(), "a write that must refuse at S3 step 1 clones nothing first" + return + assert result.returncode == 0, (result.stdout, result.stderr) assert (box.checkout(lane) / ".git").is_dir() assert " · a row W had not fetched |" in box.origin_register() + + +# --------------------------------------------- slice 1b: R4-1 (#282's re-check) + +TYPO = "repoA23-99" # a typo for repoA23-9: no row, no log, anywhere +ARCHIVED = "repoA23-8" # an archived lane: its log on origin, and no row (Amendment 16(e)) + +NAMED_WRITES = { + "log": (("log", "NOTED", f"lane:{TYPO}", "a note for a lane nobody started", + "--home", "opensoft/repoA23"), {"LANES_LANE": TYPO, "LANES_SESSION": UUID}), + "commit": (("commit", "a publish for a lane nobody started"), {"LANES_LANE": TYPO}), + "request-handoff": (("request-handoff", TYPO, "--no-wait"), {"LANES_SESSION": ASKER_UUID}), + "append-line": (("append-line", "LANDING — a line for a lane nobody started"), {"LANES_LANE": TYPO}), + # 285 of lane openRepoTools-3's review of #307: `claim` and `release` take + # their lane from LANES_LANE as `log` does, and a sweep goes through its + # runner's checkout. + "claim": (("claim", "opensoft/repoA23#1", "--no-github", "--home", "opensoft/repoA23"), + {"LANES_LANE": TYPO, "LANES_SESSION": UUID}), + "release": (("release", "opensoft/repoA23#1", "a release for a lane nobody started", "--no-github", + "--home", "opensoft/repoA23"), {"LANES_LANE": TYPO, "LANES_SESSION": UUID}), + "retire-rows": (("retire-rows", DORMANT), {"LANES_LANE": TYPO, "LANES_SESSION": UUID}), + "archive-rows": (("archive-rows", "repoA23", "--yes"), {"LANES_LANE": TYPO, "LANES_SESSION": UUID}), + "migrate-state-cells": (("migrate-state-cells", "--yes"), {"LANES_LANE": TYPO, "LANES_SESSION": UUID}), +} + + +@pytest.mark.parametrize("verb", sorted(NAMED_WRITES)) +def test_a_write_for_a_lane_with_no_row_and_no_log_clones_nothing(box: Box, verb: str): + """R4-1: under the opt-in, `log NOTED lane:repoX-99 …` — a typo for + repoX-9 — cloned `-lanes/repoX-99/` (0.35-0.5 GB of working tree on + Eagle) for a lane that does not exist, and left it. So did `commit`, + `request-handoff` and `append-line`. Each takes a lane with no row, so + the gate asks for a row OR a published log; with neither, after one fetch + of W, it refuses (2) and clones nothing. So do `claim`, `release` and the + three sweeps' runner lane (285 of lane openRepoTools-3's review of #307: + `claim` and `release` cloned the checkout and published a CLAIMED or a + RELEASED for the typo, exit 0).""" + args, env = NAMED_WRITES[verb] + head, w_before = box.origin_head(), box.w_state() + result = box.helper(*args, **env) + assert result.returncode == 2, (result.stdout, result.stderr) + assert f"lane {TYPO} has no row and no object log on origin/main" in result.stderr, result.stderr + assert not box.lanes_root.exists(), "nothing was cloned for a lane that does not exist" + assert_nothing_written(box, head, w_before) + + +def test_a_lane_with_a_published_log_and_no_row_is_no_reason_to_refuse(box: Box): + """R4-1's other half: an archived lane has no row and keeps its log, and + `log`, `commit`, `request-handoff` and `append-line` take it. The gate + passes it by its log on origin — here one W has not fetched yet, so the + gate's second read is the one that finds it — and the note lands.""" + box.peer_pushes(f"lanes/log/{ARCHIVED}.md", + f"# lane {ARCHIVED} — object log (lane-collision-protocol Amendment 7)\n") + result = box.helper("log", "NOTED", f"lane:{ARCHIVED}", "a note for an archived lane", + "--home", "opensoft/repoA23", LANES_LANE=ARCHIVED, LANES_SESSION=UUID) + assert result.returncode == 0, (result.stdout, result.stderr) + assert "has no row and no object log" not in result.stderr, result.stderr + assert (box.checkout(ARCHIVED) / ".git").is_dir(), "it went through that lane's own checkout" + assert "a note for an archived lane" in box.origin_file(f"lanes/log/{ARCHIVED}.md") + + +# --------------------------- Q4 of lane openRepoTools-3's review of #307 + +#: Writers for lanes W knows (a row, a log), each under LANES_NO_FETCH=1. +KNOWN_WRITES = { + "set-row-state": (("set-row-state", LANE, "LIVE · no fetch"), {}), + "log": (("log", "NOTED", f"lane:{LANE}", "a note with no fetch", "--home", "opensoft/repoA23"), + {"LANES_LANE": LANE, "LANES_SESSION": UUID}), + "claim": (("claim", "opensoft/repoA23#2", "--no-github", "--home", "opensoft/repoA23"), + {"LANES_LANE": LANE, "LANES_SESSION": UUID}), + "retire-rows": (("retire-rows", DORMANT), {"LANES_LANE": LANE, "LANES_SESSION": UUID}), + "request-handoff": (("request-handoff", TARGET, "--no-wait"), {"LANES_SESSION": ASKER_UUID}), +} + + +@pytest.mark.parametrize("verb", sorted(KNOWN_WRITES)) +def test_no_fetch_refuses_every_routed_writer_before_any_clone(box: Box, verb: str): + """Q4 (its P6): under the opt-in and LANES_NO_FETCH=1, a write for a lane + W already knows still had its checkout cloned, about 0.4 GB, and S3 step 1 + then refused it (2), as it must: for a writer a fetch that did not run is a + fetch that failed (ratified choice 2). Now every routed writer refuses + before any clone where its lane's checkout does not exist yet: 2, the same + reason, nothing cloned, nothing written.""" + args, env = KNOWN_WRITES[verb] + head, w_before = box.origin_head(), box.w_state() + result = box.helper(*args, LANES_NO_FETCH="1", **env) + assert result.returncode == 2, (result.stdout, result.stderr) + assert "LANES_NO_FETCH=1 is set" in result.stderr, result.stderr + assert "ratified choice 2" in result.stderr + assert "was not cloned" in result.stderr + assert not box.lanes_root.exists(), "a write S3 step 1 must refuse clones nothing first" + assert_nothing_written(box, head, w_before) + + +def test_no_fetch_through_an_existing_checkout_is_refused_there_as_before(box: Box): + """Q4's other half: a lane whose checkout exists keeps today's behaviour, + S3 step 1's refusal in that checkout, at no cost.""" + first = box.helper("set-row-state", LANE, "LIVE · the first write") + assert first.returncode == 0, (first.stdout, first.stderr) + lc = box.checkout(LANE) + head = box.origin_head() + result = box.helper("set-row-state", LANE, "LIVE · no fetch", LANES_NO_FETCH="1") + assert result.returncode == 2, (result.stdout, result.stderr) + assert f"refusing to set-row-state in {lc}: LANES_NO_FETCH=1 is set" in result.stderr, result.stderr + assert "was not cloned" not in result.stderr + assert box.origin_head() == head diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 7fa5f7a..eed396a 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -1,13 +1,21 @@ # 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). +"""A register edit that is already a local commit is PUBLISHED by the act its +RECOVERY names (opensoft/openRepoTools#194), and by nothing else. `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. +had exited 3 with the edit as a local commit, the RECOVERY it printed staged +nothing, exited 0 and pushed nothing. #214 made `commit` with nothing staged +publish the queue, behind a fence that vouches for every commit in it. + +AMENDMENT 23 S3-S4 (#198, slice 1b) KEEPS THE FENCE AND CHANGES THE ROAD. No +writer pulls: every write fetches and fast-forwards first, and refuses (2) +over a commit origin lacks by content, naming the publish act; a peer's +uncommitted file elsewhere no longer blocks anything (ratified choice 8). The +publish act is exempt from that one row, lists the queue, pushes only a +fast-forward and reads it back. The cases below that pinned the pull, the +straight push past a peer's file and the conflict recovery are rewritten to +the S3-S4 road; the fence's own cases stand as they were. 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 @@ -219,82 +227,83 @@ def _line_of(stderr: str, needle: str) -> int: 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. - """ + """THE ISSUE'S OWN SEQUENCE, ON S3-S4'S ROAD. Origin moves on; a peer's + handoff is uncommitted in the shared checkout; a register edit is a local + commit origin lacks. The next write refuses (2) and names the publish act; + the publish act, run exactly as that RECOVERY says, PUBLISHES it: one copy + on origin, the other workstation's commit kept, no commit of its own, and + the peer's file neither blocking nor carried (ratified choice 8).""" 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") + box.wgit("fetch", "-q", "origin") + box.wgit("merge", "-q", "--ff-only", "refs/remotes/origin/main") + _stranded_edit(box) - stranded = box.helper("commit", SUBJECT) - assert stranded.returncode == 3, stranded.stderr - assert "a peer's uncommitted file blocks the rebase" in stranded.stderr + stranded = box.helper("log", "NOTED", f"lane:{LANE}", "a note", + LANES_SESSION="0e3c9a52-6d1b-4b8e-9f3a-2a7c5d4e6f81") + assert stranded.returncode == 2, stranded.stderr + assert "1 unpublished commit(s)" in stranded.stderr + assert f'LANES_LANE={LANE} ' in stranded.stderr + assert ' commit ""' in stranded.stderr + assert "the publish act" in stranded.stderr, 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 "pushed origin/main (attempt 1)" 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 "a peer's line, mid-write" not in box.origin_file(PEER_HANDOFF), "never carried" + assert box.wgit("diff", "--name-only").split() == [PEER_HANDOFF], "and left as it was" 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}") + assert len(subjects) == 3, ("seed, the other workstation's write and the edit — no " + f"commit of the publish act'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.""" + """THE NO-OP, KEPT. Nothing staged and nothing on HEAD that origin lacks: + the same note, exit 0 and no commit. S3's fetch is its one network call + (a writer reads origin before it decides), and nothing is pushed.""" 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 "pushed" not in result.stderr, result.stderr + calls = box.net_calls() + assert len(calls) == 1, calls + assert " fetch " in f" {calls[0]} ", 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.""" + publish, so this is the no-op above — no pull, and the publish act makes + no fast-forward either (S4).""" 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 not any(" push " in f" {c} " or " pull " in f" {c} " for c in 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) +def test_publishing_past_a_peers_dirty_file_leaves_it_as_it_is(box): + """Amendment 23 S3, ratified choice 8: a peer's uncommitted file is no + longer a branch of the push loop (`dirty_elsewhere` went with the pull). + The publish pushes, no pull is made, and the file is left exactly as it + was found, on neither origin nor HEAD.""" + _stranded_edit(box) 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 + assert "pushed origin/main" in result.stderr, result.stderr + assert "WARNING: this checkout has uncommitted changes" not in 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 @@ -304,94 +313,71 @@ def test_publishing_past_a_peers_dirty_file_keeps_the_dirty_elsewhere_guard(box) 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): +def test_a_peers_unpushed_commit_refuses_the_write_and_is_never_reset_away(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.""" + unpushed commit. Under S3 there is no conflict to recover from: a write + over a commit origin lacks refuses (2) with nothing written, lists it by + content, says to stop where a `+` line is not the reader's own, and names + the publish act or an archive followed by `reset --keep` — never + `reset --hard`. The peer's commit and the hand edit are left 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. + assert result.returncode == 2, err + assert f"+ {peer_commit}" in err, err + # NIT 5 (lane openRepoShape-2, 6243280936): the line that stops 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.""" + keep = _line_of(err, "reset --keep refs/remotes/origin/main") + assert stop < keep, err + assert "reset --hard" not in err, err + assert "Nothing was written." in err + assert box.wgit("rev-parse", "HEAD").strip() == peer_commit + assert box.wgit("diff", "--name-only").split() == [REGISTER], "the hand edit is untouched" + + +def test_a_peers_uncommitted_file_no_longer_refuses_a_write(box): + """`refuse_dirty_checkout`'s guard went with the pull it protected + (ratified choice 8): a peer's mid-write file elsewhere does not refuse a + write, and is neither committed nor touched by it.""" 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. + # An event names the transcript taking the act (Amendment 7(b)). 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 + assert result.returncode == 0, result.stderr + assert box.wgit("diff", "--name-only").split() == [PEER_HANDOFF] + assert "mid-write" not in box.origin_file(PEER_HANDOFF) @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).""" + whose result is the line already there must push nothing and leave origin + untouched — and under Amendment 23 S3 it refuses (2) before it edits, + listing that commit, since every write over a commit origin lacks does. + 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 result.returncode == 2, result.stderr + assert not any(" push " in f" {c} " for c in 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 + assert "1 unpublished commit(s)" in result.stderr + assert f"+ {queued}" in result.stderr + assert "Nothing was written." in result.stderr def test_the_publish_counts_what_its_list_leaves_out(box): @@ -415,9 +401,13 @@ def _stranded_edit(box: Box) -> None: def _refused_untouched(box: Box, result, head: str, origin: str) -> None: + """Refused (2) by the fence ("nothing was pushed") or by S3 before it + ("Nothing was written."): either way no push was sent. S3's fetch is the + one network call a refusal may make now.""" assert result.returncode == 2, result.stderr - assert "nothing was pushed" in result.stderr, result.stderr - assert box.net_calls() == [], result.stderr + assert ("nothing was pushed" in result.stderr + or "Nothing was written." in result.stderr), result.stderr + assert not any(" push " in f" {c} " for c in 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" @@ -481,7 +471,8 @@ def _merge_in_queue(box: Box) -> str: 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.""" + register edit, the fence saw only the edit. S3's "not on main" row now + refuses it first, in the same words.""" box.queue("another lane: retire the tool", delete=TOOL) box.wgit("checkout", "-q", "-b", "scratch", "origin/main") _stranded_edit(box) @@ -548,15 +539,15 @@ def test_commit_refuses_a_queue_that_is_not_lane_toolings_to_publish(box, varian 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): +@pytest.mark.parametrize("peer", ["clean", "a-peer-mid-write"]) +def test_the_push_sends_the_commit_the_fence_read(box, peer): """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).""" + local — with or without a peer's file mid-write beside it (the straight + push that used to make a second road out went with the pull).""" _stranded_edit(box) - if path == "straight": + if peer == "a-peer-mid-write": box.peer_edit_in_wip("a peer mid-write") result = box.helper("commit", SUBJECT, FAKE_NET_RACE=TOOL) assert result.returncode == 0, result.stderr @@ -567,29 +558,30 @@ 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.""" +def test_a_queue_origin_already_holds_by_content_is_the_diverged_refusal(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` used to drop it and push + origin its own tip. Under S3 there is no pull: every commit on HEAD is on + origin by content and HEAD is not origin's ancestor, so the checkout has + DIVERGED, and the publish refuses (2), naming `reset --keep`, with no + push and nothing moved.""" _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") + head = box.wgit("rev-parse", "HEAD") 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 result.returncode == 2, result.stderr + assert "this checkout has diverged" in result.stderr, result.stderr + assert "reset --keep refs/remotes/origin/main" in 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 not any(" pull " in c or " push " in c for c in calls), calls assert box.ogit("rev-parse", "main") == origin - assert box.wgit("rev-parse", "HEAD") == origin + assert box.wgit("rev-parse", "HEAD") == head assert box.origin_subjects().count("the same edit, made elsewhere") == 1 @@ -609,8 +601,10 @@ def test_a_lane_rename_is_refused_and_told_what_carries_it(box): 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() == [] + # No later write carries it now: S3 refuses every write over it (#198). + assert "no later write carries it" in result.stderr + assert "make the rename again" in result.stderr + assert not any(" push " in f" {c} " for c in box.net_calls()) assert (box.wgit("rev-parse", "HEAD"), box.ogit("rev-parse", "main")) == (head, origin) @@ -647,5 +641,8 @@ def test_a_queued_commit_git_cannot_read_refuses(box): result = box.helper("commit", SUBJECT) assert result.returncode == 2, result.stderr - assert "nothing was pushed" in result.stderr, result.stderr + # The fence's refusal, or S3's own where it is the first to read the queue. + assert ("nothing was pushed" in result.stderr + or "Nothing was written." in result.stderr), result.stderr + assert not any(" push " in f" {c} " for c in box.net_calls()) assert box.ogit("rev-parse", "main") == origin diff --git a/tests/test_deferred_97_findings_157.py b/tests/test_deferred_97_findings_157.py index 0f63a1d..be93c30 100644 --- a/tests/test_deferred_97_findings_157.py +++ b/tests/test_deferred_97_findings_157.py @@ -150,6 +150,16 @@ def test_4d_a_malformed_local_row_with_no_vocabulary_is_legacy_and_its_writer_re result = helper(box, "managed-projection", LANE) assert result.returncode == 8, (result.stdout, result.stderr) before = fingerprint(box) + # AMENDMENT 23 S3 (#198, slice 1b): the commit is unpushed, so every writer + # refuses it first, by content, with nothing written — never as UNKNOWN. + result = helper(box, "set-row-state", LANE, "PAUSED · the writer asks") + assert result.returncode == 2, (result.stdout, result.stderr) + assert "1 unpublished commit(s)" in result.stderr + assert "UNKNOWN" not in result.stderr + assert fingerprint(box) == before + # Published, the same row is the writer's own row-shape refusal. + box.git("-C", str(box.wip), "push", "-q", "origin", "main") + before = fingerprint(box) result = helper(box, "set-row-state", LANE, "PAUSED · the writer asks") assert result.returncode == 2, (result.stdout, result.stderr) assert "separators where a seven-column row carries 6" in result.stderr diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index 9c4fef8..56e0bce 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -755,6 +755,14 @@ LANES="$WIP/lanes/LANES.md" } > "$LANES" add_seed_row() { printf '%s\n' "$1" >> "$LANES"; } +# A FIXTURE COMMIT IN THE SHARED CHECKOUT IS PUBLISHED AT ONCE (#198, slice +# 1b). Before Amendment 23 S3 the next lanes-edit.sh write pulled it onto its +# own and pushed both — the carry S3 withdrew. Every write now refuses over a +# commit origin lacks, so a fixture that commits here pushes too. +wip_publish() { + git -C "$WIP" pull -q --rebase origin main >/dev/null 2>&1 || : + git -C "$WIP" push -q origin main >/dev/null 2>&1 || : +} # repoA-1 a lane whose session has ENDED — free, and resumable # repoA-2 the same, with no transcript of its own @@ -1642,90 +1650,51 @@ has "…printing who holds it" "$out" "HOLDS lane repoE-1" has "…and refusing in Rule 1's words" "$err" "stops and reports" hasnt "…and writing nothing to the taker's log" "$(cat "$LOGD/repoF-1.md")" "opensoft/repoE#21" -# A claim is decided by which CLAIMED LANDS first, and a rebase is what makes -# that decidable — so a checkout that cannot be rebased is refused outright. +# AMENDMENT 23 S3, RATIFIED CHOICE 8 (#198 slice 1b). A claim used to refuse a +# checkout with any other tracked file dirty, because that file blocked the +# `pull --rebase` that decided the race. No writer pulls now: the race is +# decided by push order and S4's replay, so a peer's unstaged change elsewhere +# refuses only where the fast-forward would overwrite it. printf '# a peer left this here mid-write\n' >> "$WIP/handoffs/README.md" run env LANES_LANE=repoE-1 "$E" claim "opensoft/repoE#30" --no-github -is "claim refuses on a checkout that cannot be rebased" "$rc" 2 -has "…naming the offending file" "$err" "handoffs/README.md" -has "…and why it matters" "$err" "the race cannot be run safely here" +is "a peer's file mid-write elsewhere no longer refuses a claim (choice 8)" "$rc" 0 +has "…the claim is written to the lane's own log" "$(cat "$LOGD/repoE-1.md")" ", opensoft/repoE#30" +is "…and the peer's file is left exactly as it was found" "$(git -C "$WIP" status --porcelain -- handoffs/README.md | awk '{print $1}')" "M" +hasnt "…never committed" "$(git -C "$WIP" show --name-only --format= HEAD)" "handoffs/README.md" git -C "$WIP" checkout -q -- handoffs/README.md -run env LANES_LANE=repoE-1 "$E" claim "opensoft/repoE#30" --no-github -is "…and takes the claim once the tree is clean again" "$rc" 0 - -# R11 (Addendum 3) — the guard TOLERATES a peer's uncommitted REGISTER edit. -# `lanes/LANES.md` is dirty in this shared checkout for much of the day: the -# register takes ~500 commits a day and a half-written row is what one of them -# looks like a second before it lands, so refusing every claim on it would push -# lanes to stop using the tool. `claim`, `release` and `log` take the helper's -# existing pre-existing capture for the register FIRST — its own attributed -# commit, with the same warning every register write already prints — and only -# then re-test the checkout. What F1 fixed is untouched: the register never -# reaches commit_push dirty, so Amendment 5(d)'s skip-the-pull branch — the one -# that disables the rescan deciding the race — is still unreachable from all -# three. + +# RATIFIED CHOICE 9: A PENDING HAND EDIT OF THE REGISTER REFUSES EVERY WRITER BUT +# `commit`, with nothing written. R11 (Addendum 3) captured it instead, as a +# `LANES(pre-existing@…)` commit of the peer's line; that capture is withdrawn +# for every writer — the register's own pathspec writers (a LANDING) included — +# because the edit may be a peer's in this shared checkout, and it is committed +# by its own act. before_rev="$(git -C "$WIP" rev-parse HEAD)" printf 'a peer left this register line uncommitted\n' >> "$LANES" run env LANES_LANE=repoE-1 "$E" claim "opensoft/repoE#31" --no-github -is "a dirty lanes/LANES.md no longer refuses a claim: it is CAPTURED first" "$rc" 0 -has "…warning that the content is not this invocation's" "$err" "not this invocation's" -has "…and capturing it as its own attributed commit" "$err" "captured pre-existing edit" -has "…while the claim itself is written to the lane's own log" "$(cat "$LOGD/repoE-1.md")" ", opensoft/repoE#31" -is "…leaving the register clean behind it" "$(git -C "$WIP" status --porcelain -- lanes/LANES.md | grep -c .)" 0 -is "…as TWO commits, never one" "$(git -C "$WIP" rev-list --count "$before_rev..HEAD")" 2 -has "…the capture the older of the two" "$(git -C "$WIP" log --format=%s "$before_rev..HEAD" | tail -n1)" "LANES(pre-existing@" -is "…carrying the register and nothing else" "$(git -C "$WIP" show --name-only --format= 'HEAD~1')" "lanes/LANES.md" -has "…and the peer's own line, attributed to nobody else" "$(git -C "$WIP" show 'HEAD~1')" "+a peer left this register line uncommitted" -has "…the claim the newer" "$(git -C "$WIP" log --format=%s -n1 HEAD)" "LOG(repoE-1@" -is "…carrying the lane's log and nothing else" "$(git -C "$WIP" show --name-only --format= HEAD)" "lanes/log/repoE-1.md" - -printf 'and a second peer line, still uncommitted\n' >> "$LANES" +is "a dirty lanes/LANES.md refuses a claim (choice 9)" "$rc" 2 +has "…naming the act that commits it" "$err" "commit \"\"" +has "…with nothing written" "$err" "Nothing was written." +hasnt "…and no capture" "$err" "captured pre-existing edit" +hasnt "…so no line in the lane's log" "$(cat "$LOGD/repoE-1.md")" "opensoft/repoE#31" +is "…and no commit at all" "$(git -C "$WIP" rev-parse HEAD)" "$before_rev" +is "…the peer's register edit left exactly as it was found" "$(git -C "$WIP" status --porcelain -- lanes/LANES.md | awk '{print $1}')" "M" run env LANES_LANE=repoE-1 "$E" log OPENED "opensoft/repoE#32" --no-github -is "log takes the same capture and proceeds" "$rc" 0 -has "…capturing the peer's line first" "$err" "captured pre-existing edit" -has "…and writing its own line" "$(cat "$LOGD/repoE-1.md")" ", opensoft/repoE#32" -printf 'and a third peer line, still uncommitted\n' >> "$LANES" -run env LANES_LANE=repoE-1 "$E" release "opensoft/repoE#31" "capture then release" --no-github -is "release does too" "$rc" 0 -has "…capturing first" "$err" "captured pre-existing edit" -has "…and writing the RELEASED line" "$(cat "$LOGD/repoE-1.md")" ", opensoft/repoE#31 — capture then release" - -# …and ANYTHING ELSE is still a refusal, exit 2. That refusal is taken BEFORE -# the lock and before the capture, so a write that refuses never leaves a -# commit of somebody else's register line behind either. -before_rev="$(git -C "$WIP" rev-parse HEAD)" -printf '# a peer left this here mid-write\n' >> "$WIP/handoffs/README.md" -printf 'and a register line at the same moment\n' >> "$LANES" -run env LANES_LANE=repoE-1 "$E" claim "opensoft/repoE#35" --no-github -is "a dirty file that is NOT the register still refuses a claim" "$rc" 2 -has "…naming it" "$err" "handoffs/README.md" -has "…and why it matters" "$err" "the race cannot be run safely here" -has "…with the recovery its own author runs" "$err" "handoff(@)" -hasnt "…writing nothing to the lane's log" "$(cat "$LOGD/repoE-1.md")" "opensoft/repoE#35" -is "…and making no commit at all" "$(git -C "$WIP" rev-parse HEAD)" "$before_rev" -hasnt "…not even the pre-existing capture: the refusal comes first" "$err" "captured pre-existing edit" -is "…so the peer's register edit is left exactly as it was found" "$(git -C "$WIP" status --porcelain -- lanes/LANES.md | awk '{print $1}')" "M" -run env LANES_LANE=repoE-1 "$E" log OPENED "opensoft/repoE#36" --no-github -is "log refuses on the same checkout" "$rc" 2 -run env LANES_LANE=repoE-1 "$E" release "opensoft/repoE#32" "refused, never written" --no-github +is "log refuses on it too" "$rc" 2 +run env LANES_LANE=repoE-1 "$E" release "opensoft/repoE#30" "refused, never written" --no-github is "release refuses on it as well" "$rc" 2 -hasnt "…so the release was not written either" "$(cat "$LOGD/repoE-1.md")" "refused, never written" -git -C "$WIP" checkout -q -- handoffs/README.md - -# A LANDING reaches the same place by the other road: the register is one of -# ITS OWN pathspecs, so the ordinary handle_preexisting call captures it and -# R11's capture stands aside. One behaviour, two routes, and the register is -# never left dirty for commit_push either way. +hasnt "…so the release was not written" "$(cat "$LOGD/repoE-1.md")" "refused, never written" run env LANES_LANE=repoE-1 "$E" log LANDING "opensoft/repoE#41" --no-github -is "a LANDING still writes with a dirty register: that file is one of ITS pathspecs" "$rc" 0 -has "…capturing the peer's line as its own commit first" "$err" "captured pre-existing edit" -run env LANES_LANE=repoE-1 "$E" log LANDED "opensoft/repoE#41" "→" "def5678" -is "…and its LANDED closes it again" "$rc" 0 -printf '# a peer left this here mid-write\n' >> "$WIP/handoffs/README.md" -run env LANES_LANE=repoE-1 "$E" log LANDING "opensoft/repoE#42" --no-github -is "…while an UNRELATED dirty file refuses a LANDING too: the exemption is those two paths and no others" "$rc" 2 -has "…naming it" "$err" "handoffs/README.md" -git -C "$WIP" checkout -q -- handoffs/README.md +is "a LANDING, whose own pathspec the register is, refuses too" "$rc" 2 +is "…still with no commit" "$(git -C "$WIP" rev-parse HEAD)" "$before_rev" +run env LANES_LANE=repoE-1 "$E" commit "a peer's register line, committed by its own act" +is "commit is the act that commits it" "$rc" 0 +is "…as ONE commit" "$(git -C "$WIP" rev-list --count "$before_rev..HEAD")" 1 +has "…under its own subject" "$(git -C "$WIP" log --format=%s -n1 HEAD)" "a peer's register line, committed by its own act" +is "…published" "$(git -C "$WIP" rev-parse HEAD)" "$(git -C "$WIP" rev-parse refs/remotes/origin/main)" +is "…and the register is clean behind it" "$(git -C "$WIP" status --porcelain -- lanes/LANES.md | grep -c .)" 0 +run env LANES_LANE=repoE-1 "$E" release "opensoft/repoE#30" "released once the register is clean" --no-github +is "…and the release goes through once it is" "$rc" 0 # --------------------------------------------------- the cross-repo warning @@ -2133,9 +2102,22 @@ has "…writing a RELEASED line carrying the reason" "$(cat "$LOGD/repoE-1.md") # ---------------------------------------------------------- CLAIM-LOST # # The race is decided by which CLAIMED LANDS on main first. The peer commits -# and pushes; this lane's fetch is deferred, so its pre-check sees a main that -# does not yet carry the peer's line — and the peer's claim arrives exactly -# where it arrives in life: in the rebase inside its own push. +# and does NOT push; `race-at-push`, this claim's `LANES_GIT`, pushes the +# peer's commit at the moment this claim pushes. So the pre-check and S3's +# fetch both read a main without the peer's line, this claim's push is refused, +# and the replay on origin's new tip (Amendment 23 S4) rescans FIRST and finds +# it — exactly where a rival arrives in life. (Before #198's slice 1b this used +# `LANES_NO_FETCH=1`, which S3 now refuses for every writer: ratified choice 2.) +cat > "$SANDBOX/race-at-push" <<'RACE' +#!/usr/bin/env bash +# At the helper's FIRST push only: the peer's prepared commits land first. +case " $* " in *" push "*) + if [ ! -e "$RACE_MARK" ]; then : > "$RACE_MARK"; git -C "$RACE_CLONE" push -q origin main; fi ;; +esac +exec git "$@" +RACE +chmod +x "$SANDBOX/race-at-push" +race() { env LANES_GIT="$SANDBOX/race-at-push" RACE_CLONE="$CLONE2" RACE_MARK="$SANDBOX/race.$1" "${@:2}"; } CLONE2="$SANDBOX/clone2" git clone -q "$ORIGIN" "$CLONE2" 2>/dev/null git -C "$CLONE2" config user.email "peer@example.invalid" @@ -2146,10 +2128,10 @@ mkdir -p "$CLONE2/lanes/log" } > "$CLONE2/lanes/log/repoH-2.md" git -C "$CLONE2" add -- lanes/log/repoH-2.md git -C "$CLONE2" commit -q -m "LOG(repoH-2@Raven): CLAIMED opensoft/repoH#4" -git -C "$CLONE2" push -q origin main -run env LANES_LANE=repoH-1 LANES_NO_FETCH=1 "$E" claim "opensoft/repoH#4" --no-github +run race h4 env LANES_LANE=repoH-1 "$E" claim "opensoft/repoH#4" --no-github is "a claim whose rival landed first exits 7" "$rc" 7 +has "…found by the replay of its refused push (Amendment 23 S4)" "$err" "push refused (attempt 1 of 6)" has "…naming the lane that landed first" "$err" "another lane's claim landed on main before this one: @repoH-2" has "…and citing Rule 1 rather than queueing" "$err" "stop and report" has "…writing a CLAIM-LOST line in this lane's own log" "$(cat "$LOGD/repoH-1.md")" "CLAIM-LOST — lane repoH-1, session " @@ -2158,24 +2140,22 @@ run "$E" who "opensoft/repoH#4" has "…and the object is the winner's" "$out" "HOLDS lane repoH-2" is "…and the loser holds nothing" "$(printf '%s\n' "$out" | grep -c '^HOLDS')" 1 -# The winner is whichever rival is ALREADY on the rebased history — the one -# that LANDED first — and never the earliest timestamp. Two rivals land during -# one rebase window: repoH-2 lands FIRST carrying the LATER UTC. +# The winner is whichever rival is ALREADY on origin's new tip — the one that +# LANDED first — and never the earliest timestamp. Two rivals land at this +# claim's push, in one push: repoH-2 FIRST in history, carrying the LATER UTC. LATE_UTC="$(utc_at +10M)" EARLY_UTC="$(utc_at +5M)" git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null printf 'CLAIMED — lane repoH-2, session %s@Raven, %s, opensoft/repoH#60\n' "$DEAD_ID" "$LATE_UTC" >> "$CLONE2/lanes/log/repoH-2.md" git -C "$CLONE2" add -- lanes/log/repoH-2.md git -C "$CLONE2" commit -q -m "LOG(repoH-2@Raven): CLAIMED opensoft/repoH#60" -git -C "$CLONE2" push -q origin main { printf '# lane repoH-4 — object log (lane-collision-protocol Amendment 7)\n' printf 'CLAIMED — lane repoH-4, session %s@Raven, %s, opensoft/repoH#60\n' "$DEAD_ID" "$EARLY_UTC" } > "$CLONE2/lanes/log/repoH-4.md" git -C "$CLONE2" add -- lanes/log/repoH-4.md git -C "$CLONE2" commit -q -m "LOG(repoH-4@Raven): CLAIMED opensoft/repoH#60" -git -C "$CLONE2" push -q origin main -run env LANES_LANE=repoH-1 LANES_NO_FETCH=1 "$E" claim "opensoft/repoH#60" --no-github +run race h60 env LANES_LANE=repoH-1 "$E" claim "opensoft/repoH#60" --no-github is "a claim that two rivals beat to main exits 7" "$rc" 7 has "…naming the one that LANDED first" "$err" "@repoH-2" hasnt "…never the one whose timestamp is earlier" "$err" "@repoH-4" @@ -2211,20 +2191,29 @@ is "--force reads staleness from origin/main too, so a four-hour-old claim wit has "…in Rule 1's own words" "$err" "has since OPENED a PR naming it" hasnt "…and no TAKEOVER is written on a claim that is not stale" "$(cat "$LOGD/repoH-1.md")" "opensoft/repoH#51" -# ------------------------- R11 and the race, in the same run (F1's own repro) +# ------------------------- a dirty register, and then the race (F1's own repro) # -# The two halves together: a rival's CLAIMED is already on `main`, and THIS -# checkout carries a peer's half-written register line. Before F1 this sequence -# left an unpushed local CLAIMED and two lanes holding one object; after F1 and -# before R11 it was an exit-2 refusal on the one file this checkout is dirty in -# most of the day. Now the capture clears the way, `pull --rebase` runs, the -# rescan sees the rival, and the loser abandons the claim: one holder, exit 7. -peer_log_line "CLAIMED — lane repoH-2, session $DEAD_ID@Raven, $(date -u +%Y-%m-%dT%H:%M:%SZ), opensoft/repoH#70" "CLAIMED opensoft/repoH#70" +# F1's sequence: a rival's CLAIMED lands, and THIS checkout carries a peer's +# half-written register line. Before F1 it left an unpushed local CLAIMED and +# two lanes holding one object; R11 then captured the line and raced. Under +# Amendment 23 S3 (ratified choice 9) the dirty register refuses the claim +# before anything is read or written, and `commit` is the act that commits it; +# the race that follows is lost by the claim, with exit 7 and one holder. printf 'a peer left this register line uncommitted\n' >> "$LANES" -run env LANES_LANE=repoH-1 LANES_NO_FETCH=1 "$E" claim "opensoft/repoH#70" --no-github -is "a claim raced from a checkout with a dirty register still LOSES, exit 7" "$rc" 7 -has "…having captured the peer's register line on the way" "$err" "captured pre-existing edit" -has "…and naming the lane whose claim landed on main first" "$err" "another lane's claim landed on main before this one: @repoH-2" +run race h70a env LANES_LANE=repoH-1 "$E" claim "opensoft/repoH#70" --no-github +is "a claim from a checkout with a dirty register is refused before it races (choice 9)" "$rc" 2 +has "…naming the act that commits the register" "$err" "commit \"\"" +hasnt "…writing nothing in this lane's log" "$(cat "$LOGD/repoH-1.md")" "opensoft/repoH#70" +is "…and pushing nothing: the race was never reached" "$(test -e "$SANDBOX/race.h70a" && echo raced || echo quiet)" "quiet" +run env LANES_LANE=repoH-1 "$E" commit "a peer's register line" +is "…the register line goes in by its own act" "$rc" 0 +git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null +printf 'CLAIMED — lane repoH-2, session %s@Raven, %s, opensoft/repoH#70\n' "$DEAD_ID" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$CLONE2/lanes/log/repoH-2.md" +git -C "$CLONE2" add -- lanes/log/repoH-2.md +git -C "$CLONE2" commit -q -m "LOG(repoH-2@Raven): CLAIMED opensoft/repoH#70" +run race h70 env LANES_LANE=repoH-1 "$E" claim "opensoft/repoH#70" --no-github +is "and the claim raced next LOSES, exit 7" "$rc" 7 +has "…naming the lane whose claim landed on main first" "$err" "another lane's claim landed on main before this one: @repoH-2" has "…writing CLAIM-LOST in this lane's own log" "$(cat "$LOGD/repoH-1.md")" "opensoft/repoH#70 → lane:repoH-2" is "…with the register left clean" "$(git -C "$WIP" status --porcelain -- lanes/LANES.md | grep -c .)" 0 run "$E" who "opensoft/repoH#70" @@ -2237,17 +2226,16 @@ has "…the one that landed first" "$out" "HOLDS lane repoH-2" # change for that verdict to go stale, because a RESUMED line is the LANE's # own log, never an event `claim_rescan_hook`'s rival scan reads. repoT-1 is # dead exactly as repoL-1 (seeded above); the peer resumes it on the SAME -# stale-pre-fetch trick the CLAIM-LOST races above use — pushed to origin -# before this claim runs, but invisible to its own pre-check under -# LANES_NO_FETCH=1, so the takeover is granted on knowledge that is already -# out of date by the time its own push loop rebases onto what really landed. +# `race-at-push` the CLAIM-LOST races above use — landed at this claim's push, +# so the takeover is granted on knowledge that is out of date by the time the +# replay of its refused push runs on what really landed, with the takeover it +# decided on carried across the `exec` (Amendment 23 S4). git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null printf 'RESUMED — lane repoT-1, session %s@Eagle, %s, lane:repoT-1\n' "$DEAD_ID" "$(utc_at +1M)" >> "$CLONE2/lanes/log/repoT-1.md" git -C "$CLONE2" add -- lanes/log/repoT-1.md git -C "$CLONE2" commit -q -m "LOG(repoT-1@Eagle): RESUMED" -git -C "$CLONE2" push -q origin main -run env LANES_LANE=repoT-2 LANES_NO_FETCH=1 "$E" claim "opensoft/repoT#1" --no-github --force +run race t1 env LANES_LANE=repoT-2 "$E" claim "opensoft/repoT#1" --no-github --force is "a dead-lane takeover whose source RESUMES before the push lands aborts, exit 9 (never 7, a RIVAL's own code)" "$rc" 9 has "…naming the lane that is alive again" "$err" "lane repoT-1 is no longer confirmed dead" has "…and citing Rule 1 rather than completing the takeover" "$err" "stop and report; do not author a successor" @@ -2263,17 +2251,16 @@ has "…held by the revived lane itself" "$out" "HOLDS lane repoT-1" # status was `awk`'s, so an unreadable log came back as an empty verdict with # no marker set, and the abort told the operator the log had "moved past the # terminal line" — which nothing had read. The peer removes repoTx-1's log -# from origin on the same stale-pre-fetch trick, so the grant is made on the -# old ref and the recheck after the rebase finds no log to read. It still +# from origin at this claim's push, the same `race-at-push`, so the grant is +# made on the old tip and the replay's recheck finds no log to read. It still # aborts with 9, in words that say only what was established. The log is put # back afterwards so no later section meets a row with no log. git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null git -C "$CLONE2" rm -q -- lanes/log/repoTx-1.md git -C "$CLONE2" commit -q -m "fixture: repoTx-1's log is unreadable on the takeover's recheck" -git -C "$CLONE2" push -q origin main TX_GONE_AT="$(git -C "$CLONE2" rev-parse HEAD)" -run env LANES_LANE=repoTx-2 LANES_NO_FETCH=1 "$E" claim "opensoft/repoTx#1" --no-github --force +run race tx1 env LANES_LANE=repoTx-2 "$E" claim "opensoft/repoTx#1" --no-github --force is "a dead-lane takeover whose source log CANNOT BE READ on the recheck aborts, exit 9" "$rc" 9 has "…saying the dead verdict could not be reconfirmed because the log was not read" "$err" "its own object log could not be read, or yielded no lane-kind line at all" hasnt "…never that the log moved past its terminal line, which nobody read" "$err" "has moved past the terminal line" @@ -3168,9 +3155,8 @@ git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null "harness \`$ZC1\`" "harness \`$ZC1\` → harness \`$ZC2\`" "the session stamps its own row" ) >/dev/null 2>&1 # First, the reviewer's own sequence: this checkout is BEHIND and has not pulled. -# `--dry-run`, because a checkout that is behind cannot write its row at all — -# `commit_push` rebases the status on to the peer's line, conflicts, and aborts, -# which is a different refusal and already the right one. +# `--dry-run`, which plans from the same reads and writes nothing; the durable +# half below runs the same launch for real. is "the peer's new session id is on origin/main and not in this checkout" \ "$(grep '^| `repoZC-1`' "$LANES" | grep -c "$ZC2" || :)" 0 before_rev="$(git -C "$WIP" rev-parse HEAD)" @@ -3183,14 +3169,21 @@ hasnt "…never the id this checkout's copy ends on" "$err" "$ZC1" has "…and the status it plans to append names the same id" "$err" "launching claude --name repoZC-1 --resume $ZC2" is "…while --dry-run still writes nothing" "$(git -C "$WIP" rev-parse HEAD)" "$before_rev" -# And the durable half: the same divergence with HEAD already current, so the -# row write lands and the `RESUMED` line is really written. This checkout's COPY -# of the row is the older one — the shape R19's second reproduction uses, and -# the shape this register is in for much of the day (R11). -git -C "$WIP" pull -q --rebase origin main 2>/dev/null -awk -v drop=" → harness \`$ZC2\`" '{ if ($0 ~ /^\| `repoZC-1`/) { i = index($0, drop); if (i) $0 = substr($0, 1, i - 1) substr($0, i + length(drop)) } print }' \ - "$LANES" > "$SANDBOX/lanes.tmp" -cat -- "$SANDBOX/lanes.tmp" > "$LANES" +# And the durable half: the same divergence, so the row write lands and the +# `RESUMED` line is really written. This checkout's COPY of the row is the +# older one because the checkout is BEHIND: HEAD is still the commit before the +# peer's stamp — the shape R19's second reproduction uses, and the shape this +# register is in for much of the day (R11). Amendment 23 S3 fast-forwards it +# under the lock before each write, so the writes land on origin's tip, and the +# resume target is read before them, from the published cell. (Until slice 1b +# the fixture made the copy older with an uncommitted hand edit of the row on a +# current HEAD; S3 now refuses every writer over a pending register edit, +# ratified choice 9, so that fixture measured the refusal and not R33.) +git -C "$WIP" fetch -q origin main 2>/dev/null +is "this checkout is behind origin/main, not diverged from it" \ + "$(git -C "$WIP" merge-base --is-ancestor HEAD refs/remotes/origin/main && git -C "$WIP" rev-list --count HEAD..refs/remotes/origin/main | grep -cv '^0$')" 1 +is "…with no hand edit in the register" \ + "$(git -C "$WIP" status --porcelain -- lanes/LANES.md | grep -c .)" 0 is "this checkout's copy of the row is older than the one on origin/main" \ "$(grep '^| `repoZC-1`' "$LANES" | grep -c "$ZC2" || :)" 0 is "…while origin/main still names both ids" \ @@ -3795,6 +3788,13 @@ has "…told to LOOK before pushing again, never to push blind" "$err" "git -C has "…the re-push conditional on what that look finds" "$err" "git -C $WIP_REAL push origin main # only if it is not there" has "…and F-B7's sentence for a handoff INSIDE the register's checkout, verbatim" "$err" "(this handoff is in the register's own checkout, so the next lanes-edit.sh write out of it pushes the branch and carries this commit with it.)" hasnt "…never the other branch's, which would be the wrong promise here" "$err" "no lanes-edit.sh write will ever push it" +# AMENDMENT 23 S3 (#198, slice 1b): this stamp is now a commit origin lacks, +# and every lanes-edit.sh write out of this checkout REFUSES over it instead of +# carrying it out (lane-start's own note still promises the carry; it is slice +# 2b's to reword). So the fixture publishes it here, as its owner would, and +# the sections below start from a checkout with nothing unpublished. +git -C "$WIP" push -q origin main +is "…the stranded stamp is published before any later write meets it (S3)" "$(git -C "$WIP" fetch -q origin && git -C "$WIP" rev-list --count refs/remotes/origin/main..HEAD)" 0 # (b) THE HANDOFF IS RESOLVED THROUGH `LANES_WORKSPACE_ROOT`, into a checkout no # `lanes-edit.sh` write ever goes out of. Nothing will carry this commit, @@ -3859,6 +3859,13 @@ cn_push="$(printf '%s\n' "$err" | grep -n -- 'pull --rebase origin main && ' | h is "…the look printed BEFORE the re-push it gates, which is the whole of RV-B3" \ "$([ -n "$cn_look" ] && [ -n "$cn_push" ] && [ "$cn_look" -lt "$cn_push" ] && printf 'look first' || printf "look $cn_look, push $cn_push")" "look first" has "…and F-B7's carry note under it, the same two branches as the push case" "$err" "(this handoff is in the register's own checkout, so the next lanes-edit.sh write out of it pushes the branch and carries this commit with it.)" +# AMENDMENT 23 S3 (#198, slice 1b): this stamp is now a commit origin lacks, +# and every lanes-edit.sh write out of this checkout REFUSES over it instead of +# carrying it out (lane-start's own note still promises the carry; it is slice +# 2b's to reword). So the fixture publishes it here, as its owner would, and +# the sections below start from a checkout with nothing unpublished. +git -C "$WIP" push -q origin main +is "…the aborted pull's stamp is published before any later write meets it (S3)" "$(git -C "$WIP" fetch -q origin && git -C "$WIP" rev-list --count refs/remotes/origin/main..HEAD)" 0 # ------------------------------------------------- lane-start --confirm / --yes # @@ -4572,20 +4579,27 @@ FAKE chmod +x "$SANDBOX/hanggit" "$SANDBOX/peekgit" run env LANES_GIT="$SANDBOX/hanggit" LANES_GIT_TIMEOUT=2 "$E" set-row-state repoGH-1 "LIVE · HUNGPUSH" +# AMENDMENT 23 S4 (#198, slice 1b): a push that times out is not a failure to +# report as one. It goes to the read-back, and a push the read-back cannot +# find on origin is 3, `pushed, not confirmed: `, never success, with the +# commit kept on HEAD for the publish act. if [ "$HAVE_TIMEOUT" = 1 ]; then is "a push that hangs past the timeout exits 3" "$rc" 3 - has "…saying so, with the budget it was given" "$err" "TIMED OUT after 2s" - has "…and that the mutex is not held through it" "$err" "the mutex is released" - has "…telling the writer a hung push may have LANDED before it looks again" "$err" "often means it LANDED" + has "…saying so, with the budget it was given" "$err" "the push timed out after 2s" + has "…as a push it cannot confirm, by its sha" "$err" "pushed, not confirmed: $(git -C "$WIP" rev-parse HEAD)" + has "…and never as success" "$err" "never reported as success" else skip "a push that hangs past the timeout exits 3" "$NO_TIMEOUT_WHY" skip "…saying so, with the budget it was given" "$NO_TIMEOUT_WHY" - skip "…and that the mutex is not held through it" "$NO_TIMEOUT_WHY" - skip "…telling the writer a hung push may have LANDED before it looks again" "$NO_TIMEOUT_WHY" + skip "…as a push it cannot confirm, by its sha" "$NO_TIMEOUT_WHY" + skip "…and never as success" "$NO_TIMEOUT_WHY" fi is "…the lock is released, not left for the next lane to age out" "$([ -d "$H_LOCK" ] && printf held || printf free)" "free" has "…and the edit is safe as a local commit" "$(git -C "$WIP" log --oneline -n1)" "HUNGPUSH" has "…which is the row, really written" "$(grep '^| `repoGH-1`' "$LANES")" "HUNGPUSH" +run env LANES_LANE=repoGH-1 "$E" commit "publish the push that hung" +is "…and the publish act sends it, so no later write refuses over it" "$rc" 0 +is "…with nothing left on HEAD that origin lacks" "$(git -C "$WIP" rev-list --count refs/remotes/origin/main..HEAD)" 0 # The lock names its holder, and a lock whose holder is DEAD is stale at once — # not ten minutes later, which is how long every other writer used to wait. @@ -4643,32 +4657,27 @@ fi rm -rf "$H_LOCK" # ========================================================================= -# #32 — `commit_push` NAMES THE ATTEMPT THAT ACTUALLY LANDED, NEVER AN -# EARLIER ONE ORIGIN HAS SINCE OVERTAKEN. +# #32, AND AMENDMENT 23 S4 — A WRITE NAMES THE ATTEMPT THAT ACTUALLY LANDED, +# AND A PEER'S REWRITE OF THE SAME ROW IS NO LONGER A CONFLICT. # # Observed on Eagle, three to four times in one hour on 2026-09-13/14 while # several lanes wrote the register concurrently: `set-row-state` / # `append-line` printed "rebase conflict on origin/main — nothing was -# pushed" for a commit that WAS on origin by the time anyone read the line — -# carried out by the very next lanes-edit.sh write through this same shared -# checkout, which pulls this dangling local commit onto its own and pushes -# both together. A caller who trusted "nothing was pushed" and redid the -# write wrote a duplicate line (issue #32). The second clone plays the peer -# that moves origin between this invocation's own commit and its push, -# exactly as it does for the lost-claim race above. +# pushed" for a commit that WAS on origin by the time anyone read the line, +# carried out by the next write through this shared checkout (issue #32). +# Under #198's slice 1b there is no pull and so no rebase conflict: every +# write fetches and fast-forwards under its lock first (S3), and a push +# refused because origin moved drops the write's commit and runs the write +# again on origin's new tip (S4). The second clone plays the peer that moves +# origin between this invocation's own commit and its push. # -# THIS SANDBOX'S OWN CHECKOUT IS ALREADY DIRTY, from the rejectgit cases -# above (a stamp whose push or pull was faked to fail leaves its commit -# local, and the fixture files those cases wrote are still uncommitted -# where their own act never got to commit them) — real content, and none of -# it this section's. `dirty_elsewhere` would read it as "someone else's -# uncommitted file" and route every case below through Amendment 5(d)'s -# skip-the-pull branch instead of the rebase this section means to exercise, -# so it is committed first, exactly as its own author would. +# THIS SANDBOX'S OWN CHECKOUT MAY STILL HOLD FIXTURE FILES earlier cases left +# uncommitted: real content, and none of it this section's. It is committed +# and published first, exactly as its own author would. # ------------------------------------------------------------------------- if [ -n "$(git -C "$WIP" diff --name-only 2>/dev/null)" ]; then git -C "$WIP" add -u -- . - git -C "$WIP" commit -q -m "fixture cleanup: commit content earlier cases left uncommitted, before the #32 race/conflict cases" + git -C "$WIP" commit -q -m "fixture cleanup: commit content earlier cases left uncommitted, before the #32 race cases" git -C "$WIP" pull -q --rebase origin main 2>/dev/null || : git -C "$WIP" push -q origin main fi @@ -4676,17 +4685,17 @@ git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null RC_ID="aaaa0005-c0c0-4000-8000-aaaa0005c0c0" "$E" add-row "| \`repoRC-1\` | harness \`$RC_ID\` | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoRC/x.md | ACTIVE |" >/dev/null 2>&1 -# -- a race a LATER attempt wins: not a conflict, and the message must name -# the attempt that actually pushed, never attempt 1's rejection — and the -# retry must not write the row's own text twice. +# -- a race a LATER attempt wins: the message must name the attempt that +# actually pushed, never attempt 1's refusal — and the replay must not write +# the row's own text twice. RACE_FLAG="$SANDBOX/race-peer-landed" rm -f -- "$RACE_FLAG" cat > "$SANDBOX/racegit" <> "$CLONE2/lanes/log/repoH-2.md" git -C "$CLONE2" add -- lanes/log/repoH-2.md - git -C "$CLONE2" commit -q -m "LOG(repoH-2@Raven): lands between this attempt's pull and its push" + git -C "$CLONE2" commit -q -m "LOG(repoH-2@Raven): lands between this attempt's commit and its push" git -C "$CLONE2" push -q origin main >/dev/null 2>&1 fi done @@ -4703,17 +4712,18 @@ FAKE chmod +x "$SANDBOX/racegit" run env LANES_GIT="$SANDBOX/racegit" "$E" set-row-state repoRC-1 "LIVE · RACE-RETRY-WINS" -is "a peer landing between this attempt's pull and its push still exits 0" "$rc" 0 +is "a peer landing between this attempt's commit and its push still exits 0" "$rc" 0 +has "…attempt 1's push refused, and the write run again" "$err" "push refused (attempt 1 of 6)" has "…the message names attempt 2, the one that actually pushed" "$err" "pushed origin/main (attempt 2)" hasnt "…never claiming attempt 1 pushed it" "$err" "pushed origin/main (attempt 1)" -is "…and the retry writes the row's text once, never twice" "$(grep -c 'RACE-RETRY-WINS' "$LANES")" 1 - -# -- a race the rebase truly cannot resolve: the peer rewrote the SAME row. -# `rebase --abort` must leave the tree clean, the message must say so -# truthfully, and — because a write that runs after this one carries the -# dangling commit out regardless of what conflicted here — it must say to -# LOOK before resetting or redoing anything, the same rule RV-B3 took for -# the handoff stamp's own push in `lane-start`. +is "…and the replay writes the row's text once, never twice" "$(grep -c 'RACE-RETRY-WINS' "$LANES")" 1 +is "…with nothing left on HEAD that origin lacks" "$(git -C "$WIP" rev-list --count refs/remotes/origin/main..HEAD)" 0 + +# -- the peer rewrote the SAME row before this write. Until slice 1b that was a +# rebase conflict, exit 3 and a recovery ending in `reset --hard`. Now the +# write fetches and fast-forwards first, so the peer's row is the base it +# edits: exit 0, this write's state on origin's tip, no rebase, nothing left +# local, and no recovery text to read. git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null PC_LINE="$(grep -n '^| `repoRC-1`' "$CLONE2/lanes/LANES.md" | head -n1 | cut -d: -f1)" PC_ROW="$(sed -n -e "${PC_LINE}p" "$CLONE2/lanes/LANES.md")" @@ -4728,67 +4738,12 @@ git -C "$CLONE2" commit -q -m "LANES(repoRC-1@Raven): peer rewrites the same row git -C "$CLONE2" push -q origin main run "$E" set-row-state repoRC-1 "LIVE · OURS-CONFLICT" -is "a peer's rewrite of the SAME row is a real conflict: exit 3" "$rc" 3 -has "…REBASE CONFLICT is still named" "$err" "REBASE CONFLICT on origin/main" -has "…the abort runs and says the tree is clean" "$err" "rebase ABORTED — the worktree is clean and NOT mid-rebase" -is "…and it really is clean" "$(git -C "$WIP" status --porcelain | grep -c .)" 0 -has "…told to fetch before resetting or redoing anything" "$err" \ - "fetch origin main || echo FETCH-FAILED" -has "…and STOP named for a FAILED fetch too, never treated as leave to redo" "$err" \ - "if that printed FETCH-FAILED, STOP and retry — a stale or unreachable origin proves nothing either way" - -# Copilot's round-4 review of #50 (5203455553): the very peer write this -# recovery exists for carries a dangling commit by REBASING it onto a newer -# base — which the branch just above this one takes — and a rebase changes -# the commit's sha without changing its patch. `merge-base --is-ancestor -# $cp_sha …` (round 2's own fix) checks the OLD sha, so it can still answer -# "not-there" once the change is published under a new one. `git cherry` -# compares by PATCH, so it finds it either way; verified against a real bare -# repo for a peer-rebased commit (0, correctly ALREADY THERE — where -# `merge-base --is-ancestor` on the same sha answered "not-there"), a -# genuinely local-only one (1, correctly not-there), and a fetch against a -# bad remote (FETCH-FAILED, cherry never run against a stale ref). -has "…checked by CONTENT (git cherry), never by a sha a peer's rebase can change" "$err" \ - "cherry origin/main" -cp_sha_printed="$(printf '%s\n' "$err" | grep -oE 'cherry origin/main [0-9a-f]{7,40}' | head -n1 | awk '{print $3}')" -is "…naming a real local commit to check, not a placeholder" \ - "$([ -n "$cp_sha_printed" ] && printf 'has-sha' || printf 'MISSING')" "has-sha" -has "…counting only the patches git cherry could not find upstream" "$err" \ - "grep -c '^+'" -has "…the reset is now conditional on that look, not unconditional" "$err" \ - "only if it is not there: read back exactly what you wrote" -has "…and the die itself is scoped to this attempt, never a permanent claim" "$err" \ - "not pushed by this attempt; a later write from this checkout may already carry it" -hasnt "…never the old unconditional claim an aborted pull cannot prove" "$err" \ - "rebase conflict on origin/main — nothing was pushed." -pc_fetch="$(printf '%s\n' "$err" | grep -n -- 'fetch origin main || echo FETCH-FAILED' | head -n1 | cut -d: -f1)" -pc_look="$(printf '%s\n' "$err" | grep -n -- 'cherry origin/main' | head -n1 | cut -d: -f1)" -pc_reset="$(printf '%s\n' "$err" | grep -n -- 'reset --hard origin/main' | head -n1 | cut -d: -f1)" -is "…fetched, THEN checked by content, THEN reset — the same order RV-B3 pins" \ - "$([ -n "$pc_fetch" ] && [ -n "$pc_look" ] && [ -n "$pc_reset" ] && [ "$pc_fetch" -lt "$pc_look" ] && [ "$pc_look" -lt "$pc_reset" ] && printf 'ordered' || printf "fetch $pc_fetch, look $pc_look, reset $pc_reset")" "ordered" - -# Copilot's review of #50 (5202640056), suppressed comment on lanes-edit.sh:1180: -# the gate above was attached only to the diagnostic `diff` line — a caller -# whose fetch/look just showed the commit already on origin was still told, by -# the next two unconditional lines, to reset and redo, which is the exact -# duplicate this PR exists to prevent. A single STOP between the look and the -# reset gates all three lines below it (diff, reset, redo) at once, the same -# job the existing "only if it is not there" qualifier does for the diff line -# alone. -has "…and told to STOP there instead of resetting or redoing a landed commit" "$err" \ - "if that printed 0, STOP — reset or redo now would write it a second time." -pc_stop="$(printf '%s\n' "$err" | grep -n -- 'STOP — reset or redo now' | head -n1 | cut -d: -f1)" -is "…the STOP sits between the look and the reset, gating both reset and redo" \ - "$([ -n "$pc_look" ] && [ -n "$pc_stop" ] && [ -n "$pc_reset" ] && [ "$pc_look" -lt "$pc_stop" ] && [ "$pc_stop" -lt "$pc_reset" ] && printf 'ordered' || printf "look $pc_look, stop $pc_stop, reset $pc_reset")" "ordered" - -# Suite housekeeping: this conflict is real and stays real (the peer's row -# rewrite is still on origin), so nothing later inherits this section's own -# dangling commit — and nothing later finds $WIP behind the peer's push -# either, which every case below this one assumes it never is. Fully synced -# to the CURRENT origin/main (the abort's own fetch already has it), not -# merely one commit back from wherever HEAD happened to be. -git -C "$WIP" fetch -q origin main 2>/dev/null || : -git -C "$WIP" reset --hard origin/main >/dev/null 2>&1 +is "a peer's earlier rewrite of the SAME row is the base this write edits: exit 0" "$rc" 0 +has "…its own state on origin's tip" "$(git -C "$WIP" show refs/remotes/origin/main:lanes/LANES.md | grep '^| `repoRC-1`')" "OURS-CONFLICT" +hasnt "…no REBASE CONFLICT" "$err" "REBASE CONFLICT" +hasnt "…and no recovery that resets with --hard" "$err" "reset --hard" +is "…the tree clean" "$(git -C "$WIP" status --porcelain -- lanes | grep -c .)" 0 +is "…and nothing left on HEAD that origin lacks" "$(git -C "$WIP" rev-list --count refs/remotes/origin/main..HEAD)" 0 # --------------------------------------- the pointer file, when it does NOT answer # @@ -5178,6 +5133,7 @@ hasnt "…while writing nothing at all" "$(git -C "$WIP" log --format=%s -n1)" " # reason — a fixture step, not a fix to anything under test. git -C "$WIP" add -A >/dev/null 2>&1 git -C "$WIP" commit -q -m "commit pending handoff edits before the clause (c) write cases" >/dev/null 2>&1 || : +wip_publish # Amendment 23 S3: a write refuses over a commit origin lacks, so the fixture publishes it run env LANES_LANE=repoA11w-1 "$E" log RESUMED "lane:repoA11w-1" '→' "swap; workstation Eagle" "tick 8.4 landed, and 5.7a, 5.9a stay unheld" is "…while a comma in the FREE TEXT is ordinary prose and is written" "$rc" 0 has "…exactly as it was given" "$(tail -n1 "$LOGD/repoA11w-1.md")" "landed, and 5.7a, 5.9a stay unheld" @@ -5985,6 +5941,7 @@ git init -q -b main "$HOME/projects/repoA11b" git -C "$HOME/projects/repoA11b" remote add origin "https://github.com/opensoft/repoA11b.git" git -C "$WIP" add -A >/dev/null 2>&1 git -C "$WIP" commit -q -m "commit pending handoffs before the Amendment 11 lane-start cases" >/dev/null 2>&1 || : +wip_publish # Amendment 23 S3: a write refuses over a commit origin lacks, so the fixture publishes it FAKE_TMUX_WINDOW="a11start:@42" FAKE_TMUX_WINDOW_INDEX=3 FAKE_TMUX_WINDOW_NAME=claude \ CLAUDE_PROFILE_NAME=team-05a run "$START" repoA11b 1 --no-launch is "lane-start exits 0 for a new lane under Amendment 11" "$rc" 0 @@ -8499,6 +8456,7 @@ section "== Amendment 15: a lane name is one name under any case ==" # cannot rebase — which is those cases' subject and not this one's. git -C "$WIP" add -A >/dev/null 2>&1 git -C "$WIP" commit -q -m "commit the sandbox's pending edits before the Amendment 15 cases" >/dev/null 2>&1 || : +wip_publish # Amendment 23 S3: a write refuses over a commit origin lacks, so the fixture publishes it # THE FIXTURE IS THE INCIDENT'S OWN SHAPE: a MIXED-CASE row, a checkout whose # directory is spelled that way too, and a log file already written under the @@ -8992,6 +8950,7 @@ has "…as does the ENDED line in the lane's log" \ # and every case after it red for a reason that is not about this amendment. git -C "$WIP" add -A >/dev/null 2>&1 git -C "$WIP" commit -q -m "commit the sandbox's pending edits before the round-4 cases" >/dev/null 2>&1 || : +wip_publish # Amendment 23 S3: a write refuses over a commit origin lacks, so the fixture publishes it add_seed_row "| \`repoTwo-1\` | harness \`$DEAD_ID\` | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoTwo/x.md | ACTIVE |" add_seed_row "| \`repoLate-1\` | harness \`$DEAD_ID\` | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoLate/x.md | ACTIVE |" add_seed_row "| \`repoDrop-1\` | harness \`$DEAD_ID\` | Eagle / test / brett | 2026-09-11T00:00Z | none | handoffs/repoDrop/x.md | ACTIVE |" @@ -9165,10 +9124,10 @@ has "…citing the merge that is a person's act" "$err" "Amendment 15(d)" # ---- add-row ASKS THE PUBLISHED REGISTER, NOT ONLY THIS COPY OF IT ----------- # # The case-duplicate refusal scanned `$LANES_FILE` alone, so a row `origin` -# already carries is invisible on a checkout that is behind: `add-row` appends -# the second spelling, `commit_push` rebases the first in underneath it, and the -# pair 15(a) makes this command refuse is published by the command that refuses -# it. +# already carries is invisible on a checkout that is behind: `add-row` appended +# the second spelling, the old push loop rebased the first in underneath it, and +# the pair 15(a) makes this command refuse was published by the command that +# refuses it. a15_publish_row "| \`repoPush-1\` | harness \`$DEAD_ID\` | Eagle / test / brett | 2026-09-14T00:00Z | none | handoffs/repoPush/x.md | ACTIVE |" is "the row is on origin/main and not in this checkout" \ "$(grep -ci '^| `repopush-1` ' "$LANES" || :)" 0 @@ -9176,7 +9135,7 @@ run "$E" add-row "| \`repopush-1\` | harness \`$DEAD_ID\` | Eagle / test / brett is "add-row refuses a row the PUBLISHED register already carries under another case" "$rc" 2 has "…naming the row that is there, in its own spelling" "$err" "spelled repoPush-1" has "…citing the clause that makes the two one lane" "$err" "Amendment 15(a)" -has "…and naming the one command that makes this checkout current" "$err" "pull --rebase" +has "…and naming the verbs that bring this checkout to it (Amendment 23 S3)" "$err" "each fast-forwards this checkout to it first" is "…adding nothing" "$(grep -ci '^| `repopush-1` ' "$LANES" || :)" 0 # AND IT STILL REFUSES A ROW ONLY THIS CHECKOUT HAS, which is the half the local # scan is there for: a row added here and not yet pushed is on no origin at all. @@ -9190,6 +9149,14 @@ has "…named in its own spelling" "$err" "spelled repoTwo-1" # at all, so the state a failed rename leaves — the canonical file here, the # old-cased path still in the remote tree — walked past it, and the next write # staged the canonical path alone and left the published one where it was. +# +# AND SINCE AMENDMENT 23 S3 THE TWO FILES MEET IN THIS CHECKOUT (rv1b's NIT 2 +# on slice 1b): the write fast-forwards before `ensure_log` runs, so the +# published old-cased log arrives beside the untracked canonical copy, and the +# refusal is the two-logs one. One tracked and one untracked is the stray copy +# a failed rename leaves, not two logs for 15(d)'s hand merge — a commit of the +# copy would publish the second file — so the refusal says which is which and +# that the cure is to remove the copy and re-run. a15_publish "lanes/log/repodrop-1.md" "# lane repodrop-1 — object log (lane-collision-protocol Amendment 7) STARTED — lane repoDrop-1, session $DEAD_ID@Eagle, $OLD_UTC, lane:repoDrop-1 → home opensoft/repoDrop; estate repoDrop" { printf '# lane repoDrop-1 — object log (lane-collision-protocol Amendment 7)\n' @@ -9197,34 +9164,40 @@ STARTED — lane repoDrop-1, session $DEAD_ID@Eagle, $OLD_UTC, lane:repoDrop-1 } > "$LOGD/repoDrop-1.md" run env LANES_LANE=repodrop-1 LANES_SESSION="$DEAD_ID" "$E" log PAUSED lane:repodrop-1 --no-github is "a canonical file beside an old-cased PUBLISHED path is refused" "$rc" 2 -has "…naming the path that is published" "$err" "published as lanes/log/repodrop-1.md" +has "…naming the path that is published" "$err" "lanes/log/repodrop-1.md is the log this checkout has from origin" has "…and saying which half of the rename landed" "$err" "rename whose commit never landed" -has "…with the one command that settles it" "$err" "pull --rebase" +has "…naming the untracked copy as the stray" "$err" "lanes/log/repoDrop-1.md is NOT TRACKED" +has "…with the act that settles it: remove the copy and re-run" "$err" "lanes/log/repoDrop-1.md\") and re-run" +has "…after which the write renames the published log in its own commit" "$err" "renames the tracked log once, inside its own commit" +hasnt "…never 15(d)'s hand merge, which would publish the copy" "$err" "Merge them by hand" is "…appending nothing to the file it would have split the lane across" \ "$(grep -c '^PAUSED' "$LOGD/repoDrop-1.md" || :)" 0 rm -f -- "$LOGD/repoDrop-1.md" -# ---- A LOG THIS CHECKOUT HAS AND ORIGIN DOES NOT IS STILL THIS LANE'S -------- +# ---- A LANE'S OWN LOG, PENDING UNDER THE OTHER CASE, IS ITS OWN PATH -------- # -# `claim` exempts its own log from the dirty-checkout refusal, and computes that -# path with `log_path_ci` — whose fallback was the canonical path outright, a -# path that need not exist. A lane whose log is still spelled the old way and -# has never been pushed therefore had its OWN uncommitted log read as an -# unrelated dirty file, and the claim was refused for it. +# `claim` used to exempt its own log from the dirty-checkout refusal, and a log +# still spelled the old way was read as an unrelated dirty file. Amendment 23 S3 +# step 4 asks it the other way round: a pending change to a path the write +# commits refuses (2), naming THAT path — the claim's own log under the case it +# is spelled — because a pathspec commit would carry the change under the +# claim's subject. The log is committed and published first, so the pending +# line is the one thing S3 has to find. { printf '# lane repolocal-1 — object log (lane-collision-protocol Amendment 7)\n' printf 'STARTED — lane repoLocal-1, session %s@Eagle, %s, lane:repoLocal-1 → home opensoft/repoLocal; estate repoLocal\n' "$DEAD_ID" "$OLD_UTC" } > "$LOGD/repolocal-1.md" git -C "$WIP" add -- lanes/log/repolocal-1.md >/dev/null 2>&1 -git -C "$WIP" commit -q -m "LOG(repoLocal-1@Eagle): STARTED — committed here and pushed nowhere" +git -C "$WIP" commit -q -m "LOG(repoLocal-1@Eagle): STARTED" +git -C "$WIP" push -q origin main printf 'RESUMED — lane repoLocal-1, session %s@Eagle, %s, lane:repoLocal-1 → home opensoft/repoLocal\n' "$DEAD_ID" "$OLD_UTC" >> "$LOGD/repolocal-1.md" -is "the lane's own log is dirty here and on no origin" \ +is "the lane's own log holds a pending line" \ "$(git -C "$WIP" status --porcelain -- lanes/log/repolocal-1.md | grep -c . || :)" 1 run env LANES_LANE=repolocal-1 LANES_SESSION="$DEAD_ID" "$E" claim "opensoft/repoLocal#1" --no-github -is "a claim is not refused for its own log, written under the other case" "$rc" 0 -hasnt "…never reading that log as somebody else's uncommitted work" \ - "$err" "refusing to claim on a checkout that cannot be rebased" -is "…and the log is the row's spelling afterwards" \ - "$(ls "$LOGD" | grep -c '^repoLocal-1\.md$' || :)" 1 +is "a claim over a pending line in its own log refuses (S3 step 4)" "$rc" 2 +has "…naming that log, in the case it is spelled" "$err" "lanes/log/repolocal-1.md" +has "…with nothing written" "$err" "Nothing was written." +hasnt "…never the old guard's words" "$err" "cannot be rebased" +git -C "$WIP" checkout -q -- lanes/log/repolocal-1.md # ---- A LANE NAME IS NOT A REGULAR EXPRESSION -------------------------------- # @@ -9275,8 +9248,8 @@ has "…including the log a lane's own scan has to find in it" \ # ---- THE RACE'S WINNER IS JOINED ON A FILE NAME, AND THE LINE SPELLS IT FREELY # -# LAST IN THIS SECTION, because it is decided by an `origin/main` this checkout -# must not have fetched yet. `first_landed_of` reads the pickaxe's oldest commit +# The two rivals land at this claim's push (`race-at-push`, Amendment 23 S4's +# replay finds them). `first_landed_of` reads the pickaxe's oldest commit # that touched a rival's LOG FILE and joins that file's name against the holder # rows. The file carries the row's canonical spelling from the moment the first # write renames it (Amendment 15); the holder row carries whatever the winning @@ -9294,14 +9267,12 @@ git -C "$CLONE2" pull -q --rebase origin main 2>/dev/null || : } > "$CLONE2/lanes/log/repoRace-2.md" git -C "$CLONE2" add -- lanes/log/repoRace-2.md git -C "$CLONE2" commit -q -m "LOG(repoRace-2@Raven): CLAIMED opensoft/repoRace#7" -git -C "$CLONE2" push -q origin main { printf '# lane repoRace-3 — object log (lane-collision-protocol Amendment 7)\n' printf 'CLAIMED — lane repoRace-3, session %s@Raven, %s, opensoft/repoRace#7\n' "$DEAD_ID" "$(utc_at -1M)" } > "$CLONE2/lanes/log/repoRace-3.md" git -C "$CLONE2" add -- lanes/log/repoRace-3.md git -C "$CLONE2" commit -q -m "LOG(repoRace-3@Raven): CLAIMED opensoft/repoRace#7" -git -C "$CLONE2" push -q origin main -run env LANES_LANE=repoRace-1 LANES_NO_FETCH=1 LANES_SESSION="$DEAD_ID" "$E" claim "opensoft/repoRace#7" --no-github +run race r7 env LANES_LANE=repoRace-1 LANES_SESSION="$DEAD_ID" "$E" claim "opensoft/repoRace#7" --no-github is "a claim two rivals beat to main exits 7" "$rc" 7 has "…naming the one that LANDED first, whose own line spells it in another case" \ "$err" "@repoRace-2" @@ -9741,7 +9712,9 @@ ren_unread="$WIP/lanes/aliases-unreadable.tsv" printf 'someLane-1\tsomeLane-2\t2026-09-15T00:00:00Z\n' > "$ren_unread" chmod 0000 "$ren_unread" ren_before="$(git -C "$WIP" rev-parse HEAD)" -run env LANES_NO_FETCH=1 LANES_SESSION="$REN3_ID" \ +# (No LANES_NO_FETCH=1 here: for a writer that is a failed fetch, and S3 +# would refuse it with 2 before the table is read — ratified choice 2.) +run env LANES_SESSION="$REN3_ID" \ LANES_ALIASES_TSV="$ren_unread" LANES_ALIASES_PATH=lanes/aliases-unreadable.tsv \ "$E" rename-lane repoRen-1 repoRen-21 --no-github if [ "$rc" = 0 ]; then @@ -9833,7 +9806,7 @@ ren_before="$(git -C "$WIP" rev-parse HEAD)" run env LANES_SESSION="$REN6_ID" "$E" rename-lane repoKind-1 repoKind-2 --no-github is "an alias table origin carries and this checkout does not is refused" "$rc" 2 has "…because appending here would drop every alias already published" "$err" "drop every alias already published" -has "…and it names the pull" "$err" "pull --rebase" +has "…and it names the restore, since S3 already brought this checkout to origin's tip" "$err" "checkout -- lanes/aliases.tsv" is "…with nothing written" "$(git -C "$WIP" rev-parse HEAD)" "$ren_before" git -C "$WIP" checkout -- lanes/aliases.tsv @@ -13935,22 +13908,28 @@ git -C "$A19_WIP" push -q origin main run a19 "$LANES_CMD" --prefix repo19bad /dev/null git -C "$A19_PEER" config user.email "test@example.invalid" git -C "$A19_PEER" config user.name "lane helper tests" cat > "$SANDBOX/git-archive-race" <<'WRAP' #!/usr/bin/env bash -if [ "${1-}" = -C ] && [ "${2-}" = "$A19_WIP" ] && [ "${3-}" = pull ] && - [ ! -e "$A19_RACE_MARKER" ]; then - : > "$A19_RACE_MARKER" - printf '| `repo19race-1` | peer | Eagle | 2026-09-29 | none | none | RETIRED |\n' >> "$A19_PEER/lanes/archive/LANES-retired.md" - "$REAL_GIT" -C "$A19_PEER" add -- lanes/archive/LANES-retired.md || exit 1 - "$REAL_GIT" -C "$A19_PEER" commit -q -m 'peer retires repo19race-1' || exit 1 - "$REAL_GIT" -C "$A19_PEER" push -q origin main || exit 1 -fi +case " $* " in + *" fetch "*" +refs/heads/main:refs/remotes/origin/main "*) + if [ ! -e "$A19_RACE_MARKER" ]; then + : > "$A19_RACE_MARKER" + printf '| `repo19race-1` | peer | Eagle | 2026-09-29 | none | none | RETIRED |\n' >> "$A19_PEER/lanes/archive/LANES-retired.md" + "$REAL_GIT" -C "$A19_PEER" add -- lanes/archive/LANES-retired.md || exit 1 + "$REAL_GIT" -C "$A19_PEER" commit -q -m 'peer retires repo19race-1' || exit 1 + "$REAL_GIT" -C "$A19_PEER" push -q origin main || exit 1 + fi ;; +esac exec "$REAL_GIT" "$@" WRAP chmod +x "$SANDBOX/git-archive-race" @@ -13959,12 +13938,17 @@ cp "$SANDBOX/git-archive-race" "$SANDBOX/racebin/git" run a19 env PATH="$SANDBOX/racebin:$PATH" A19_WIP="$A19_WIP" A19_PEER="$A19_PEER" \ A19_RACE_MARKER="$SANDBOX/a19-raced" REAL_GIT="$(command -v git)" \ LANES_LANE=repo19-2 "$E" add-row "| \`repo19race-1\` | harness \`$A19_OLD\` | Eagle / test / brett | 2026-09-29 | none | none | LIVE |" +is "the peer's retirement landed inside add-row's run" "$([ -e "$SANDBOX/a19-raced" ] && echo yes)" yes is "an add-row losing to a peer retirement refuses to push" "$rc" 2 -has "…naming the peer retirement" "$err" "was retired by a peer" -is "…and the rejected local HEAD no longer has its row" \ +has "…having seen origin move under its checks, and run again on the new tip" "$err" "moved while this write's checks ran" +has "…naming the peer retirement" "$err" "lane 'repo19race-1' is RETIRED" +is "…and this checkout's register never had its row" \ "$(grep -c '^| `repo19race-1`' "$A19_WIP/lanes/LANES.md" || :)" 0 +# The BARE origin, never a clone's `origin/main`: the peer's tracking ref is +# moved by nothing this run does, so reading it passed whatever happened +# (rv1b's NIT 3 on slice 1b). is "…and the remote has no resurrected active row" \ - "$(git -C "$A19_PEER" show origin/main:lanes/LANES.md | grep -c '^| `repo19race-1`' || :)" 0 + "$(git --git-dir "$A19_ORIGIN" show main:lanes/LANES.md | grep -c '^| `repo19race-1`' || :)" 0 run a19 env LANES_LANE=repo19-2 "$E" add-row "| \`repo19race-2\` | harness \`$A19_OLD\` | Eagle / test / brett | 2026-09-29 | none | none | LIVE |" is "a later unrelated add-row can still push" "$rc" 0 is "…without carrying the retired row into the published register" \ @@ -15182,6 +15166,7 @@ is "…and the lane it just bound is RUNNING under the act that confirmed it" # reason. git -C "$WIP" add -A >/dev/null 2>&1 git -C "$WIP" commit -q -m "commit the sandbox's pending handoffs before the fence cases" >/dev/null 2>&1 || : +wip_publish # Amendment 23 S3: a write refuses over a commit origin lacks, so the fixture publishes it # (a) A SNAPSHOT THIS HELPER CANNOT READ IS NEVER REPLACED BY ONE IT CAN. # `repoRC-3` carries the `schema: 999` file section 8 wrote. A reader that fails @@ -15209,6 +15194,7 @@ is "…having left that snapshot alone too" \ rc_seed_handoff repoRC-7 git -C "$WIP" add -- handoffs/repoRC >/dev/null 2>&1 git -C "$WIP" commit -q -m "seed repoRC-7's handoff" >/dev/null 2>&1 || : +wip_publish # Amendment 23 S3: a write refuses over a commit origin lacks, so the fixture publishes it rc_row repoRC-7 "harness \`$RC_ID\`" rc_seed_log repoRC-7 run "$E" set-lane-state repoRC-7 RUNNING --owner "$RC_ID" --agent claude --profile team-01a @@ -15366,6 +15352,7 @@ ln -s "$RC_DIR" "$RC_LINK" rc_seed_handoff repoRC-8 git -C "$WIP" add -- handoffs/repoRC >/dev/null 2>&1 git -C "$WIP" commit -q -m "seed repoRC-8's handoff" >/dev/null 2>&1 || : +wip_publish # Amendment 23 S3: a write refuses over a commit origin lacks, so the fixture publishes it rc_row repoRC-8 "harness \`$RC_ID\`" { printf '# lane repoRC-8 — object log (lane-collision-protocol Amendment 7)\n' printf 'STARTED — lane repoRC-8, session %s@Eagle, 2026-09-15T00:00:00Z, lane:repoRC-8 → home opensoft/repoRC; dir %s; profile team-01a\n' \ @@ -15401,6 +15388,7 @@ is "…and the lane's own checkout is still not a tree either, whichever spel rc_seed_handoff repoRC-9 git -C "$WIP" add -- handoffs/repoRC >/dev/null 2>&1 git -C "$WIP" commit -q -m "seed repoRC-9's handoff" >/dev/null 2>&1 || : +wip_publish # Amendment 23 S3: a write refuses over a commit origin lacks, so the fixture publishes it rc_row repoRC-9 "harness \`$RC_ID\`" rc_seed_log repoRC-9 @@ -16014,9 +16002,19 @@ is "the two copies' disagreement reads as unknown (1), never the published ro has "…naming the register file it read" "$err" "register file, $MGM_WIP/lanes/LANES.md, carries" has "…and both ways the copies can disagree" "$err" "AHEAD of origin" has "…a fast-forward being the other remedy" "$err" "fast-forward it" -run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" migrate-state-cells --yes -mgm_refused "migrate-state-cells --yes over a marker only this checkout carries" 1 - "$mg_b" +# The seam's own answer is the dry run's since slice 1b: the dry run is a read +# and takes no S3, so it reaches the scan and reads both copies. The act takes +# Amendment 23 S3 under its lock first, and S3 refuses a checkout holding a +# commit origin lacks (2, ratified choice 5) before the scan begins — so the +# act never commits over the marker either, and the reason it gives is S3's. +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" migrate-state-cells +mgm_refused "the migration's dry run over a marker only this checkout carries" 1 - "$mg_b" has "…saying the two copies disagree" "$err" "the two copies disagree on" +run env LANES_REPO="$MGM_WIP" LANES_FILE="$MGM_WIP/lanes/LANES.md" "$E" migrate-state-cells --yes +is "migrate-state-cells --yes over a marker only this checkout carries is refused with 2, by S3" "$rc" 2 +has "…naming the commit origin lacks" "$err" "1 unpublished commit(s)" +has "…before a byte is written" "$err" "Nothing was written." +is "…and changed nothing: register HEAD here and on origin, status, the register's bytes, every log's bytes, no archive" "$(mgm_fp)" "$mg_b" # (d) AND THE SAME REGISTER WITH NO MARKER IN EITHER COPY MIGRATES, so each # refusal above was the seam's and not the fixture's. @@ -16111,6 +16109,7 @@ git -C "$HOME/projects/repoWS" remote add origin "https://github.com/opensoft/re # refusal is out of the way. git -C "$WIP" add -A >/dev/null 2>&1 git -C "$WIP" commit -q -m "commit the sandbox's pending handoffs before the workstation-seam cases" >/dev/null 2>&1 || : +wip_publish # Amendment 23 S3: a write refuses over a commit origin lacks, so the fixture publishes it # `LANES_IN_CONTAINER=0` THROUGHOUT THIS SECTION, and the reason is the rule # itself (`R-A11-14`): with the seam unset, the DEFAULT is `hostname -s` OUTSIDE diff --git a/tests/test_register_hardening_120_122.py b/tests/test_register_hardening_120_122.py index 3d98b5d..eca346e 100644 --- a/tests/test_register_hardening_120_122.py +++ b/tests/test_register_hardening_120_122.py @@ -24,6 +24,7 @@ import pytest +from conftest import REPO from test_register_scan_hardening_138 import ( # noqa: F401 (pytestmark is this module's too) HEADER, OLD_ID, UNPRIVILEGED_ONLY, Register as SweepRegister, lane_line, log_text, pytestmark, row) @@ -156,24 +157,25 @@ def test_120_item_1_a_rewrite_cut_short_leaves_the_register_whole(reg, act, cut) assert "UNCHANGED" not in result.stderr -@PROC_FD -def test_120_item_1_a_refusal_after_a_captured_peer_edit_says_this_act_committed_nothing(reg): - """The delta review of #263: a peer's uncommitted register edit found - before the act is captured as a commit of its own (`handle_preexisting`), - so a refusal after it that said "nothing has been committed" was false. It - says what holds: this act has committed nothing.""" +def test_120_item_1_a_peer_edit_is_refused_before_the_copy_and_never_captured(reg): + """The delta review of #263 pinned the words of a refusal made after a + peer's uncommitted register edit was captured as a commit of its own + (`handle_preexisting`). Amendment 23 S3 step 4 withdraws the capture + (ratified choice 9, #198 slice 1b): the write refuses (2) over the peer's + edit before its copy is made, commits nothing, and leaves the edit as it + was, for `commit` to commit as its own act. The copy's cut never runs.""" before = reg.head() reg.write("lanes/LANES.md", reg.read("lanes/LANES.md").replace( "another repository's row 30 |", "another repository's row 30, edited by a peer |")) edited = reg.read("lanes/LANES.md") reg.cut_cat("copy fails") result = reg.run(*ACTS["replace_line"], LANES_LANE="repoX-1") - assert result.returncode == 5, (result.stdout, result.stderr) - assert "captured pre-existing edit" in result.stderr - assert reg.git("log", "--format=%s", f"{before}..HEAD").startswith("LANES(pre-existing@") - assert reg.git("rev-list", "--count", f"{before}..HEAD").strip() == "1" - assert "this act has committed nothing" in result.stderr + assert result.returncode == 2, (result.stdout, result.stderr) + assert "holds an uncommitted edit, which may be a peer's" in result.stderr + assert "captured pre-existing edit" not in result.stderr assert "nothing has been committed" not in result.stderr + assert "MAY NOW BE TRUNCATED" not in result.stderr + assert reg.head() == before assert reg.read("lanes/LANES.md") == edited @@ -230,6 +232,14 @@ def test_120_item_1_a_sweep_refused_part_way_names_what_it_wrote_and_is_not_call # ------------------------------- #120 item 2: the sweep's published-path scan +#: Amendment 23 S3's refusal of an unpushed commit, in its own words +#: (`cs_refuse`). Under #198 slice 1b it comes before any scan of a sweep: a +#: commit made here and not pushed is refused (2) as soon as the write starts. +S3_LACKS = "this checkout holds 1 commit(s) origin/main lacks by content:" +S3_WORDS = ("would push them under its own name or strand itself behind them " + "(Amendment 23 S3). Nothing was written.") + + def case_sensitive(path: Path) -> bool: probe = path / "case-probe" probe.write_text("") @@ -246,8 +256,11 @@ def test_120_item_2_a_rename_whose_commit_never_landed_refuses_in_the_scan(reg): its commit is not pushed — the row's spelling here, committed; the old spelling still on origin; a checkout merely AHEAD — passed the scan, and `ensure_log` refused it inside the write loop, after the lane before it had - had its log created and appended. The scan refuses it now, with - `ensure_log`'s own words, and nothing is written.""" + had its log created and appended. #263's scan refused it with `ensure_log`'s + own words. Under Amendment 23 S3 (#198 slice 1b) the rename's commit is an + unpushed commit, and S3 refuses it (2) before the scan, naming it: the + scan's words are reached only where origin moves after this write's + fast-forward under its lock. Nothing is written either way.""" if not case_sensitive(reg.tmp): pytest.skip("two log names that differ only by case need a case-sensitive filesystem") reg.write("lanes/log/repox-3.md", log_text("repox-3", lane_line( @@ -258,13 +271,88 @@ def test_120_item_2_a_rename_whose_commit_never_landed_refuses_in_the_scan(reg): before = reg.head() result = reg.sweep("repoX-2", "repoX-3") assert result.returncode == 2, (result.stdout, result.stderr) - assert "published as lanes/log/repox-3.md" in result.stderr - assert "a case-only rename whose commit never landed" in result.stderr + assert S3_LACKS in result.stderr + assert "repoX-3's log takes the row's spelling (not pushed)" in result.stderr + assert S3_WORDS in result.stderr + assert "published as lanes/log/repox-3.md" not in result.stderr assert reg.head() == before assert not (reg.wip / "lanes/log/repoX-2.md").exists() assert reg.dirty() == "" +#: `ensure_log`'s own recovery sentence, which the sweep's scan repeats word for +#: word (the comment above the scan promises "in its words"): both dies carry it. +ENSURE_LOG_RECOVERY = ("Re-run the write: it fast-forwards this checkout to origin's tip under its lock " + "first (Amendment 23 S3), and the rename then happens once, inside its own commit.") + + +def test_120_item_2_a_log_respelled_on_origin_after_the_fast_forward_refuses_in_the_scan(reg, tmp_path): + """S4 of lane openRepoTools-3's review of the re-homed slice 1b: the scan's + refusal at `retire_rows`' published-log check (B73's hand-resolved die) had + no case, because Amendment 23 S3 refuses the state it was written for, a + case-only rename whose commit is not pushed, before the scan. It is reached + only where origin moves AFTER S3's fast-forward and after the sweep's + behind-check: here a peer respells repoX-3's log on origin and a reader + fetches it into this shared checkout, which still holds the row's spelling, + clean and in step with the tip S3 fast-forwarded to. The scan refuses (2) in + `ensure_log`'s words and recovery, before the lane ahead of it in this one + commit has a log made or a line appended: nothing is written.""" + if not case_sensitive(reg.tmp): + pytest.skip("two log names that differ only by case need a case-sensitive filesystem") + original = log_text("repoX-3", lane_line("NOTED", "repoX-3", OLD_ID, "2026-09-13T00:00:00Z") + + " — migrated; history kept here") + reg.write("lanes/log/repoX-3.md", original) + reg.publish("repoX-3's log, under the row's spelling") + peer = tmp_path / "peer" + reg.raw_git("clone", "-q", str(reg.origin), str(peer)) + reg.raw_git("-C", str(peer), "mv", "lanes/log/repoX-3.md", "lanes/log/repox-3.md") + reg.raw_git("-C", str(peer), "commit", "-q", "-m", "a peer respells repoX-3's log") + register = reg.read("lanes/LANES.md") + before = reg.head() + real = shutil.which("git", path=os.environ.get("PATH")) + mark = tmp_path / "fetched" + # The sweep's behind-check is the last read of `origin/main` before its scan: + # the peer's push and a reader's fetch of it follow that call, in the shared + # checkout, and the call's own answer (0 behind) is handed back unchanged. + shim = reg.fakebin / "git" + shim.write_text(f"""#!/usr/bin/env bash +case " $* " in + *" rev-list --count HEAD..refs/remotes/origin/main "*) + if [ ! -e '{mark}' ]; then + : > '{mark}' + out=$('{real}' "$@") || exit $? + '{real}' -C '{peer}' push -q origin main || exit 1 + '{real}' -C '{reg.wip}' fetch -q origin '+refs/heads/main:refs/remotes/origin/main' || exit 1 + printf '%s\\n' "$out" + exit 0 + fi ;; +esac +exec '{real}' "$@" +""") + shim.chmod(0o755) + result = reg.sweep("repoX-2", "repoX-3") + shim.unlink() + assert mark.exists(), "the peer never landed: the sweep's behind-check was never reached" + assert reg.published("lanes/log/repox-3.md") == original, "the peer's respelling is on origin" + assert result.returncode == 2, (result.stdout, result.stderr) + assert S3_LACKS not in result.stderr, "S3 had nothing to refuse: the checkout was in step with origin" + assert ("lane repoX-3's object log is published as lanes/log/repox-3.md while this checkout has " + "lanes/log/repoX-3.md instead") in result.stderr + assert ("the row spells the lane repoX-3, so the log is lanes/log/repoX-3.md (Amendment 15), " + "and writing one here now would leave TWO files for one lane.") in result.stderr + assert ENSURE_LOG_RECOVERY in result.stderr + assert "Origin moved after" not in result.stderr + assert result.stderr.rstrip().endswith("Nothing was written.") + assert (REPO / "lanes-edit.sh").read_text(encoding="utf-8").count(ENSURE_LOG_RECOVERY) == 2, \ + "the scan's refusal and ensure_log's say the same sentence" + assert reg.head() == before + assert reg.dirty() == "" + assert reg.read("lanes/LANES.md") == register + assert reg.read("lanes/log/repoX-3.md") == original + assert not (reg.wip / "lanes/log/repoX-2.md").exists() + assert "RETIRED" not in reg.published("lanes/LANES.md") + + # ------------------------------- #120 item 3: a log that already closes the lane @pytest.mark.parametrize("last", ["ENDED", "RETIRED"]) @@ -298,9 +386,12 @@ def test_120_item_3_a_lane_whose_log_closes_it_is_told_it_is_closed(reg, last): @pytest.mark.parametrize("last", ["ENDED", "RETIRED"]) def test_120_item_3_a_closing_line_only_this_checkout_has_is_not_called_closed(reg, last): """The review of #263, S2, from a commit rather than a cut sweep: a closing - line committed here and never pushed is refused as this checkout's alone, - never called CLOSED, and with its article right: "an ENDED line" (the - delta review's NIT).""" + line committed here and never pushed is refused, and never called CLOSED. + #263's scan refused it as this checkout's alone, with its article right: + "an ENDED line" (the delta review's NIT). Under Amendment 23 S3 (#198 + slice 1b) the commit is an unpushed commit, and S3 refuses it (2) before + the scan, naming it: the scan's words are reached only where origin moves + after this write's fast-forward under its lock. Nothing is written.""" started = lane_line("STARTED", "repoX-2", OLD_ID, "2026-09-11T00:00:00Z") reg.write("lanes/log/repoX-2.md", log_text("repoX-2", started)) reg.publish("repoX-2 started") @@ -310,11 +401,14 @@ def test_120_item_3_a_closing_line_only_this_checkout_has_is_not_called_closed(r before = reg.head() result = reg.sweep("repoX-2") assert result.returncode == 2, (result.stdout, result.stderr) - article = "an" if last == "ENDED" else "a" - assert (f"in THIS CHECKOUT ends on {article} {last} line, and origin/main's copy does not" - in result.stderr) + assert S3_LACKS in result.stderr + assert f"repoX-2 {last.lower()}, not pushed" in result.stderr + assert S3_WORDS in result.stderr + assert "in THIS CHECKOUT ends on" not in result.stderr assert "already CLOSED" not in result.stderr assert reg.head() == before + assert reg.dirty() == "" + assert "RETIRED" not in reg.published("lanes/LANES.md").split("`repoX-2`")[1].splitlines()[0] def test_120_item_3_a_lane_whose_log_says_it_is_parked_is_sent_to_lane_end(reg): @@ -354,21 +448,22 @@ def add_repo_x_4(reg: Register) -> subprocess.CompletedProcess: return reg.run("add-row", NEW_ROW.rstrip("\n"), LANES_LANE="repoX-4") -def peer_pushes_during_pull(reg: Register, tmp_path: Path, changes: dict[str, str], *, - diff_log: Path | None = None, diff_context: int | None = None, - extra_shell: str = "") -> Path: +def peer_pushes_before_its_push(reg: Register, tmp_path: Path, changes: dict[str, str], *, + diff_log: Path | None = None, diff_context: int | None = None, + extra_shell: str = "") -> Path: """The shell suite's `repo19race` shape. A peer clone commits `changes` (a path and its whole new text each, one commit each, in order) now, and a `git` first on PATH pushes them the moment the writer's own checkout runs - its first `pull`: after add-row has committed its row, before it pushes. - Returns the mark the shim leaves; the caller removes the shim. - - The same shim sees add-row's take-back diff (the register's `-U0` diff - against origin's). `diff_log` records how each was called, the - `GIT_DIFF_OPTS` it saw and its arguments; `diff_context` runs it with that - much context instead, as any source of context the take-back cannot turn - off would. `extra_shell` runs in the shim before it hands each call to the - real `git`, with `@MARK@` standing for the mark's path.""" + its first `push`: after add-row has committed its row, so its push is + refused. (#263 raced the `pull --rebase`; Amendment 23 S4 removed the pull, + #198 slice 1b, and replays a refused push on origin's new tip.) Returns the + mark the shim leaves; the caller removes the shim. + + The same shim would see add-row's take-back diff (the register's `-U0` + diff against origin's), which went with the pull: `diff_log` records each + such call, so a case can assert there is none; `diff_context` would run it + with context. `extra_shell` runs in the shim before it hands each call to + the real `git`, with `@MARK@` standing for the mark's path.""" peer = tmp_path / "peer" reg.raw_git("clone", "-q", str(reg.origin), str(peer)) for rel, text in changes.items(): @@ -391,7 +486,7 @@ def peer_pushes_during_pull(reg: Register, tmp_path: Path, changes: dict[str, st if extra_shell: take_back += extra_shell.replace("@MARK@", str(mark)) + "\n" shim.write_text(f"""#!/usr/bin/env bash -if [ "${{1-}}" = -C ] && [ "${{3-}}" = pull ] && [ ! -e '{mark}' ]; then +if [ "${{1-}}" = -C ] && [ "${{3-}}" = push ] && [ ! -e '{mark}' ]; then case "${{2-}}" in */wip) : > '{mark}' '{real}' -C '{peer}' push -q origin main || exit 1 ;; @@ -472,21 +567,25 @@ def test_122_a_line_number_says_which_copy_it_is_in(reg): assert f"line 12 (this checkout): {mangled}" in result.stderr -def test_122_a_refusal_after_the_rebase_takes_its_row_back(reg, tmp_path): +def test_122_a_refusal_on_the_replay_leaves_no_row_to_publish(reg, tmp_path): """The review of #263, M1, the shell suite's `repo19race` shape: a peer - publishes an archive line this scan cannot take apart while add-row is in - its `pull --rebase`. The after-rebase rescan refused (1), but only the + publishes an archive line this scan cannot take apart while add-row is + publishing. Under #263 the after-rebase rescan refused (1) and only its retired-match branch took the committed row back, so the next unrelated - write from this checkout published the row add-row had refused. Every - refusal there now removes it in a compensating local commit.""" + write published the row add-row had refused. Under Amendment 23 S4 (#198 + slice 1b) the push is refused, the replay drops add-row's commit and runs + its checks again on origin's new tip, and they refuse (1) with nothing + left at HEAD: no compensating commit is needed, and none is made.""" archive = a_good_archive(reg) - mark = peer_pushes_during_pull(reg, tmp_path, { + mark = peer_pushes_before_its_push(reg, tmp_path, { ARCHIVE: archive + MANGLED["the lane lost its backticks"] + "\n"}) result = add_repo_x_4(reg) (reg.fakebin / "git").unlink() - assert mark.exists(), "the peer never raced the pull" + assert mark.exists(), "the peer never raced the push" assert result.returncode == 1, (result.stdout, result.stderr) - assert "removed in a compensating LOCAL commit" in result.stderr + assert "carries a line this scan cannot take apart (above)" in result.stderr + assert "compensating LOCAL commit" not in result.stderr + assert reg.git("log", "--oneline", "origin/main..HEAD") == "", "nothing of add-row's is left to publish" assert "`repoX-4`" not in reg.read("lanes/LANES.md") assert "`repoX-4`" not in reg.git("diff", "origin/main", "HEAD", "--", "lanes/LANES.md") assert "`repoX-4`" not in reg.published("lanes/LANES.md") @@ -507,57 +606,58 @@ def test_122_a_refusal_after_the_rebase_takes_its_row_back(reg, tmp_path): @pytest.mark.parametrize("why", sorted(RESCAN_REFUSALS)) -def test_122_a_refusal_after_the_rebase_leaves_a_peers_identical_row_alone(reg, tmp_path, why): +def test_122_a_refusal_on_the_replay_leaves_a_peers_identical_row_alone(reg, tmp_path, why): """The delta review of #263, D1: a peer publishes the byte-identical - repoX-4 row while add-row is in its `pull --rebase`, so the rebase drops - add-row's commit as already upstream and the one copy left is the PEER's. - The take-back found it by its exact text and deleted it in a local commit, - and the next write published that deletion: the peer's row gone from - origin. Only a copy this checkout adds over origin's is taken back now, so - here none is, for either refusal of the rescan.""" + repoX-4 row while add-row is publishing. Under #263 the rebase dropped + add-row's commit as already upstream, and the take-back deleted the + PEER's copy in a local commit that the next write published. Under + Amendment 23 S4 (#198 slice 1b) the push is refused and the replay drops + add-row's own commit, by what it is, and its checks refuse on origin's new + tip, for either refusal: nothing is taken back, nothing of add-row's is + left, and the next write leaves the peer's row as it is.""" archive = a_good_archive(reg) refusal, code = RESCAN_REFUSALS[why] - mark = peer_pushes_during_pull(reg, tmp_path, { + mark = peer_pushes_before_its_push(reg, tmp_path, { "lanes/LANES.md": reg.read("lanes/LANES.md") + NEW_ROW, ARCHIVE: archive + refusal}) result = add_repo_x_4(reg) (reg.fakebin / "git").unlink() - assert mark.exists(), "the peer never raced the pull" + assert mark.exists(), "the peer never raced the push" assert result.returncode == code, (result.stdout, result.stderr) + assert "origin/main already carries this exact row" not in result.stderr, \ + "#263's rebase dropped add-row's commit as upstream and said so; the replay does neither" ahead = reg.git("log", "--oneline", "origin/main..HEAD") - diff = reg.git("diff", "origin/main", "HEAD") after = reg.run("set-row-state", "repoX-2", "PAUSED · the next unrelated write", LANES_LANE="repoX-1") assert after.returncode == 0, (after.stdout, after.stderr) assert reg.published("lanes/LANES.md").count("`repoX-4`") == 1, (ahead, result.stderr) - assert ahead == "" and diff == "" - assert "origin/main already carries this exact row" in result.stderr + assert ahead == "" assert "compensating LOCAL commit" not in result.stderr -def test_122_a_refusal_after_the_rebase_takes_back_its_own_row_beside_a_peers_copy(reg, tmp_path): +def test_122_a_refusal_on_the_replay_drops_its_own_row_beside_a_peers_copy_in_another_place(reg, tmp_path): """…and the other direction. Where the peer put the identical row in - another place, the rebase KEEPS add-row's commit and the register holds - two copies. The one this checkout adds is taken back: not the peer's, and - not neither. "origin already has the row" would have kept add-row's - commit, and the next write would have published a second copy.""" + another place, #263's rebase KEPT add-row's commit and the register held + two copies, and the take-back removed this checkout's. Under Amendment 23 + S4 (#198 slice 1b) the replay drops add-row's commit whole before its + checks refuse, so the register holds the peer's copy alone once the next + write brings it to origin's tip, and that write publishes no second one.""" archive = a_good_archive(reg) seeded = row("repoX-3", OLD_ID, "more old words") peer_register = reg.read("lanes/LANES.md").replace(seeded, seeded + NEW_ROW) assert peer_register != reg.read("lanes/LANES.md") - mark = peer_pushes_during_pull(reg, tmp_path, { + mark = peer_pushes_before_its_push(reg, tmp_path, { "lanes/LANES.md": peer_register, ARCHIVE: archive + RESCAN_REFUSALS["a line the scan cannot take apart"][0]}) result = add_repo_x_4(reg) (reg.fakebin / "git").unlink() - assert mark.exists(), "the peer never raced the pull" + assert mark.exists(), "the peer never raced the push" assert result.returncode == 1, (result.stdout, result.stderr) - register = reg.read("lanes/LANES.md") - diff = reg.git("diff", "origin/main", "HEAD", "--", "lanes/LANES.md") + ahead = reg.git("log", "--oneline", "origin/main..HEAD") after = reg.run("set-row-state", "repoX-2", "PAUSED · the next unrelated write", LANES_LANE="repoX-1") assert after.returncode == 0, (after.stdout, after.stderr) assert reg.published("lanes/LANES.md").count("`repoX-4`") == 1, result.stderr - assert register == peer_register - assert diff == "" - assert "removed in a compensating LOCAL commit" in result.stderr + assert reg.read("lanes/LANES.md").count("`repoX-4`") == 1 + assert ahead == "" + assert "compensating LOCAL commit" not in result.stderr #: The ways the take-back cannot remove its row once it has found it: its copy @@ -570,18 +670,17 @@ def test_122_a_refusal_after_the_rebase_takes_back_its_own_row_beside_a_peers_co @pytest.mark.parametrize("cut", TAKE_BACK_CUTS) -def test_122_a_take_back_that_cannot_rewrite_says_add_row_has_committed(reg, tmp_path, cut): - """The delta review of #263, on the wording: where the take-back itself - cannot rewrite the register, add-row HAS committed its row (locally, not - pushed), and the refusal says so and how to remove it, never that this - act has committed nothing. And in add-row's own words (delta review 3's - NIT): `delete_lines`' sentences are archive-rows' ("the rows are in" the - archive, "the archive may have been appended"), and false here. And on - every refusal `delete_lines` makes after its copy (delta review 4, N1 and - S4's N2): the pass, the line count and `numstat` named no row and no - commit, so "the removal added or rewrote lines" was all add-row said.""" +def test_122_a_refusal_on_the_replay_says_nothing_of_a_committed_row_whatever_is_cut(reg, tmp_path, cut): + """The delta review of #263, on the wording, and delta review 4's N1 and + S4's N2: where #263's take-back could not rewrite the register (its copy + or its write cut short, its pass, its line count or its `numstat` proof + refused), add-row HAD committed its row, and the refusal had to say so in + add-row's own words. Under Amendment 23 S4 (#198 slice 1b) there is no + take-back: the replay drops add-row's commit and its checks refuse (1) + with nothing of add-row's left, so the same cuts change nothing. The + refusal says nothing of a committed row, because there is none.""" archive = a_good_archive(reg) - mark = peer_pushes_during_pull(reg, tmp_path, { + mark = peer_pushes_before_its_push(reg, tmp_path, { ARCHIVE: archive + RESCAN_REFUSALS["a line the scan cannot take apart"][0]}) real_awk = shutil.which("awk", path=os.environ.get("PATH")) if cut == "awk pass": @@ -608,24 +707,14 @@ def test_122_a_take_back_that_cannot_rewrite_says_add_row_has_committed(reg, tmp for shim in ("git", "cat", "awk"): if (reg.fakebin / shim).exists(): (reg.fakebin / shim).unlink() - assert mark.exists(), "the peer never raced the pull" - assert result.returncode == 5, (result.stdout, result.stderr) - if cut == "write": - assert "have been put back and proved" in result.stderr - assert "Its row for lane repoX-4 was not taken back, and this act HAS committed it" in result.stderr - else: - assert ("The register is as it was and this act HAS committed its row for lane repoX-4, locally and " - "unpushed: no row was removed, so that row is still in it and in that commit.") in result.stderr - assert {"copy": "could not be copied whole", - "awk pass": "the pass that removes 1 row(s)", - "line count": "removing 1 row(s) changed the line count by 2; refusing. The register", - "numstat": "the removal added or rewrote lines (numstat: -\t-); refusing. The register", - }[cut] in result.stderr - assert "from lanes/LANES.md and commit that before any other write in " in result.stderr + assert mark.exists(), "the peer never raced the push" + assert result.returncode == 1, (result.stdout, result.stderr) + assert "carries a line this scan cannot take apart (above)" in result.stderr + assert "HAS committed" not in result.stderr for archive_rows_words in ("the rows are in", "The archive may have been appended", "resolve that partial move"): assert archive_rows_words not in result.stderr - assert "this act has committed nothing" not in result.stderr - assert "add row" in reg.git("log", "--format=%s", "origin/main..HEAD") + assert "compensating LOCAL commit" not in result.stderr + assert reg.git("log", "--format=%s", "origin/main..HEAD") == "", "nothing of add-row's is left to publish" assert "`repoX-4`" not in reg.published("lanes/LANES.md") @@ -642,15 +731,15 @@ def test_122_a_take_back_that_cannot_rewrite_says_add_row_has_committed(reg, tmp @pytest.mark.parametrize("case", CONTEXT_CASES) def test_122_a_take_back_finds_its_row_whatever_context_the_diff_carries(reg, tmp_path, case): - """The delta review of #263, S3: `-U0` alone does not keep context out of - the take-back's diff. A `GIT_DIFF_OPTS=-u3` overrides it, and a user's - `diff.interHunkContext` fuses a nearby change's hunk with the row's, the - lines between them as context. The parse counted only `+` lines, so the - row was put at the wrong line, the line check refused, nothing was taken - back, and the next ordinary write published the row add-row had refused (M1 - again). The diff now runs with neither, and the parse counts every line of - a hunk but a removed one, so context that arrives anyway (a shim, here) is - counted where it stands, and so is a second added row in the same hunk.""" + """The delta review of #263, S3: `-U0` alone did not keep context out of + #263's take-back diff (a `GIT_DIFF_OPTS=-u3`, a user's + `diff.interHunkContext`, a second row in the same hunk), the row was put + at the wrong line, and the next ordinary write published the row add-row + had refused. Under Amendment 23 S4 (#198 slice 1b) no such diff is asked + for: the replay drops add-row's commit by what it is and its checks refuse + (1), whatever context a diff would carry. The nearby edit and the other + lane's row are published first, since S3 refuses a write over an unpushed + commit.""" archive = a_good_archive(reg) extra = {"LANES_LANE": "repoX-4"} diff_log = tmp_path / "take-back-diff" @@ -659,8 +748,8 @@ def test_122_a_take_back_finds_its_row_whatever_context_the_diff_carries(reg, tm extra["GIT_DIFF_OPTS"] = "-u3" elif case.startswith("another lane"): reg.write("lanes/LANES.md", reg.read("lanes/LANES.md") - + row("repoX-5", OLD_ID, "another lane's row, not pushed", "2026-10-19")) - reg.commit("another lane's row, not pushed") + + row("repoX-5", OLD_ID, "another lane's row, published", "2026-10-19")) + reg.publish("another lane's row, just above") else: if case.startswith("diff.interHunkContext"): (reg.home / ".gitconfig").write_text("[diff]\n\tinterHunkContext = 3\n", encoding="utf-8") @@ -668,25 +757,19 @@ def test_122_a_take_back_finds_its_row_whatever_context_the_diff_carries(reg, tm context = 3 reg.write("lanes/LANES.md", reg.read("lanes/LANES.md").replace( "another repository's row 29 |", "another repository's row 29, edited here |")) - reg.commit("an edit one row above the end, not pushed") - mark = peer_pushes_during_pull(reg, tmp_path, { + reg.publish("an edit one row above the end") + mark = peer_pushes_before_its_push(reg, tmp_path, { ARCHIVE: archive + RESCAN_REFUSALS["a line the scan cannot take apart"][0]}, diff_log=diff_log, diff_context=context) result = reg.run("add-row", NEW_ROW.rstrip("\n"), **extra) (reg.fakebin / "git").unlink() - assert mark.exists(), "the peer never raced the pull" + assert mark.exists(), "the peer never raced the push" assert result.returncode == 1, (result.stdout, result.stderr) after = reg.run("set-row-state", "repoX-2", "PAUSED · the next unrelated write", LANES_LANE="repoX-1") assert after.returncode == 0, (after.stdout, after.stderr) assert reg.published("lanes/LANES.md").count("`repoX-4`") == 0, result.stderr - assert "removed in a compensating LOCAL commit" in result.stderr - # How the one take-back diff was asked for: with no GIT_DIFF_OPTS and no - # inter-hunk context, whatever add-row's environment and config held. - calls = diff_log.read_text(encoding="utf-8").splitlines() - assert len(calls) == 1, calls - seen_opts, args = calls[0].split("\t", 1) - assert seen_opts == "", calls - assert " -c diff.interHunkContext=0 diff " in f" {args} ", calls + assert "compensating LOCAL commit" not in result.stderr + assert not diff_log.exists(), "no take-back diff is asked for" #: The ways git classes the register as binary (delta review 4 of #263, S4), @@ -700,14 +783,14 @@ def test_122_a_take_back_finds_its_row_whatever_context_the_diff_carries(reg, tm @pytest.mark.parametrize("case", BINARY_CASES) def test_122_a_take_back_reads_its_diff_as_text_whatever_classes_the_register_binary(reg, tmp_path, case): - """Delta review 4 of #263, S4: where git classes the register as binary, - the take-back's diff is the one line "Binary files … differ", with no hunk - to read. add-row said this checkout added no copy of the row, kept its - commit, and the next write published the row add-row had refused (M1 - again). The diff is now read as text, and under a `core.bigFileThreshold` - the removal's `numstat` proof counts lines as well, so each takes its row - back. The next write is an `append-line`: a `set-row-state` refuses under - such a threshold on its own `numstat` proof, as it does on main.""" + """Delta review 4 of #263, S4: where git classed the register as binary, + #263's take-back diff had no hunk to read, add-row kept its commit, and + the next write published the row add-row had refused (M1 again). Under + Amendment 23 S4 (#198 slice 1b) the replay drops add-row's commit by what + it is, with no diff of the register to read, and its checks refuse (1) + whatever classes the register binary. The next write is an `append-line`: + a `set-row-state` refuses under such a threshold on its own `numstat` + proof, as it does on main.""" archive = a_good_archive(reg) if case.startswith("`lanes/LANES.md -diff`"): (reg.wip / ".git" / "info").mkdir(parents=True, exist_ok=True) @@ -718,16 +801,17 @@ def test_122_a_take_back_reads_its_diff_as_text_whatever_classes_the_register_bi else: (reg.home / ".gitconfig").write_text("[core]\n\tbigFileThreshold = 200\n", encoding="utf-8") assert (reg.wip / "lanes/LANES.md").stat().st_size > 200 - mark = peer_pushes_during_pull(reg, tmp_path, { + mark = peer_pushes_before_its_push(reg, tmp_path, { ARCHIVE: archive + RESCAN_REFUSALS["a line the scan cannot take apart"][0]}) result = add_repo_x_4(reg) (reg.fakebin / "git").unlink() - assert mark.exists(), "the peer never raced the pull" + assert mark.exists(), "the peer never raced the push" assert result.returncode == 1, (result.stdout, result.stderr) + assert reg.git("log", "--oneline", "origin/main..HEAD") == "", "nothing of add-row's is left to publish" after = reg.run("append-line", "a plain line, the next unrelated write", LANES_LANE="repoX-1") assert after.returncode == 0, (after.stdout, after.stderr) assert reg.published("lanes/LANES.md").count("`repoX-4`") == 0, result.stderr - assert "removed in a compensating LOCAL commit" in result.stderr + assert "compensating LOCAL commit" not in result.stderr #: Where the take-back's removal is written but cannot be committed (delta @@ -745,34 +829,28 @@ def test_122_a_take_back_reads_its_diff_as_text_whatever_classes_the_register_bi @pytest.mark.parametrize("step", sorted(COMPENSATION_FAILS)) -def test_122_a_removal_that_cannot_be_committed_says_so(reg, tmp_path, step): - """Delta review 4 of #263, N3: where the take-back's `git add` or `git - commit` failed, add-row returned with nothing of its own said. The removal - was left in the working tree or staged under add-row's commit, and the next - write's commit carried it under that write's caption. It now says what is - staged, that add-row's commit still holds the row, and the command that - commits the removal, which is run here: then the next write publishes no - repoX-4, and origin's history has the removal under its own caption.""" +def test_122_the_replay_makes_no_removal_for_a_refused_add_or_commit_to_strand(reg, tmp_path, step): + """Delta review 4 of #263, N3: where #263's take-back could not stage or + commit its removal, the removal was left in the working tree or staged + under add-row's commit, and the next write's commit carried it under that + write's caption. Under Amendment 23 S4 (#198 slice 1b) there is no + removal to commit: the replay drops add-row's commit and its checks refuse + (1), so the same `add` and `commit` failures leave the checkout clean, + nothing of add-row's to publish, and no cancelling commit anywhere.""" archive = a_good_archive(reg) shell, status, said = COMPENSATION_FAILS[step] - mark = peer_pushes_during_pull(reg, tmp_path, { + mark = peer_pushes_before_its_push(reg, tmp_path, { ARCHIVE: archive + RESCAN_REFUSALS["a line the scan cannot take apart"][0]}, extra_shell=shell) result = add_repo_x_4(reg) (reg.fakebin / "git").unlink() - assert mark.exists(), "the peer never raced the pull" + assert mark.exists(), "the peer never raced the push" assert result.returncode == 1, (result.stdout, result.stderr) - assert reg.dirty().splitlines() == [status] - assert "add row" in reg.git("log", "--format=%s", "origin/main..HEAD") - assert said in result.stderr - caption = "LANES(repoX-4@Eagle): cancel add-row whose retirement could not be checked after rebase" - fix = re.search(r"Commit the removal before any other write in (\S+), so that write does not carry it " - r"under its own caption: `(git -C (\S+) commit -m '([^']+)' -- lanes/LANES\.md)`", result.stderr) - assert fix, result.stderr - assert fix.group(1) == fix.group(3) and os.path.samefile(fix.group(1), reg.wip), fix.group(0) - assert fix.group(4) == caption, fix.group(0) - done = subprocess.run(["bash", "-c", fix.group(2)], env=reg.env, text=True, capture_output=True, check=False) - assert done.returncode == 0, (done.stdout, done.stderr) + assert reg.dirty() == "", (status, reg.dirty()) + assert reg.git("log", "--format=%s", "origin/main..HEAD") == "", "nothing of add-row's is left to publish" + assert said not in result.stderr + caption = "LANES(repoX-4@Eagle): cancel add-row" + assert "Commit the removal" not in result.stderr after = reg.run("set-row-state", "repoX-2", "PAUSED · the next unrelated write", LANES_LANE="repoX-1") assert after.returncode == 0, (after.stdout, after.stderr) assert reg.published("lanes/LANES.md").count("`repoX-4`") == 0 - assert caption in reg.raw_git("-C", str(reg.origin), "log", "--format=%s", "main") + assert caption not in reg.raw_git("-C", str(reg.origin), "log", "--format=%s", "main") diff --git a/tests/test_register_scan_hardening_138.py b/tests/test_register_scan_hardening_138.py index 881d097..553628c 100644 --- a/tests/test_register_scan_hardening_138.py +++ b/tests/test_register_scan_hardening_138.py @@ -185,19 +185,20 @@ def test_a_refusal_in_the_scan_commits_no_peer_edit(reg, act): assert reg.dirty().strip() == "M lanes/LANES.md" -def test_a_sweep_that_writes_still_captures_the_peer_edit_as_its_own_commit(reg): - """The capture moved; it did not go: the peer's edit still lands in its own - `LANES(pre-existing@…)` commit and never inside the sweep's.""" - peer_edit(reg) +def test_a_sweep_over_a_peer_register_edit_refuses_and_captures_nothing(reg): + """The capture moved in #157 4b; Amendment 23 S3 step 4 withdraws it + (ratified choice 9, #198 slice 1b). A sweep that would write refuses (2) + over a peer's pending register edit, commits nothing, and leaves the edit + as it was, for `commit` to commit as its own act. It never lands inside + the sweep's commit, which is what the capture was for.""" + edited = peer_edit(reg) before = reg.head() result = reg.sweep("repoX-2") - assert result.returncode == 0, (result.stdout, result.stderr) - subjects = reg.git("log", "--format=%s", f"{before}..HEAD").splitlines() - assert len(subjects) == 2, subjects - assert subjects[1].startswith("LANES(pre-existing@Eagle)") - assert "retire-dormant" in subjects[0] - swept = reg.git("show", "--format=", "-U0", "HEAD", "--", "lanes/LANES.md") - assert "a peer's half-written cell" not in swept + assert result.returncode == 2, (result.stdout, result.stderr) + assert "never swept into another write" in result.stderr + assert "captured pre-existing edit" not in result.stderr + assert reg.head() == before + assert reg.read("lanes/LANES.md") == edited # ------------------------------------------------- #138: retire_rows' scan @@ -259,12 +260,15 @@ def test_item_12_a_symlinked_log_path_refuses_and_writes_nothing_through_it(reg) def test_item_8_the_retired_line_carries_the_writers_unpushed_newest_transcript(reg): """#138 item 8: the writer's uuid came from the PUBLISHED row alone, so a sweeping lane whose newest transcript is stamped in this checkout and not - yet pushed signed its append-only RETIRED lines with the older one.""" + yet pushed signed its append-only RETIRED lines with the older one. Under + Amendment 23 S3 (#198 slice 1b) a write refuses over an unpushed commit, so + the stamp is published first; the RETIRED line must still carry the NEWEST + id of the row, never its first.""" newest = "dddd0138-0004-4000-8000-000000000004" text = reg.read("lanes/LANES.md").replace( f"| harness `{WRITER_ID}` |", f"| harness `{WRITER_ID}` `{newest}` |") reg.write("lanes/LANES.md", text) - reg.commit("repoX-1 resumed in a new transcript, not yet pushed") + reg.publish("repoX-1 resumed in a new transcript") result = reg.sweep("repoX-2") assert result.returncode == 0, (result.stdout, result.stderr) retired = [line for line in reg.read("lanes/log/repoX-2.md").splitlines() @@ -599,12 +603,13 @@ def test_item_1_an_unreadable_published_register_refuses_the_sweep(reg): from #97) reads the published register and this checkout's row with their status and refuses UNKNOWN (1) on either failing. Pinned here, because the fence for a live session whose id is only in the published row rests - on that refusal: here this checkout's uncommitted edit dropped the id.""" + on that refusal. (It used to be reached with this checkout's uncommitted + edit dropping the id; Amendment 23 S3 now refuses that edit first, choice + 9, so the published read's failure is pinned on its own.)""" live = "cccc0138-0003-4000-8000-000000000003" text = reg.read("lanes/LANES.md").replace(f"| `repoX-2` | harness `{OLD_ID}`", f"| `repoX-2` | harness `{live}`") reg.write("lanes/LANES.md", text) reg.publish("repoX-2 is live in a session here") - reg.write("lanes/LANES.md", text.replace(live, OLD_ID)) reg.live(live) reg.refuse_git("show origin/main:lanes/LANES.md") before = reg.head() @@ -618,7 +623,8 @@ def test_item_1_an_unreadable_published_register_refuses_the_sweep(reg): # ------------------------------------- Amendment 23's routing, by default resolution def test_a_routed_sweep_over_a_peer_edit_in_W_warns_and_captures_nothing(reg): - """SHOULD 4, the default-resolution case of the capture above. Under + """SHOULD 4, the default-resolution case of the refusal above (which was + the capture until slice 1b). Under `LANES_ROUTE_WRITES=1` with W resolved from its root and no `LANES_REPO`, the sweep goes through the sweeping lane's own checkout, so nothing captures the peer's edit in W. Since the re-review's N2 only `commit` diff --git a/tests/test_rename_publication_atomic_158.py b/tests/test_rename_publication_atomic_158.py index afc56f6..8ca7c7a 100644 --- a/tests/test_rename_publication_atomic_158.py +++ b/tests/test_rename_publication_atomic_158.py @@ -100,7 +100,8 @@ class RenameBox: def __init__(self, root: Path, shape: str) -> None: self.box = box = Sandbox(root) box.env["LANES_LANE_STATE_ROOT"] = str(root / "state") - box.env["LANES_NO_FETCH"] = "1" + # No `LANES_NO_FETCH=1`: for a writer it is a failed fetch, and + # Amendment 23 S3 refuses the write (ratified choice 2, #198 slice 1b). box.resolver() started = box.start("--no-launch") assert started.returncode == 0, (started.stdout, started.stderr) diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 2a8aaf4..7f238f8 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -2761,51 +2761,52 @@ def test_the_exit_code_table_carries_every_code_the_file_exits_with(): assert "64" in documented, "64 is the code every Amendment 11 read uses" -#: THE THREE CAUSES OF EXIT 3, as `lanes-edit.sh`'s exit-code table and the -#: manual's copy of it give them, and what ties each cause to the `die` that -#: reports it (opensoft/openRepoTools#68 item 5). +#: THE ONE CAUSE OF EXIT 3, as `lanes-edit.sh`'s exit-code table and the +#: manual's copy of it give it, and what ties it to the `die` that reports it +#: (opensoft/openRepoTools#68 item 5, re-pinned by #198's slice 1b). #: -#: A DIE IS TIED TO ITS CAUSE BY WHERE IT SITS AND NEVER BY WHAT IT SAYS. #68 -#: item 3 rewords `git_timeout_die`'s message and the act 2 `commit_publish` -#: rewrites the push loop's, and a rule that found each die by a run of its own -#: words would go blind at the first rewording that keeps the meaning (the -#: first version of this rule did: it found the rebase conflict's die by the -#: text `rebase conflict on origin/$LANES_BRANCH`). So: +#: Until Amendment 23 S3-S4 exit 3 had three causes — a rebase conflict, a +#: network timeout on a push or a pull (`git_timeout_die`), and six attempts +#: blocked by a peer's uncommitted file — and all three were born of the +#: `pull --rebase` that S4 removed. What is left is the one thing no writer can +#: settle by itself: it PUSHED (or its push timed out, and a hung push often +#: lands) and the read-back could not confirm the commit on origin. Its message +#: is `pushed, not confirmed: `, and it is never success. +#: +#: A DIE IS TIED TO ITS CAUSE BY WHERE IT SITS AND NEVER BY WHAT IT SAYS: #: #: * `function`: the cause owns every `die … 3` inside that function — -#: `git_timeout_die`, which exists to report one thing; +#: `cp_not_confirmed`, which exists to report one thing; #: * `marker`: else the cause owns a `die … 3` that follows its marker -#: nearer than any other cause's marker, inside the same function — the -#: `dirty_elsewhere` guard that skips the pull and pushes straight out -#: (a peer's uncommitted file), and the `git_net … pull` call, after which -#: the rebase conflict's die sits (its `else` arm, reached when that call -#: fails). Neither is the function's name, so renaming `commit_push` leaves -#: the rule standing; and the marker is a CALL, not a word, because the -#: peer branch's own `note` says "skipping 'pull --rebase'" a few lines -#: above its die. +#: nearer than any other cause's marker, inside the same function. The one +#: cause left has none; the field stays for the next cause that needs it. #: #: What a die and each row that documents its cause must keep saying is -#: `concept`: one word-group per cause, loose enough that rewording keeps -#: passing ("timed out", "timeout" and "time-out" are one concept) and tight -#: enough that a row which drops the cause fails. `scoped` is the wording #32 -#: put on the one cause whose die could be read as a claim about origin. +#: `concept`, loose enough that rewording keeps passing ("pushed, not +#: confirmed" and "pushed but not confirmed" are one concept) and tight enough +#: that a row which drops the cause fails; and `named`, the commit it could not +#: confirm (`` in a row, a variable in the die), so that the reader knows +#: what to look for by content. #: -#: A FOURTH CAUSE is a new row in this tuple and a new clause in BOTH rows — and -#: a fourth `die … 3` with no row is exactly what the rule below refuses. -_Exit3Cause = namedtuple("_Exit3Cause", "name function marker concept scoped") +#: A SECOND CAUSE is a new row in this tuple and a new clause in BOTH rows — and +#: a second `die … 3` with no row is exactly what the rule below refuses. +_Exit3Cause = namedtuple("_Exit3Cause", "name function marker concept named") _EXIT_3_CAUSES = ( _Exit3Cause( - "a network timeout", "git_timeout_die", None, - re.compile(r"\btime[ds]?[ -]?out", re.I), None), - _Exit3Cause( - "six attempts blocked by a peer's uncommitted file", None, - re.compile(r"\bdirty_elsewhere\b"), - re.compile(r"\b(?:uncommitted|dirty)\b", re.I), None), - _Exit3Cause( - "a rebase conflict", None, - re.compile(r"\bgit_net\b[^\n]*\bpull\b"), - re.compile(r"\brebase\W+conflict", re.I), - re.compile(r"\bnot pushed by this attempt", re.I)), + "a push the read-back cannot confirm", "cp_not_confirmed", None, + re.compile(r"\bpushed\W+(?:but\W+)?not\W+confirmed\b", re.I), + re.compile(r"\bnot\W+confirmed\s*:\W*(?:|\$\{?[A-Za-z_0-9@]+\}?)", + re.I)), +) + +#: THE CAUSES AMENDMENT 23 S3-S4 WITHDREW, each with the words a die or a row +#: would use to name it. No die reports any of them now, so a row that still +#: names one describes a message nobody prints. +_EXIT_3_WITHDRAWN = ( + ("a rebase conflict", re.compile(r"\brebase\W+conflict", re.I)), + ("a peer's uncommitted file blocking the rebase", + re.compile(r"\b(?:uncommitted|dirty)\b[^.;|]*\bblocks?\b", re.I)), + ("a pull", re.compile(r"\bpull\b", re.I)), ) #: A `die` whose message is one quoted literal and whose last word is `3`; a @@ -2821,10 +2822,10 @@ def test_the_exit_code_table_carries_every_code_the_file_exits_with(): r'''["'][ \t]+3(?=\s*(?:$|;|\|\||&&|\)|\}|#))''', re.M) #: A SENTENCE THAT CLAIMS THE EDIT NEVER REACHED ORIGIN. Exit 3 is the code for -#: "this attempt cannot confirm whether it also reached origin" (#32): a hung -#: push often landed, and an aborted pull is no proof the commit stayed local -#: in a checkout every lane shares. "not pushed BY THIS ATTEMPT" is the one -#: true form of the claim, and is the one form this lets through. +#: a push that was sent and could not be confirmed (Amendment 23 S4; #32 +#: before it): a hung push often landed, and a failed read-back is no proof the +#: commit stayed local. "not pushed BY THIS ATTEMPT" is the one form of the +#: claim #32 found true, and is the one form this lets through. _NOTHING_REACHED_ORIGIN = re.compile( r"\bnothing (?:was |has been )?pushed\b" r"|\bnot (?:been )?pushed\b(?! by this attempt)" @@ -2867,67 +2868,10 @@ def _exit_3_cause_of(code: str, at: int): return nearest -#: Every mention of `git_timeout_die` but its definition (`git_timeout_die() {`), -#: each read as a call; and what may stand before a command on its line. -_GIT_TIMEOUT_MENTION = re.compile(r"\bgit_timeout_die\b(?!\s*\()") -_AT_COMMAND_START = re.compile(r"(?:^|&&|\|\||[;|({]|\b(?:then|do|else))\s*$") - - -def _timeout_operations(code: str): - """`(operations, findings)`: the network operations every call of - `git_timeout_die` names (`push`, `pull`, ...), read off the words that - follow the call on its line: a quoted command "for the reader" - (`git_timeout_die "git push origin $B"`), a bare or quoted operation word - (`git_timeout_die push "git push origin $B"`), or both. A timeout row that - names a push and a pull is right until a call names a fetch. - - EVERY MENTION OF THE NAME BUT ITS DEFINITION IS READ AS A CALL, and a call - whose shape this cannot read is a finding that says exactly that. It is - never "nothing calls it", which would be false of a call spelled in a shape - the reader did not expect (#68 item 3 may hand it the operation as a word - of its own).""" - operations, findings = set(), [] - mentions = list(_GIT_TIMEOUT_MENTION.finditer(code)) - if not mentions: - findings.append( - "lanes-edit.sh has no call of `git_timeout_die` (every mention of " - "it but its definition is read as one), so the timeout cause both " - "rows document can never fire") - for mention in mentions: - line = code.count("\n", 0, mention.start()) + 1 - head = code.rfind("\n", 0, mention.start()) + 1 - tail = code.find("\n", mention.end()) - rest = code[mention.end():tail if tail != -1 else len(code)] - if not _AT_COMMAND_START.search(code[head:mention.start()]): - findings.append( - f"lanes-edit.sh:{line} mentions `git_timeout_die` somewhere " - "other than the start of a command, a shape this rule cannot " - "read as a call, so it cannot tell which network operation it " - "is called for. Make it the first word of a command; if it is " - "only a mention in a string, leave the name out of the string") - continue - words = [] - for token in (_commands_on(rest) or [[]])[0]: - if token.startswith("#"): - break - words.append(token) - text = " ".join(words) - named = set(re.findall(r"\b(push|pull|fetch|clone)\w*", text)) - if not named: - findings.append( - f"lanes-edit.sh:{line} calls `git_timeout_die` with {text!r}, " - "which names no network operation (push, pull, fetch or clone) " - "as a bare word or in a quoted command, so the rows cannot be " - "held to it") - operations |= named - return operations, findings - - def _exit_3_dies(src: str): - """`(dies, operations, findings)` for the text of `lanes-edit.sh`: every - `die "…" 3` as `(cause, line, message)`, the network operations - `git_timeout_die` is called for, and everything about the DIES themselves - that is wrong. The line numbers are the file's own (a whole-line comment is + """`(dies, findings)` for the text of `lanes-edit.sh`: every `die "…" 3` + as `(cause, line, message)`, and everything about the DIES themselves that + is wrong. The line numbers are the file's own (a whole-line comment is blanked, not removed), and no comment is read as code.""" code = "\n".join("" if line.lstrip().startswith("#") else line for line in src.splitlines()) @@ -2964,17 +2908,23 @@ def line_of(offset: int) -> int: findings.append( f"lanes-edit.sh:{line}: the exit-3 die for {cause.name} claims " f"that nothing reached origin ({said.group(0)!r}), where a " - "push may already have landed (#32)") + "push may already have landed (#32, Amendment 23 S4)") if not cause.concept.search(message): findings.append( f"lanes-edit.sh:{line}: the exit-3 die in the branch for " f"{cause.name} does not name that cause, so the row that " f"documents it describes a message nobody prints: {message!r}") - if cause.scoped and not cause.scoped.search(message): + if cause.named and not cause.named.search(message): findings.append( - f"lanes-edit.sh:{line}: the exit-3 die for {cause.name} lost " - "its attempt-scoped wording (\"not pushed by this attempt\"), " - f"the one form of that claim #32 found true: {message!r}") + f"lanes-edit.sh:{line}: the exit-3 die for {cause.name} never " + "names the commit it could not confirm, so nobody can look " + f"for it by content: {message!r}") + for withdrawn, words in _EXIT_3_WITHDRAWN: + if words.search(message): + findings.append( + f"lanes-edit.sh:{line}: the exit-3 die for {cause.name} " + f"still names {withdrawn}, a cause Amendment 23 S3-S4 " + f"withdrew with the pull: {message!r}") for cause in _EXIT_3_CAUSES: if not any(found is cause for found, _, _ in dies): findings.append( @@ -2982,8 +2932,7 @@ def line_of(offset: int) -> int: "either the cause is gone from the code and the rows are stale, " "or its die moved where nothing anchors it (see " "`_EXIT_3_CAUSES`)") - operations, more = _timeout_operations(code) - return dies, operations, findings + more + return dies, findings def _flattened(text: str) -> str: @@ -3014,7 +2963,7 @@ def _exit_3_row_of_the_manual(section: str) -> str: return row.group(0) if row else "" -def _exit_3_row_findings(name: str, whole: str, row: str, operations) -> list[str]: +def _exit_3_row_findings(name: str, whole: str, row: str) -> list[str]: """What is wrong with ONE document's exit-3 row: `whole` is all the text of the document that is held to the old claim, `row` is the exit-3 row alone, both flattened.""" @@ -3022,7 +2971,8 @@ def _exit_3_row_findings(name: str, whole: str, row: str, operations) -> list[st if "nothing was pushed" in whole or "nothing pushed" in whole: findings.append( f"{name} still claims that nothing reached origin on exit 3, which " - "an aborted pull does not prove (#32)") + "a push that was sent and not confirmed does not prove (#32, " + "Amendment 23 S4)") if not row: findings.append(f"{name} has no exit-3 row") return findings @@ -3036,19 +2986,18 @@ def _exit_3_row_findings(name: str, whole: str, row: str, operations) -> list[st findings.append( f"{name}'s exit-3 row no longer names {cause.name}, a cause " "the code's `die … 3` still reports. Exit 3 is \"no two " - "meanings on one number\", so a code with three causes has to " - "name all three (#50 rounds 3 and 4, 5203261904 and " - "5203455553)") - if cause.scoped and not cause.scoped.search(row): + "meanings on one number\", so the row has to name the one it " + "has (#50 rounds 3 and 4, 5203261904 and 5203455553)") + if cause.named and not cause.named.search(row): findings.append( - f"{name}'s exit-3 row lost the attempt-scoped wording for " - f"{cause.name} (\"not pushed by this attempt\")") - for operation in sorted(operations): - if not re.search(rf"\b{operation}", row, re.I): + f"{name}'s exit-3 row never names the commit {cause.name} " + "leaves on HEAD (``), which the die prints") + for withdrawn, words in _EXIT_3_WITHDRAWN: + if words.search(row): findings.append( - f"{name}'s exit-3 row never names a {operation}, which " - "`git_timeout_die` is called for: a timeout on it ends in " - "exit 3") + f"{name}'s exit-3 row still names {withdrawn}, a cause " + "Amendment 23 S3-S4 withdrew with the pull, so it describes " + "a message no die prints any more") return findings @@ -3057,20 +3006,20 @@ def _exit_3_findings(src: str, manual: str | None = None) -> list[str]: exit-code table and — when given — the manual's copy of that table disagree. Empty when they agree. The code's half comes first and alone when there is no manual: a standalone code leg has none (#186, T009).""" - dies, operations, findings = _exit_3_dies(src) + _dies, findings = _exit_3_dies(src) start = src.index("# EXIT CODES — every subcommand, one table") end = src.index("# --no-sweep", start) table = src[start:end] findings += _exit_3_row_findings( "lanes-edit.sh's exit-code table", _flattened(table), - _flattened(_exit_3_row_of_the_table(table)), operations) + _flattened(_exit_3_row_of_the_table(table))) if manual is not None: heading = manual.index("### Exit codes") section = manual[heading:manual.index( "3 to 6 are the codes this helper already used", heading)] findings += _exit_3_row_findings( "docs/README-lanes.md's copy of the table", _flattened(manual), - _flattened(_exit_3_row_of_the_manual(section)), operations) + _flattened(_exit_3_row_of_the_manual(section))) return findings @@ -3086,43 +3035,22 @@ def test_the_exit_3_documentation_agrees_with_the_die_message_it_describes(): file_exits_with` makes for a code missing from the table entirely. Round 2 (5203033893) found this test's own gap: it read only the table - and the manual, so a regression in the `die` MESSAGE ITSELF, back to - "nothing was pushed", left both docs unchanged and passed anyway — a - test that enforces two descriptions agree with each other, never that - either agrees with the code. The die is extracted and checked first now, + and the manual, so a regression in the `die` MESSAGE ITSELF left both docs + unchanged and passed anyway. The die is extracted and checked first now, so all three have to agree with the one thing that actually runs. - Round 3 (5203261904) named exit 3's other two causes (`git_timeout_die`, - and six push attempts exhausted against a peer's uncommitted file) that - this row used to leave out entirely; the row now names all three, which - means the phrase this test looks for can legitimately wrap onto its own - comment-continuation line — so the comparison text is whitespace- - normalized (one space, however the source wrapped it) rather than - matched as one literal run of characters. - - Round 4 (5203455553) found this test's own gap in THAT check: it looked - for the bare word "timeout" only, which the round-3 wording ("timeout on - the push itself") already satisfied while omitting `git_timeout_die`'s - OTHER call site — a timed-out `git pull --rebase` (`commit_push`'s - `git_timeout_die "git pull --rebase …"`) — entirely. Checking for - "timeout" alone would keep passing even if a push is all either row ever - names again; "push" and "pull" are now checked for too, so the row has to - cover both keywords or the test catches it. - - ROUND 5 (5203628254) AND #68 ITEM 5: the gap that was left. Of the three - `die "…" 3` in `lanes-edit.sh` — `git_timeout_die`'s own (:1894 at main - 08e7550), the six-attempts one in `commit_push`'s dirty-checkout branch - (:2125) and the rebase conflict's (:2192) — this test read ONE, and the - two rows only for the WORDS timeout, push, pull and peer's somewhere in - the code's whole table text, not even in its exit-3 row. So either of the - other two dies could say anything, "nothing was pushed" included, where a - push may already have landed, and this test stayed green. It now reads - EVERY `die … 3` and holds each to both rows. What ties a die to its cause - (where it sits, so that a rewording that keeps the meaning cannot blind the - rule: #68 item 3 will reword, and so will the act 2 `commit_publish`) and - what each die and row must keep saying are written at `_EXIT_3_CAUSES` - above. A `die … 3` no cause explains is a finding: a fourth cause needs a - clause in BOTH rows before it ships. + Rounds 3 to 5 (5203261904, 5203455553, 5203628254) and #68 item 5 made it + read EVERY `die … 3`, tie each to its cause by where it sits, and hold + each to both rows, so that a rewording that keeps the meaning cannot blind + the rule and a die no row documents is a finding. + + AMENDMENT 23 S3-S4 (#198, slice 1b) RE-PINS IT TO ONE CAUSE. The pull is + gone, and with it the rebase conflict, the timed-out pull, the peer's file + that blocked every rebase and `git_timeout_die`. What is left is a push + the read-back cannot confirm: `cp_not_confirmed`'s + `pushed, not confirmed: `. Both rows must name it and the commit, no + die or row may still name a withdrawn cause, and a `die … 3` anywhere + else — the old timeout die come back, say — is a finding. The rule is `_exit_3_findings`; its own regression cases, one red per mutation and one green per rewording that keeps the meaning, are the @@ -3144,45 +3072,36 @@ def test_the_exit_3_documentation_agrees_with_the_die_message_it_describes(): + "\n ".join(findings)) -#: A FIXTURE FOR THE RULE ABOVE, shaped like the three places exit 3 is raised -#: and the two rows that document it, and carrying the decoys that fooled -#: simpler rules: a `die … 3` in a comment, and a `note` that says "skipping -#: 'pull --rebase'" a few lines above the peer branch's die. +#: A FIXTURE FOR THE RULE ABOVE, shaped like the one place exit 3 is raised +#: and the two rows that document it, and carrying the decoys that could fool +#: a simpler rule: a `die … 3` in a comment, a `note` in the caller that says +#: "not confirmed" with no exit code, and the bound's `die … 6`, which names +#: the commit kept on HEAD in the same words. EXIT_3_SOURCE = '''\ # EXIT CODES — every subcommand, one table -# 0 done -# 3 the edit is never a permanent loss, but this attempt cannot confirm -# whether it also reached origin. THREE CAUSES: -# a rebase conflict (not pushed BY THIS ATTEMPT; look by content first); -# a network timeout on a push OR a pull (a hung one often landed); -# or six attempts exhausted because a peer's own uncommitted file blocks -# every rebase. +# 0 done, and for a write CONFIRMED by a read-back +# 3 PUSHED, NOT CONFIRMED: . The push was sent, or it timed out, and +# the read-back could not confirm the commit on origin. Never success; +# the commit stays on HEAD, so look for it by content. # 4 the mutex could not be taken within 60s +# 6 the commit is kept on HEAD after six attempts # --no-sweep is not an exit code # die "a decoy in a comment, which nothing runs" 3 -git_timeout_die() { # - release_lock - die "timed out after ${GIT_TIMEOUT}s on '$1' — your commit is local; nothing was lost." 3 +cp_not_confirmed() { # + note "look for $1 by content before you write again" + die "pushed, not confirmed: $1 — $3; the commit is kept on HEAD, and this is never success" 3 +} +commit_publish() { + git_net push -q origin "refs/heads/main:refs/heads/main" + if cp_confirm "$sha"; then + note "pushed origin/main: $sha" + return 0 + fi + note "the read-back did not confirm $sha" + cp_not_confirmed "$sha" "$msg" "the read-back failed" } -commit_push() { - while [ "$attempt" -le 6 ]; do - others="$(dirty_elsewhere)" - if [ -n "$others" ]; then - note "skipping 'pull --rebase' and pushing straight out" - git_net push -q origin "$BRANCH" && return 0 - [ "$GIT_TIMED_OUT" = 1 ] && git_timeout_die "git push origin $BRANCH" - if [ "$attempt" -ge 6 ]; then - die "could not push origin/$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 pull --rebase -q origin "$BRANCH"; then - git_net push -q origin "$BRANCH" && return 0 - [ "$GIT_TIMED_OUT" = 1 ] && git_timeout_die "git push origin $BRANCH" - else - [ "$GIT_TIMED_OUT" = 1 ] && git_timeout_die "git pull --rebase origin $BRANCH" - die "rebase conflict on origin/$BRANCH — not pushed by this attempt; a later write from this checkout may already carry it, so look before you retry." 3 - fi - done - die "could not push origin/$BRANCH after 6 attempts" 6 +cp_replay() { + [ "$CP_ATTEMPT" -ge 6 ] && die "could not push origin/main after six attempts; the commit is kept on HEAD: $1" 6 } ''' @@ -3191,147 +3110,134 @@ def test_the_exit_3_documentation_agrees_with_the_die_message_it_describes(): | code | meaning | |---|---| -| 0 | done | -| 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; look by content first), a network timeout on a push or a pull (a hung one often landed), or six attempts exhausted because a peer's uncommitted file blocks every rebase | +| 0 | done, and for a write confirmed by a read-back | +| 3 | **pushed, not confirmed: ``**. The push was sent, or it timed out, and the read-back could not confirm the commit on origin. Never success; the commit stays on HEAD | | 4 | the mutex could not be taken within 60s | 3 to 6 are the codes this helper already used; 7 and 8 were free. ''' -def _a_fourth_die(message: str) -> str: - """A FOURTH FUNCTION WITH AN EXIT-3 DIE, to append to the fixture's source.""" - return "index_open() {\n die \"" + message + "\" 3\n}\n" +def _a_second_die(message: str, name: str = "index_open") -> str: + """A SECOND FUNCTION WITH AN EXIT-3 DIE, to append to the fixture's source.""" + return name + "() {\n die \"" + message + "\" 3\n}\n" #: Each row is (why, where, old, new, what the findings must say): `old` is #: replaced by `new` in the fixture's `code` or `manual`, and the rule must #: refuse the result, in the words of the last column. EXIT_3_REFUSED = [ - ("the timeout die says nothing was pushed", "code", - "your commit is local; nothing was lost.", - "your commit is local; nothing was pushed.", - "claims that nothing reached origin"), - ("the peer's-file die says nothing was pushed", "code", - "Nothing was lost — your commit is local.", - "Nothing was pushed — your commit is local.", + ("the die says nothing was pushed", "code", + "the commit is kept on HEAD, and this is never success", + "nothing was pushed; the commit is kept on HEAD", "claims that nothing reached origin"), - ("the conflict die drops its attempt-scoped wording", "code", - "not pushed by this attempt; a later write", - "not pushed to origin; a later write", - "attempt-scoped"), - ("the conflict die no longer says what failed", "code", - "rebase conflict on origin/$BRANCH — not pushed", - "the pull failed on origin/$BRANCH — not pushed", + ("the die no longer says what it could not confirm", "code", + 'die "pushed, not confirmed: $1 —', + 'die "the push failed: $1 —', "does not name that cause"), - ("the timeout die no longer says it timed out", "code", - "timed out after ${GIT_TIMEOUT}s on '$1' — your commit", - "failed on '$1' — your commit", - "does not name that cause"), - ("the peer's-file die no longer says what blocks the rebase", "code", - "and a peer's uncommitted file blocks the rebase", - "and the rebase is blocked", - "does not name that cause"), - ("a fourth die in a function no row documents", "code", - 'die "could not push origin/$BRANCH after 6 attempts" 6\n}\n', - 'die "could not push origin/$BRANCH after 6 attempts" 6\n}\n' - + _a_fourth_die("the index is gone"), + ("the die no longer names the commit", "code", + 'die "pushed, not confirmed: $1 —', + 'die "pushed, not confirmed —', + "never names the commit"), + ("the die names a withdrawn cause", "code", + "$3; the commit is kept on HEAD,", + "$3, or a rebase conflict; the commit is kept on HEAD,", + "still names a rebase conflict"), + ("a second die in a function no row documents", "code", + "after six attempts; the commit is kept on HEAD: $1\" 6\n}\n", + "after six attempts; the commit is kept on HEAD: $1\" 6\n}\n" + + _a_second_die("the index is gone"), "none of the documented causes explains"), - ("a fourth die whose message holds a command substitution", "code", - 'die "could not push origin/$BRANCH after 6 attempts" 6\n}\n', - 'die "could not push origin/$BRANCH after 6 attempts" 6\n}\n' - + _a_fourth_die('$(describe "the index")'), + ("the old timeout die comes back", "code", + "after six attempts; the commit is kept on HEAD: $1\" 6\n}\n", + "after six attempts; the commit is kept on HEAD: $1\" 6\n}\n" + + _a_second_die("timed out on '$1' — your commit is local; nothing was " + "lost.", "git_timeout_die"), + "in git_timeout_die that none of the documented causes explains"), + ("a die whose message holds a command substitution", "code", + "after six attempts; the commit is kept on HEAD: $1\" 6\n}\n", + "after six attempts; the commit is kept on HEAD: $1\" 6\n}\n" + + _a_second_die('$(describe "the index")'), "cannot read"), - ("a fourth die in the push loop's success branch", "code", - ' git_net push -q origin "$BRANCH" && return 0\n' - ' [ "$GIT_TIMED_OUT" = 1 ] && git_timeout_die "git push origin $BRANCH"\n' - ' else\n', - ' git_net push -q origin "$BRANCH" && return 0\n' - ' [ "$GIT_TIMED_OUT" = 1 ] && git_timeout_die "git push origin $BRANCH"\n' - ' die "the push raced six times" 3\n' - ' else\n', - "does not name that cause"), - ("the timeout die now exits 6, so no die reports a timeout", "code", - "nothing was lost.\" 3\n}", - "nothing was lost.\" 6\n}", - "no `die … 3` reports a network timeout"), - ("a call site names a network operation no row names", "code", - 'git_timeout_die "git pull --rebase origin $BRANCH"', - 'git_timeout_die "git fetch origin $BRANCH"', - "never names a fetch"), - ("a call site names no network operation at all", "code", - 'git_timeout_die "git pull --rebase origin $BRANCH"', - 'git_timeout_die "the network call"', - "names no network operation"), - ("a call hands git_timeout_die a bare operation word no row names", "code", - 'git_timeout_die "git pull --rebase origin $BRANCH"', - 'git_timeout_die fetch "the network call"', - "never names a fetch"), - ("a mention of git_timeout_die the reader cannot place as a call", "code", - " release_lock\n", - " release_lock\n note \"git_timeout_die has released the lock\"\n", - "cannot read as a call"), - ("the table's row loses the timeout cause", "code", - "# a network timeout on a push OR a pull (a hung one often landed);\n", - "# a network failure on a push OR a pull (a hung one often landed);\n", - "no longer names a network timeout"), - ("the table's row loses the peer's-file cause", "code", - "# or six attempts exhausted because a peer's own uncommitted file blocks\n" - "# every rebase.\n", - "# or one more thing.\n", - "no longer names six attempts blocked by a peer's uncommitted file"), - ("the table's row loses the pull", "code", - "a network timeout on a push OR a pull (a hung one often landed)", - "a network timeout on a push (a hung one often landed)", - "never names a pull"), - ("the table's row loses its attempt-scoped wording", "code", - "not pushed BY THIS ATTEMPT; look by content first", - "look by content first", - "lost the attempt-scoped wording"), + ("an exit-3 die in the caller, beside the one that owns it", "code", + ' note "the read-back did not confirm $sha"\n', + ' note "the read-back did not confirm $sha"\n' + ' [ -n "$sha" ] || die "pushed, not confirmed: $sha" 3\n', + "in commit_publish that none of the documented causes explains"), + ("the die now exits 6, so no die reports the cause", "code", + "and this is never success\" 3\n}", + "and this is never success\" 6\n}", + "no `die … 3` reports a push the read-back cannot confirm"), + ("the table's row loses the cause", "code", + "# 3 PUSHED, NOT CONFIRMED: .", + "# 3 THE PUSH MAY HAVE FAILED: .", + "no longer names a push the read-back cannot confirm"), + ("the table's row loses the commit", "code", + "# 3 PUSHED, NOT CONFIRMED: .", + "# 3 PUSHED, NOT CONFIRMED.", + "never names the commit"), + ("the table's row still names a rebase conflict", "code", + "Never success;\n", + "Never success; a rebase conflict is the other cause;\n", + "still names a rebase conflict"), + ("the table's row still names the peer's file", "code", + "Never success;\n", + "Never success; or a peer's uncommitted file blocks every rebase;\n", + "still names a peer's uncommitted file"), + ("the table's row still names a pull", "code", + "The push was sent, or it timed out,", + "The push was sent, or the pull timed out,", + "still names a pull"), ("the table's row says the edit did not reach origin", "code", - "whether it also reached origin. THREE CAUSES:", - "whether it also reached origin; if it did not reach origin, retry. THREE CAUSES:", + "could not confirm the commit on origin. Never success;", + "could not confirm the commit on origin; if it did not reach origin, retry. Never success;", "claims that nothing reached origin"), ("the table says nothing was pushed", "code", - "# 0 done\n", + "# 0 done, and for a write CONFIRMED by a read-back\n", "# 0 done, and on a refusal nothing was pushed\n", "still claims that nothing reached origin"), ("the manual's row says nothing was pushed", "manual", - "cannot confirm whether it also reached origin", - "cannot confirm — nothing was pushed", + "Never success; the commit stays on HEAD |", + "Never success; nothing was pushed |", "claims that nothing reached origin"), + ("the manual's row loses the cause", "manual", + "**pushed, not confirmed: ``**", + "**the push may have failed: ``**", + "no longer names a push the read-back cannot confirm"), + ("the manual's row still names a pull", "manual", + "The push was sent, or it timed out,", + "The push was sent, or a pull timed out,", + "still names a pull"), ("the manual's row is gone", "manual", - "| 3 | the edit", - "| 13 | the edit", + "| 3 | **pushed", + "| 13 | **pushed", "has no exit-3 row"), ] #: Each row is (why, where, old, new): a rewording that keeps the meaning, which #: the rule must let through. EXIT_3_ACCEPTED = [ - ("the timeout die is reworded and still says timeout", "code", - "timed out after ${GIT_TIMEOUT}s on '$1' — your commit is local; nothing was lost.", - "git hit its ${GIT_TIMEOUT}s timeout on '$1'; your commit is local and nothing was lost."), - ("the peer's-file die says dirty and not uncommitted", "code", - "a peer's uncommitted file blocks the rebase. Nothing was lost — your commit is local.", - "another writer's dirty file blocks the rebase. Nothing was lost; your commit is still local."), - ("the conflict die keeps its wording and moves its clauses", "code", - "a later write from this checkout may already carry it, so look before you retry", - "so look before you retry: a later write from this checkout may already carry it"), - ("the function that holds the push loop is renamed", "code", - "commit_push() {", "commit_publish() {"), - ("a call hands git_timeout_die the operation as a bare word", "code", - 'git_timeout_die "git pull --rebase origin $BRANCH"', - 'git_timeout_die pull "the rebase on origin/$BRANCH"'), + ("the die says pushed but not confirmed", "code", + 'die "pushed, not confirmed: $1 —', + 'die "pushed but not confirmed: $1 —'), + ("the die names the commit by a braced variable", "code", + 'die "pushed, not confirmed: $1 —', + 'die "pushed, not confirmed: ${cn_sha} —'), + ("the die's clauses move", "code", + "the commit is kept on HEAD, and this is never success", + "this is never success, and the commit is kept on HEAD"), + ("the function that calls it is renamed", "code", + "commit_publish() {", "publish_commit() {"), ("two spaces stand before the exit code", "code", - "nothing was lost.\" 3\n}", "nothing was lost.\" 3\n}"), + "never success\" 3\n}", "never success\" 3\n}"), ("a comment trails the exit code", "code", - "nothing was lost.\" 3\n}", "nothing was lost.\" 3 # the mutex is already released\n}"), - ("the table wraps the attempt-scoped phrase onto a continuation line", "code", - "not pushed BY THIS ATTEMPT;", "not pushed BY THIS\n# ATTEMPT;"), - ("the manual spells time-out and names each operation", "manual", - "a network timeout on a push or a pull", - "a network time-out on a push or on a pull"), + "never success\" 3\n}", "never success\" 3 # the lock is released by the trap\n}"), + ("the table wraps the cause onto a continuation line", "code", + "# 3 PUSHED, NOT CONFIRMED: .", + "# 3 PUSHED, NOT\n# CONFIRMED: ."), + ("the manual says pushed but not confirmed", "manual", + "**pushed, not confirmed: ``**", + "**pushed but not confirmed: ``**"), ] @@ -3355,10 +3261,11 @@ def test_the_exit_3_rule_is_green_on_the_fixture_it_is_mutated_from(): def test_the_exit_3_rule_is_red_on_a_die_or_a_row_that_disagrees( why, where, old, new, finding): """RED: each case plants one disagreement in the fixture — a die that - claims nothing reached origin, a die or a row that lost its cause, a - fourth die no row documents — and the rule must name it, in words that - say WHICH check refused. A case that passed for the wrong reason would - teach nothing: the last column is what the findings must hold.""" + claims nothing reached origin, a die or a row that lost its cause or its + commit, a withdrawn cause named again, a second die no row documents — and + the rule must name it, in words that say WHICH check refused. A case that + passed for the wrong reason would teach nothing: the last column is what + the findings must hold.""" code, manual = _exit_3_fixture(where, old, new) findings = _exit_3_findings(code, manual) assert any(finding in each for each in findings), ( @@ -3370,15 +3277,144 @@ def test_the_exit_3_rule_is_red_on_a_die_or_a_row_that_disagrees( ids=[row[0] for row in EXIT_3_ACCEPTED]) def test_the_exit_3_rule_is_green_on_a_rewording_that_keeps_the_meaning( why, where, old, new): - """GREEN: #68 item 3 and the act 2 `commit_publish` will reword these - messages, so a rule anchored to the words of one would fail on the first - change that is only a change of words. The die is tied to its cause by - where it sits, and the rows by what they name.""" + """GREEN: a later round will reword these messages, so a rule anchored to + the words of one would fail on the first change that is only a change of + words. The die is tied to its cause by where it sits, and the rows by what + they name.""" code, manual = _exit_3_fixture(where, old, new) assert _exit_3_findings(code, manual) == [], ( f"the exit-3 rule refused a rewording that keeps the meaning ({why})") +#: AMENDMENT 23 S3-S4: NO LANE WRITER PULLS (#198). A `pull --rebase` in front +#: of a push is what stranded a write on a stale base eight times in two days; +#: every writer now fetches and fast-forwards before the edit, and a refused +#: push replays the one edit. Asserted for `lanes-edit.sh` in slice 1b; the +#: handoff's writers, which still pull until their slices, join the list then. +_NO_PULL_FILES = ("lanes-edit.sh",) +#: A `pull` that rebases: `--rebase`, `-r` or `--rebase=…`, on a line that is +#: not a comment. +_PULL_REBASE = re.compile(r"\bpull\b[^\n]*?(?:--rebase\b|\s-r\b)") + + +def _pulls_with_rebase(src: str) -> list[int]: + """The line numbers of `src` (a shell script) that run a rebasing pull, + comments aside.""" + return [n for n, line in enumerate(src.splitlines(), 1) + if not line.lstrip().startswith("#") and _PULL_REBASE.search(line)] + + +@pytest.mark.parametrize("name", _NO_PULL_FILES) +def test_no_lane_writer_pulls_with_rebase(name): + """No writer of the register rebases (Amendment 23 S3-S4). A die message + that still TELLS a person to `pull --rebase` counts: it is text outside a + comment, and it would send them to the act S3 replaced.""" + src = (REPO / name).read_text(encoding="utf-8") + lines = _pulls_with_rebase(src) + assert not lines, ( + f"{name} still runs or prints a rebasing pull outside a comment at " + f"line(s) {lines}: Amendment 23 S3 fetches and fast-forwards instead, " + "and S4 replays a refused push (#198)") + + +@pytest.mark.parametrize("line,red", [ + (' git_net -C "$C" pull --rebase -q origin "$B"', True), + (' git pull -q -r origin main', True), + (' die "Pull first — `git -C $C pull --rebase` — and re-run." 2', True), + (' die "pull (#198) --rebase, then re-run" 2', True), + (" # the old loop ran `pull --rebase` here", False), + (' git -C "$C" merge --ff-only -q refs/remotes/origin/main', False), + (' note "no writer pulls any more"', False), +], ids=["git_net-pull", "short-flag", "a-die-that-says-it", "a-hash-inside-it", "a-comment", + "the-fast-forward", "the-word-alone"]) +def test_the_no_pull_rule_reads_what_it_should(line, red): + assert bool(_pulls_with_rebase("x() {\n" + line + "\n}\n")) is red + + +#: TEXT THAT PROMISES THE CARRY (#198 §1.10). Until S3, a commit stranded in a +#: shared checkout was said to reach origin when "a later write from this +#: checkout" pulled it onto its own: true by accident, and how a peer's commit +#: was published under another lane's subject. S3 refuses every write over such +#: a commit, so no text may promise that one will carry it. A sentence that +#: says NO later write carries it is the truth and passes. +_CARRY_PROMISES = ( + re.compile(r"(? list[str]: + """`: ` for each promise of the carry in `text`; a script's + comments are history and are not read. A document is read a PARAGRAPH at + a time, its lines joined (SHOULD 1: a promise wrapped across two lines got + past a rule that read one), and `` is the paragraph's first.""" + found = [] + if script: + units = [(n, line) for n, line in enumerate(text.splitlines(), 1) + if not line.lstrip().startswith("#")] + else: + units, start, para = [], 1, [] + for n, line in enumerate(text.splitlines() + [""], 1): + if line.strip(): + if not para: + start = n + para.append(line.strip()) + elif para: + units.append((start, " ".join(para))) + para = [] + for n, unit in units: + for rule in _CARRY_PROMISES: + m = rule.search(unit) + if m: + found.append(f"{n}: {m.group(0)}") + return found + + +def test_no_text_promises_the_carry(): + """Neither `lanes-edit.sh`'s messages nor the manual say that a later write + will carry a stranded commit to origin (Amendment 23 S3; #198 §1.10). The + rule was red at #282's head `874e020`: the exit-3 die and its table row + ("a later write from this checkout may already carry it"), the rename + refusal ("the next register write from this checkout carries it") and the + dirty-checkout recovery ("its own push publishes their commits").""" + src = (REPO / "lanes-edit.sh").read_text(encoding="utf-8") + found = _carry_promises(src, script=True) + assert not found, f"lanes-edit.sh promises the carry: {found}" + manual = doc("docs/README-lanes.md").read_text(encoding="utf-8") + found = _carry_promises(manual, script=False) + assert not found, f"docs/README-lanes.md promises the carry: {found}" + + +@pytest.mark.parametrize("text,red", [ + ("a later write from this checkout may already carry it, so look first", True), + ("the next register write from this checkout carries it", True), + ("then re-run the refused act: its own push publishes their commits", True), + ("no later write carries it (Amendment 23 S3 refuses every write over it)", False), + ("every later write from that checkout refuses until it is published", False), + ("An uncommitted hand edit left in the worktree is captured by the *next* lane's" + + chr(10) + "helper run, not by yours", True), + ("the commit of a peer's uncommitted register edit, which the act" + chr(10) + + "makes as its own `LANES(pre-existing@…)` commit", True), + ("the next run is REFUSED by the dirty-checkout guard", True), + ("`refuse_dirty_checkout` then refuses `log`, `claim` and `release`", True), + ("the `LANES(pre-existing@)` capture and `--sweep` are withdrawn (2)", False), +], ids=["exit-3", "the-rename", "the-recovery", "the-negation", "the-refusal", + "a-wrapped-capture", "a-wrapped-own-commit", "the-guard", "the-function", "the-withdrawal"]) +def test_the_carry_rule_reads_what_it_should(text, red): + assert bool(_carry_promises(text, script=False)) is red + + #: ADOPTION ACT 0, AND THE ONE SHA THAT IS IT. `opensoft/brett-wip#5` merged #: 2026-09-13T19:14:37Z, SQUASHED — so the PR's pre-merge head is not an #: ancestor of `origin/main` and names code that never landed, while the merge diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index d24c321..0f4cc84 100644 --- a/tests/test_supervised_restart_regressions.py +++ b/tests/test_supervised_restart_regressions.py @@ -21,7 +21,10 @@ def restart_box(tmp_path: Path) -> Sandbox: box = Sandbox(tmp_path) box.env["LANES_LANE_STATE_ROOT"] = str(tmp_path / "state") - box.env["LANES_NO_FETCH"] = "1" + # No `LANES_NO_FETCH=1` (Amendment 23 S3 step 1, ratified choice 2): for a + # writer a fetch that did not run is a fetch that failed, so every + # `add-row`, `log` and `claim` these cases make would refuse (2). The + # sandbox's origin is a local bare repository, so the fetch is offline. box.env["TMUX_PANE"] = "%1" tmux = box.fakebin / "tmux" tmux.write_text(tmux.read_text().replace("#!/usr/bin/env bash\n", '''#!/usr/bin/env bash