From a86ba1077633b8d0564dd802006334e7119d38bd Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 11:03:06 +0000 Subject: [PATCH 01/59] Amendment 23 S3: every writer fetches, checks and fast-forwards its checkout under the lock before it edits, or refuses with nothing written (#198, slice 1b stage 1) The writers committed first and synced second: an edit made on whatever the checkout last saw, then a `pull --rebase` in front of the push. A checkout one adjacent row behind conflicted, aborted and kept the write as a local commit origin never heard of, which is the stranding #198 measured and Amendment 23 exists to end. `checkout_sync` is S3, written once and run by every writer under its checkout's lock, before it reads the file it writes, routed or not: the fetch (a failed one, or LANES_NO_FETCH=1, refuses), 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 change to a path the write commits (the register refuses every verb but `commit`, which carries the hand edit across the fast-forward), and the fast-forward itself, whose refusal quotes git and names the file. Each refusal is exit 2, names the checkout, says nothing was written, prints its row's recovery and releases the lock. Every ref is spelled in full, so a local branch named origin/main cannot shadow the tracking ref. It replaces `refuse_dirty_checkout` and the two captures in `write_event` and in set-row-state, replace-in-row, append-session-id, append-line, add-row and `commit`. `claim` loses its whole-checkout guard (ratified choice 8), `--sweep` is withdrawn with exit 2 (choice 9), and `commit` is the hand-edit commit or, with a clean register, the publish act. `commit_push` is unchanged in this stage; S4 replaces it next. New: tests/test_a23_guard_refusals.py, every S3 row against every writing verb, the fast-forward's refusal, choice 8's pass, the 13:12Z shape, and the guard through a routed lane checkout and through W with routing off. tests/a23_box.py gains the seam checkout, a logging LANES_GIT shim and the snapshot a refusal is compared against. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 325 +++++++++++++++++++++++++--- tests/a23_box.py | 79 +++++++ tests/test_a23_guard_refusals.py | 359 +++++++++++++++++++++++++++++++ 3 files changed, 728 insertions(+), 35 deletions(-) create mode 100644 tests/test_a23_guard_refusals.py diff --git a/lanes-edit.sh b/lanes-edit.sh index 382cb6f7..0d8b9e09 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2137,6 +2137,250 @@ refuse_dirty_checkout() { die "refusing to $rd_what on a checkout that cannot be rebased" 2 } +# ==================================================== 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`). +# 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_refuse() { # […] + csr_what="$1"; csr_why="$2"; shift 2 + if [ "$#" -gt 0 ]; then + 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 +} +checkout_sync() { # […] + cs_what="$1"; cs_lane="${2:-}"; cs_mode="$3"; shift 3 + CS_NO_ORIGIN_BRANCH=0; CS_CARRY_DIFF="" + [ "$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" + 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 + 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 + + # ---- 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 origin/$LANES_BRANCH HEAD" \ + "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 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 + 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 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" + 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 + if [ "$cs_mode" != publish ] && [ "$CS_NO_ORIGIN_BRANCH" != 1 ] && + ! git -C "$cs_c" merge-base --is-ancestor "$cs_trk" 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_trk" >"$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 + if ! git -C "$cs_c" apply --whitespace=nowarn -- "$CS_CARRY_DIFF" 2>"$cs_tmp/apply.err"; then + cs_refuse "$cs_what" "the hand edit of $LANES_PATH no longer applies to origin's version of it, so it is KEPT in $CS_CARRY_DIFF 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)" + fi + cs_flush + LOG_SYNC_FETCH=yes + return 0 +} + # ------------------------------------------- AMENDMENT 8, ruling (h): THE # WRITER NEVER BLOCKS THE ESTATE. # @@ -6084,11 +6328,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 @@ -6113,33 +6352,24 @@ 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" + # 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[@]}" we_rc=$? state_events_flush @@ -15508,6 +15738,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. @@ -15657,7 +15900,7 @@ 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 n="$(row_line "$lane")" || exit 2 row="$(sed -n -e "${n}p" "$LANES_FILE")" srs_rc=0; row_split_state_cell "$row" || srs_rc=$? @@ -15683,7 +15926,6 @@ 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" ;; @@ -15697,7 +15939,7 @@ 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 n="$(row_line "$lane")" || exit 2 row="$(sed -n -e "${n}p" "$LANES_FILE")" c="$(count_occurrences "$row" "$old")" || exit 2 @@ -15717,7 +15959,6 @@ 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" ;; @@ -15762,7 +16003,7 @@ 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 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 @@ -15773,7 +16014,6 @@ 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" ;; @@ -15832,10 +16072,9 @@ 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" ;; @@ -15920,10 +16159,9 @@ Nothing was written." 2 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 + 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 @@ -15940,6 +16178,18 @@ 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_push` 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" + 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 @@ -16948,7 +17198,12 @@ EOF # 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. 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 diff --git a/tests/a23_box.py b/tests/a23_box.py index be3f087f..bbbabd37 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 00000000..0b674059 --- /dev/null +++ b/tests/test_a23_guard_refusals.py @@ -0,0 +1,359 @@ +# 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 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 origin/main HEAD", + "the publish act", "reset --keep 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 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", + f"LANES_LANE={LANE} ", " 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) + 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.""" + 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")) + behind = box.git("-C", str(c), "rev-list", "--count", "HEAD..refs/remotes/origin/main").strip() + 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"], behind + stamp = run_verb(box, "set-row-state") + assert stamp.returncode == 0, stamp.stderr + note = run_verb(box, "log") + assert note.returncode == 0, note.stderr + register = box.origin_register() + assert "LIVE · 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" + + +# -------------------------------------------------------- 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 and "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 and "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" From ef1377f6312b3ef101da518b0034f5f190933f82 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 16:06:34 +0000 Subject: [PATCH 02/59] Amendment 23 S4: every write commits by pathspec, pushes with no pull and reads origin back, a refused push replays the one edit on origin's new tip six times at most, and `commit` with nothing staged is the publish act (#198, slice 1b stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `commit_publish` replaces `commit_push`. The commit by pathspec stays as it was (the case-only rename's index commit, the bytecode refusal, `add`, `commit`); nothing staged is still a no-op with no push. What changed is everything after the commit: - The push sends `refs/heads/main` with no pull. A push that went, or that timed out, is read back: a fresh fetch, then `merge-base --is-ancestor`. Confirmed is 0 and `pushed … confirmed by a read-back`; not confirmed is 3, `pushed, not confirmed: `, with the commit kept on HEAD and the look-by-content steps printed. Exit 3 now has that one cause. - A refused push is read back first (it may have landed). Where origin moved, `reset --keep` drops this write's commit, the only unpublished one S3 allows, and the same argv runs again under the same pid with the lock adopted across the `exec` (`LANES__REPLAY_PID`, `LANES__ATTEMPT`, read and unset at load). The verb's own checks re-run on the new content: `claim`'s rescan first (CLAIM-LOST, 7, through the new `claim_lost_write`), the one-match guard, `add-row`'s retirement and duplicate checks. - At the sixth attempt the commit is kept on HEAD and named (6); every later write from that checkout refuses on S3's unpushed row (ratified choices 4 and 5). A `reset --keep` git refuses, and a push refused with origin unmoved (a hook, a permission: no lost race), are 6 the same way. - `commit`'s carry form replays by `reset --mixed ^`, because its edit is the hand edit and no argv makes it again: `reset --keep` would have dropped it and the re-run would have been an empty publish act. - A carried row edit whose own lines are unchanged on origin applies with no context when its full context no longer matches, so a neighbour's row changed on origin does not refuse it; an insertion has no line of its own and still needs its context. - `commit` with nothing staged is the publish act: S3 without the unpushed row and without the fast-forward; the queue listed by content before the push; #214's fence kept; a fast-forward push only, refusing (2) a stack origin has moved past and naming S6.3's archive then `reset --keep` (Open question 2); then the read-back, and no replay. Removed with the pull: `dirty_elsewhere` and the 5(d) straight push, `git_timeout_die`, `cp_publish_refence`, `add_row_retirement_rescan`, `CP_AFTER_REBASE` and its `ADD_ROW_*` state, and the CLAIM-LOST block after the pull. The unpushed refusal keeps NIT 5's words ("if any '+' line is not your own write, STOP and take it to the lane its subject names"). The exit table (rows 0, 2, 3, 6) and the manual's copy say this. `tests/test_repo_hygiene.py`'s exit-3 rule is re-pinned to its one cause, `cp_not_confirmed`, and also refuses a die or row that still names a withdrawn cause. New `tests/test_a23_publish_replay.py` (28 cases); `tests/test_commit_push_published_edit_194.py` rewritten where it pinned the pull, the straight push or the conflict recovery, its fence cases kept; `tests/test_a23_guard_refusals.py`'s two expectations corrected. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 8 +- lanes-edit.sh | 787 ++++++++++--------- tests/test_a23_guard_refusals.py | 10 +- tests/test_a23_publish_replay.py | 631 +++++++++++++++ tests/test_commit_push_published_edit_194.py | 252 +++--- tests/test_repo_hygiene.py | 523 +++++------- 6 files changed, 1394 insertions(+), 817 deletions(-) create mode 100644 tests/test_a23_publish_replay.py diff --git a/docs/README-lanes.md b/docs/README-lanes.md index a6caed0a..29e51089 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1220,13 +1220,13 @@ else. | code | meaning | |---|---| -| 0 | done, including `commit`'s publish of a queue that `pull --rebase` emptied because origin already held every commit in it by content (#194; it says so, and sends no push) | +| 0 | done — and for a write, **confirmed**: the commit was read back on origin before the writer said `pushed` (Amendment 23 S4) | | 1 | environment — no register, no writer | -| 2 | refusal — bad arguments, the object is held, an unknown alias, a checkout that cannot be rebased, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them) | -| 3 | the edit is never a permanent loss, but this attempt cannot confirm whether it also reached origin — a rebase conflict (not pushed by this attempt; a later write from this checkout may already carry it to origin, so look by content before you retry), a network timeout on a push or a pull (a hung one often already landed), or six attempts exhausted because a peer's uncommitted file blocks every rebase | +| 2 | refusal — bad arguments, the object is held, an unknown alias, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them); and, Amendment 23 S3, every writer with nothing written, where its checkout 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, or a fast-forward git refused; the withdrawn `--sweep`; and the publish act over a stack origin has moved past (S4) | +| 3 | **pushed, not confirmed: ``** (Amendment 23 S4). 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 success. The commit stays on HEAD, 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 | | 5 | an edit moved more than one line and was refused | -| 6 | `git add` / `commit` / `push` failed | +| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): origin moved under six attempts (Amendment 23 S4's bound), a replay's `reset --keep` refused over an uncommitted change, or a push (a write's, or the publish act's) refused with origin unmoved, which no replay can mend. Every later write from that checkout refuses until the lane publishes or archives it | | 7 | another act got there first and this one wrote nothing: `claim`'s `CLAIM-LOST` (another lane's claim landed first), and `set-lane-state` / `set-lane-tree`'s lifecycle fence (openRepoTools#91, below) | | 9 | `CLAIM-LOST` — issue #30's own dead-lane verdict could not be reconfirmed before a `--force` takeover's push landed: the source lane resumed, a live session now backs it up, or that could not be read at all (`claim` only). Never 7 — that code is a RIVAL's claim, and this is the same lane the takeover was granted over | | 10 | `lane-state`: the lane's lifecycle snapshot is there and could not be read, or is of this schema and not a whole record, so nothing can be read out of it as a snapshot (openRepoTools#91 and #115, below) — never the 8 that means it has none. It was 9 until #61 spent 9 on `claim`. `lane-trees`: the lane's `trees` directory is there and could not be listed (#157 item 4e) — never the 8 that means it recorded no tree | diff --git a/lanes-edit.sh b/lanes-edit.sh index 0d8b9e09..4aac0872 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -441,18 +441,19 @@ # 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 +# 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 @@ -462,7 +463,15 @@ # 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`; and the publish act over a stack origin has moved +# past (S4, Open question 2). 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 @@ -503,22 +512,25 @@ # 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, 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 # lane's object log could not be renamed to the row's own spelling -# 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`): origin moved under six +# attempts (the bound, ratified choices 4 and 5), a replay's +# `reset --keep` refused over an uncommitted change, or a push (a +# write's, or the publish act's) refused with origin unmoved, which +# no replay can mend. 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 @@ -1125,11 +1137,29 @@ 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. 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="" +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:-}" +fi +unset LANES__REPLAY_PID LANES__ATTEMPT LANES__CLAIM_FROM LANES__CLAIM_DEAD # 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 @@ -1217,20 +1247,23 @@ 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=() +# 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 # 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: @@ -1431,11 +1464,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 @@ -1460,7 +1493,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. @@ -1513,7 +1546,7 @@ cleanup() { [ -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" # 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 @@ -1581,12 +1614,13 @@ 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 @@ -1603,6 +1637,12 @@ acquire_lock() { lock_try 60 && return 0 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 @@ -1900,7 +1940,7 @@ replace_line() { # workstation it is a lane stamped twice in a second being told its write was # refused, when the register already says precisely what the caller asked for. # - # THE CALLERS ARE SAFE WITH IT: nothing is written, so `commit_push` finds + # THE CALLERS ARE SAFE WITH IT: nothing is written, so `commit_publish` finds # nothing staged and says so on its own line, and every one of the four # callers (`set-row-state`, `append-session-id`, `replace-in-row` and # `migrate-state-cells`) wanted the file to SAY something rather than to @@ -2071,17 +2111,6 @@ 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 @@ -2184,6 +2213,7 @@ refuse_dirty_checkout() { # guard it replaces did — that mode publishes nothing to guard. CS_NO_ORIGIN_BRANCH=0 CS_CARRY_DIFF="" +CS_CARRY_ANCHORED=0 cs_refuse() { # […] csr_what="$1"; csr_why="$2"; shift 2 if [ "$#" -gt 0 ]; then @@ -2328,6 +2358,7 @@ CS_EOF 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 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 origin/$LANES_BRANCH" fi @@ -2346,6 +2377,15 @@ CS_EOF 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" + # 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 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="" @@ -2369,7 +2409,14 @@ CS_EOF fi fi if [ -n "$CS_CARRY_DIFF" ]; then - if ! git -C "$cs_c" apply --whitespace=nowarn -- "$CS_CARRY_DIFF" 2>"$cs_tmp/apply.err"; then + cs_applied=0 + git -C "$cs_c" apply --whitespace=nowarn -- "$CS_CARRY_DIFF" 2>"$cs_tmp/apply.err" && cs_applied=1 + if [ "$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, so the edit was applied anchored by those lines alone" + fi + if [ "$cs_applied" = 0 ]; then cs_refuse "$cs_what" "the hand edit of $LANES_PATH no longer applies to origin's version of it, so it is KEPT in $CS_CARRY_DIFF 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 \"\"" @@ -2390,11 +2437,12 @@ CS_EOF # 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 @@ -2448,23 +2496,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 @@ -2562,7 +2599,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. # @@ -2594,7 +2632,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=$? @@ -2636,32 +2674,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. @@ -2709,7 +2751,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; } @@ -2734,47 +2776,20 @@ 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 - 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 + if [ "$CP_PUBLISH_AHEAD" = 1 ]; then + cp_publish_act + return $? 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 + 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 @@ -2792,132 +2807,173 @@ 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="$(git -C "$LANES_REPO" rev-parse HEAD 2>/dev/null)" + 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. + if git -C "$LANES_REPO" merge-base --is-ancestor "refs/remotes/origin/$LANES_BRANCH" "$cp_sha" 2>/dev/null; then + 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 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" +} + +# 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 +} +cp_pushed() { # + LANES_INDEX_NUDGE=1 + note "pushed origin/$LANES_BRANCH: $1, confirmed by a read-back (attempt $CP_ATTEMPT)" +} +# S4.3 — A PUSH IT CANNOT CONFIRM IS NEVER SUCCESS. The commit stays on HEAD; +# the next write's S3 finds it by content either way: published, it reads as +# nothing to do; not published, as an unpushed commit, refused until the lane +# publishes or archives it. +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 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)." + die "pushed, not confirmed: $1 — $3; the commit is kept on HEAD, and this is never reported as success (Amendment 23 S4)" 3 +} +# S4.4 AND S4.5 — A REFUSED PUSH REPLAYS THE ONE EDIT ON A FRESH FAST-FORWARD, +# SIX ATTEMPTS IN ALL. `reset --keep` drops exactly this write's commit — 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() { # + 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" + 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 origin/$LANES_BRANCH\` run." + 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). + if [ "$CP_REPLAY_KEEPS_EDIT" = 1 ]; then + if ! git -C "$LANES_REPO" reset -q --mixed "$1^" 2>"$cr_err" >/dev/null; then + note "git said: $(head -n 4 -- "$cr_err" 2>/dev/null | tr '\n' ' ')" + 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 - LANES_INDEX_NUDGE=1 - note "pushed (created origin/$LANES_BRANCH)" + 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)." + elif ! git -C "$LANES_REPO" reset -q --keep "refs/remotes/origin/$LANES_BRANCH" 2>"$cr_err"; then + note "git said: $(head -n 4 -- "$cr_err" 2>/dev/null | tr '\n' ' ')" + 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 reset --keep\` refused over an uncommitted change), so it is kept on HEAD: $1 $2" 6 + 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[@]}" + 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 - 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" - 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 + 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 + 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 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." + note "RECOVERY (S6.3), in this order:" + note " git -C $LANES_REPO push origin HEAD:refs/heads/archive/-stranded-$(date -u +%Y%m%dT%H%MZ) # the archive, on origin" + note " git -C $LANES_REPO reset --keep 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." + 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 } # ====================================================== AMENDMENT 14 ======= @@ -2931,7 +2987,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 @@ -3451,7 +3507,7 @@ ensure_log() { # `log_files_named_ci` reads the WORKING TREE, while every state reader here # deliberately answers out of `origin/` (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). @@ -3485,7 +3541,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`. @@ -3968,7 +4024,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 @@ -4127,30 +4183,6 @@ retired_identity_hits() { # ; stdout: matching retired spellings }' | LC_ALL=C sort -u } -add_row_retirement_rescan() { - ar_rebased_hits="$(retired_identity_hits "$ADD_ROW_LANE")" || { - note "the archive could not be read after rebase; refusing to push add-row for $ADD_ROW_LANE" - return 1 - } - if [ -n "$ar_rebased_hits" ]; then - # The rejected add-row is already a local commit on top of the peer's - # archive. Leaving it 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. - ar_lines="$(command grep -nFx -- "$ADD_ROW_TEXT" "$LANES_FILE" 2>/dev/null)" || { - note "the rejected row could not be found exactly in $LANES_FILE; the local add-row commit must be repaired before any later push" - return 1 - } - case "$ar_lines" in *$'\n'*) note "more than one exact copy of the rejected row exists; refusing automatic compensation"; return 1 ;; esac - ar_line="${ar_lines%%:*}" - delete_lines "$ar_line" - git -C "$LANES_REPO" -c "$CP_EXACT" add -- "$LANES_PATH" || return 1 - git -C "$LANES_REPO" -c "$CP_EXACT" commit -q -m "LANES($ADD_ROW_LANE@$WS): cancel add-row rejected by peer retirement" -- "$LANES_PATH" || return 1 - note "lane $ADD_ROW_LANE was retired by a peer before this add-row could push (archive: $(printf '%s' "$ar_rebased_hits" | tr '\n' ' ')); the rejected row was removed in a compensating LOCAL commit. Neither commit was pushed." - return 2 - fi -} - # Each lane's LAST line on , as # utc lane verb uuid ws object ref payload text file line # @@ -6370,7 +6402,7 @@ write_event() { 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" - commit_push "$we_msg" "${we_paths[@]}" + commit_publish "$we_msg" "${we_paths[@]}" we_rc=$? state_events_flush release_lock @@ -11656,7 +11688,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 @@ -11696,9 +11728,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 @@ -11779,6 +11812,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() { @@ -12032,8 +12098,8 @@ 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 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 '')" @@ -12321,7 +12387,7 @@ EOF # # 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 @@ -12359,7 +12425,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 @@ -12811,7 +12877,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 @@ -13070,7 +13136,7 @@ $(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 @@ -13100,7 +13166,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 @@ -13308,7 +13374,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" @@ -15926,7 +15992,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" - commit_push "$msg" + commit_publish "$msg" ;; replace-in-row) @@ -15959,7 +16025,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}" - commit_push "$msg" + commit_publish "$msg" ;; # AMENDMENT 8, R-A8-4 — THE UUID APPEND IS CELL-SCOPED. @@ -16014,7 +16080,7 @@ 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}" - commit_push "$msg" + commit_publish "$msg" ;; append-line) @@ -16075,7 +16141,7 @@ Nothing was written." 2 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}" - commit_push "$msg" + commit_publish "$msg" ;; add-row) @@ -16105,7 +16171,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 @@ -16162,13 +16228,13 @@ Nothing was written." 2 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" - 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" + # A PEER'S RETIREMENT OR ROW LANDING FIRST IS MET ON THE REPLAY (Amendment + # 23 S4). A refused push drops this add-row's commit and runs the verb again + # from the top on origin's new tip, so 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. + commit_publish "$msg" + exit $? ;; commit) @@ -16184,9 +16250,10 @@ Nothing was written." 2 # 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_push` publishes the queue (S4, "the publish act"). + # 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 @@ -16210,9 +16277,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 @@ -16266,7 +16333,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 @@ -16354,9 +16421,10 @@ 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. acquire_lock # Refuse a spelling-only rename before probing both roots: on a @@ -16776,7 +16844,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 @@ -16824,7 +16892,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" @@ -16950,7 +17018,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. @@ -16958,15 +17026,15 @@ 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 @@ -17086,8 +17154,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 @@ -17184,19 +17252,11 @@ 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 # AMENDMENT 23 S3 NARROWS THE GUARD THAT WAS HERE (ratified choice 8): # an unstaged change to another tracked file refused every claim, because @@ -17215,6 +17275,20 @@ 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") + 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 @@ -17340,14 +17414,14 @@ EOF # 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") # 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" @@ -17357,39 +17431,8 @@ EOF write_event "$lane" CLAIMED "$obj" "" "" "$ng" "$utc" "$uuid" fi wrc=$? - CP_AFTER_REBASE="" + CP_REPLAY_ENV=() 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. @@ -19650,7 +19693,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/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py index 0b674059..f5444f18 100644 --- a/tests/test_a23_guard_refusals.py +++ b/tests/test_a23_guard_refusals.py @@ -204,7 +204,7 @@ def _no_fetch(box: Box, c: Path): 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", - f"LANES_LANE={LANE} ", " commit \"\""] + " commit \"\""] ROWS = { @@ -245,6 +245,9 @@ def test_every_S3_row_refuses_every_writer_with_nothing_written(box: Box, verb: 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" @@ -300,16 +303,15 @@ def test_the_1312Z_shape_is_pushed(box: Box): 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")) - behind = box.git("-C", str(c), "rev-list", "--count", "HEAD..refs/remotes/origin/main").strip() 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"], behind + "HEAD...refs/remotes/origin/main").split() == ["0", "10"] stamp = run_verb(box, "set-row-state") assert stamp.returncode == 0, stamp.stderr note = run_verb(box, "log") assert note.returncode == 0, note.stderr register = box.origin_register() - assert "LIVE · the guarded write |" in 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" diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py new file mode 100644 index 00000000..23a41604 --- /dev/null +++ b/tests/test_a23_publish_replay.py @@ -0,0 +1,631 @@ +# 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 (`reset --keep`, 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 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 = "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 "confirmed by a read-back (attempt 2)" in err, err + 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 and 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 and "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 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 and 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 test_replay_refused_by_a_change_in_the_way_keeps_the_commit_exit_6(box: Box): + """`reset --keep` never runs 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 "confirmed by a read-back" not in result.stderr + 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 f"pushed origin/main: {sha}, confirmed by a read-back" 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) + + +# ------------------------------------------------------------ `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 and "LIVE · a hand edit" in register + assert box.head(c) == box.origin_head() + assert box.git("-C", str(c), "status", "--porcelain").strip() == "" + saved = list((c / ".git" / "lanes-edit").glob("hand-edit-*.diff")) + assert len(saved) == 1 and "a hand edit" in saved[0].read_text(encoding="utf-8") + _settled(box, c) + + +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 and 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) + + +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 and "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) + + +# ------------------------------------------------------------ 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 f"pushed origin/main: {shas[-1]}, confirmed by a read-back" in result.stderr + assert box.unpublished(c) == [] + else: + assert result.returncode == 2, result.stderr + assert words in result.stderr and "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 and f"git -C {c} reset --keep origin/main" in err + assert "push" not in box.net_ops() + assert box.origin_head() == tip and box.head(c) == shas[-1] + _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) diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 7fa5f7aa..bf3d111a 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,81 @@ 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 and ' 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" in rerun.stderr and "confirmed by a read-back" in 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 and " 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 +311,70 @@ 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 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 and 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 +398,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 +468,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 +536,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 +555,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 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 +598,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 +638,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_repo_hygiene.py b/tests/test_repo_hygiene.py index 1addfd59..9480e194 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -2739,51 +2739,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 @@ -2799,10 +2800,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)" @@ -2845,67 +2846,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()) @@ -2942,17 +2886,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( @@ -2960,8 +2910,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: @@ -2992,7 +2941,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.""" @@ -3000,7 +2949,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 @@ -3014,19 +2964,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 @@ -3035,20 +2984,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 @@ -3064,43 +3013,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 @@ -3122,45 +3050,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 } ''' @@ -3169,147 +3088,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.", + ("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 peer's-file die says nothing was pushed", "code", - "Nothing was lost — your commit is local.", - "Nothing was pushed — your commit is local.", - "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", - "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", + ("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 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: ``**"), ] @@ -3333,10 +3239,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), ( @@ -3348,10 +3255,10 @@ 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})") From e3b6810c25639c9e569e233cc08aa8fe0188507e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 17:35:09 +0000 Subject: [PATCH 03/59] Amendment 23 S3 for the sweeps and `rename-lane`: each takes S3 under its lock and asks every path it commits again before its first byte, the dirty-checkout guard and the pre-existing capture are deleted, and no message tells a person to `pull --rebase` (#198, slice 1b stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `migrate-state-cells`, `retire-rows`, `archive-rows` and `rename-lane` run `checkout_sync` under their lock, before their behind test (which can then fire only for a dry run, or a race), and `cs_own_pending` on every path they commit right before the first byte: the register, the logs, the archive, the alias table and the handoff. A pending change there refuses (2) with nothing written, where the capture used to commit it first (ratified choice 9). Deleted, with what they guarded: `tracked_dirty` and `refuse_dirty_checkout` (the whole-checkout guard a `pull --rebase` needed; choice 8 narrows it to the fast-forward and the write's own paths), `handle_preexisting` and `capture_register_edit` (the `LANES(pre-existing@…)` capture), and `PRE_DIRTY_LANES`. `identify_changed_lanes` and `warn_dirty` stay: `commit` uses them. Every "Pull first — `git -C … pull --rebase`" refusal is reworded: a write already fetched and fast-forwarded under its lock, so it says origin moved since and to re-run, or names the restore where a file is missing here. `add-row`'s published-row refusal names the verbs that fast-forward. The `--sweep` header and the usage lines say the flag is withdrawn. The manual's Layout note, "How to write" (its five steps and a recovery that never resets with `--hard`) and the `claim` section say what S3 and S4 do. `tests/test_repo_hygiene.py` gains `test_no_lane_writer_pulls_with_rebase` (`lanes-edit.sh` in this slice; `lane-start` and `lane-handoff` join in 2b) and `test_no_text_promises_the_carry` (the script's messages and the manual; red at `874e020` on three lines of the script and one row of the manual), each with its own red and green cases. Existing tests brought to S3, one fixture or expectation each: - `test_a23_lock.py`: the vanished-lock case removed the lock on a pull no writer runs, and the heartbeat case had gone vacuous the same way; both now act at the push and the fetch. - `test_deferred_97_findings_157.py` 4d: an unpushed fixture commit is refused by S3 first; the case asserts that, then publishes it and asserts the row-shape refusal it was written for. - `test_register_scan_hardening_138.py`: the capture case is now the refusal; item 8 publishes its stamp first; item 1 pins the published read's failure without the local edit S3 now refuses. - `test_rename_publication_atomic_158.py`: its fixture no longer exports `LANES_NO_FETCH=1` for writers (ratified choice 2). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 178 +++++----- lanes-edit.sh | 348 ++++++-------------- tests/test_a23_lock.py | 10 +- tests/test_deferred_97_findings_157.py | 10 + tests/test_register_scan_hardening_138.py | 35 +- tests/test_rename_publication_atomic_158.py | 3 +- tests/test_repo_hygiene.py | 98 ++++++ 7 files changed, 320 insertions(+), 362 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 29e51089..3769b06f 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -87,29 +87,35 @@ cross-references. Nothing here is ever released, pinned, swept or bumped. **The register no longer owns its whole checkout.** The workspace checkout also holds `handoffs/` and `workspaces/`, which other lanes write. That is why -the writer now (a) uses a pathspec on every `git add` and `git commit`, so a -peer's half-written handoff can never ride along in a register commit, and (b) -SKIPS `pull --rebase` when the checkout has unstaged changes to other tracked -files — `pull --rebase` refuses outright on any unstaged tracked change — and -pushes straight out instead, warning and naming the files. If the push is also -rejected (the remote moved), it stops with exit 3, leaves your edit as a local -commit, and prints the recovery: the owner of that file commits it, then you -re-run `lanes-edit.sh commit`. If `lanes/LANES.md` is clean, that re-run makes -no commit of its own and pushes the local commits origin does not have yet, -through the same loop, guard and retries (#194). It reads that queue on -`refs/heads/main`, the branch the push sends, with HEAD required to be on it, -and pushes exactly the commit it read. It refuses with 2 and pushes nothing -unless every queued commit is a non-merge commit whose every change is an added -or modified regular file under `lanes/` or `handoffs/`, and never bytecode, a -cache or an environment. That refuses a merge, a deletion (a lane rename's -included), a type change, a symlink, a gitlink and any other path. It is a -path filter, not an ownership check: another lane's register write passes it, -so read `git cherry -v origin/main HEAD` before you publish a shared queue. -Only `commit` publishes that queue: any other write with nothing to stage -leaves it local, and with nothing ahead `commit` is the no-op it always was. -When `pull --rebase` finds every queued commit on origin already, by content -under other shas, it drops them all. The publish then says so and exits 0 -without a push. +the writer uses a pathspec on every `git add` and `git commit`, so a peer's +half-written handoff can never ride along in a register commit. Since +Amendment 23 S3–S4 (#198) no writer pulls. Every write first fetches, under its +checkout's lock, and refuses (2, nothing written) a checkout it cannot bring to +origin's tip: a fetch that failed, an operation in progress, HEAD off `main`, +staged entries that are not its own, a commit origin lacks by content, a +diverged branch, a pending change to a path it commits (a hand edit of the +register refuses every writer but `commit`), or a fast-forward git refuses. +Otherwise it fast-forwards, makes its edit on origin's tip, commits it by +pathspec, pushes and reads origin back. A peer's unstaged change to another +file does not refuse it, unless the fast-forward would overwrite that file. + +A local commit origin lacks — one kept on HEAD after six lost push races (exit +6), or one a hand made — is published by **the publish act**: `lanes-edit.sh +commit` with nothing staged and `lanes/LANES.md` clean. It writes nothing new, +lists the commits origin lacks (`git cherry -v origin/main HEAD`) before it +pushes, and pushes them only as a fast-forward of origin. Where origin has +moved past them it refuses (2), naming the recovery: archive them on origin, +then `git -C reset --keep origin/main`, then make again each edit +still wanted. It reads that queue on `refs/heads/main`, the branch the push +sends, with HEAD required to be on it, and pushes exactly the commit it read. +It refuses with 2 and pushes nothing unless every queued commit is a non-merge +commit whose every change is an added or modified regular file under `lanes/` +or `handoffs/`, and never bytecode, a cache or an environment. That refuses a +merge, a deletion (a lane rename's included), a type change, a symlink, a +gitlink and any other path. It is a path filter, not an ownership check: +another lane's register write passes it, so read the listing before you +publish a shared queue. Only `commit` publishes that queue: every other write +refuses over it, and with nothing ahead `commit` is the no-op it always was. ## THE ONE HAZARD — `sed -i` DESTROYS THE SYMLINK @@ -183,57 +189,41 @@ anything, so a name typed in another case edits the row that is there instead of creating a second one; two rows differing only by case are a refusal naming both (Amendment 15). Then it does the same five things: -1. takes a `mkdir` lock in `.lanes/` (so two helper runs never interleave); -2. checks whether LANES.md is already dirty before making its own edit. If it - is, that content is someone else's — never this invocation's — so it - **warns** (`git diff --stat` plus the first 200 chars of every changed - line, and the affected lane(s) identified by the first backtick token on - each changed line, or `append` for a line with no row of its own) and, by - **default (`--no-sweep`)**, commits that pre-existing content FIRST, as its - own `LANES(pre-existing@): capture an uncommitted registry - edit (row )` commit, so a peer's in-flight edit is never combined - with yours. Pass `--sweep` to fall back to the old combined-commit - behaviour instead — never silently: it appends " + sweeps uncommitted - edit to row \" to the commit subject it is about to create. Either - way the write still goes through — this never fails the call; +1. takes a `mkdir` lock in its checkout's `lanes/` (so two helper runs never + interleave); +2. **S3, before the edit:** fetches `origin main` and brings the checkout to + origin's tip, or refuses with 2 and nothing written, naming the checkout and + the recovery (the Layout note above lists the rows). `LANES_NO_FETCH=1` is a + fetch that failed, for a writer. A pending hand edit of `lanes/LANES.md` + refuses every writer but `commit`, which carries it across the + fast-forward and commits it as its own act. A row edit carries even where + origin changed the row beside it, and anything that no longer applies is + kept in a file under the checkout's `.git/lanes-edit/` and named. The old + `LANES(pre-existing@…)` capture and `--sweep` are withdrawn (2); 3. re-reads the file, edits **exactly one line** in place, and refuses if `git diff --numstat` says more than one line moved; 4. commits as `LANES(@): ` — workstation from - `hostname -s`; -5. `git pull --rebase origin main` then `git push`, retrying a lost push race - up to six times — or, if a peer has uncommitted changes to other files in - this checkout, a warning naming them and a straight push (see the Layout - note above). - -**On a rebase conflict it aborts**, leaves the worktree clean and not -mid-rebase, prints the conflicting lines and prints the recovery commands. Your -edit survives as a local commit — but an aborted pull is not proof it never -reached origin: this checkout is shared with every lane on the workstation, so -the very next write out of it can pull this dangling commit onto its own and -push both together (#32). LOOK first: `git -C fetch -origin main` — if THAT fails, STOP and retry it; a stale or unreachable origin -proves nothing either way, and is not permission to reset and redo. Only once -the fetch succeeds do you check by CONTENT whether your own commit is already -there, never by sha: the very peer write this recovery exists for carries a -dangling commit by rebasing it onto a newer base, which changes its hash, so -`merge-base --is-ancestor` alone can still say "not there" once the change is -actually published. `git -C cherry origin/main | grep -c '^+'` compares by patch instead and still finds it; 0 means -it is already there, in which case STOP. Only once fetch succeeded AND this -prints something other than 0 do you check that `git -C rev-parse HEAD` is still the commit the recovery named; if the checkout -has moved since the abort, STOP. Then list WHOSE the commits are, with -`git -C cherry -v origin/main HEAD`: one `+` line per -local commit origin lacks, which is exactly what a reset drops, and in a shared -checkout not every one is yours. If any `+` line is not your own write, STOP and -take it to the lane its subject names. A `LANES(concurrent@…)` or -`LANES(pre-existing@…)` capture names no lane, so it is not yours either. Never -reset, stash or overwrite a peer's work: `reset --hard` drops EVERY `+` commit -in the checkout, theirs with yours, and origin holds no copy of any of them -(#194). Only when every `+` line is yours do you read your edit back with -`git -C diff origin/main..HEAD -- lanes/LANES.md`, -then `git -C reset --hard origin/main` and redo it on -top of the peer's version. + `hostname -s` — by pathspec; +5. **S4, after the edit:** pushes `main` with no pull and reads origin back. + Confirmed is 0 and `pushed`. A push that went, or timed out, and cannot be + confirmed is **3, `pushed, not confirmed: `**, never success: the commit + stays on HEAD, and the next write's S3 finds it by content (published, + nothing to do; not, every write refuses until the publish act sends it). A + push refused because origin moved drops this write's commit + (`reset --keep`) and runs the same write again on origin's new tip, so its + own checks re-run on the new content — `claim`'s rescan included, which + ends a lost race as `CLAIM-LOST` (7). After six attempts, or a refusal with + origin unmoved, the commit is kept on HEAD and named (6). + +**No write rebases, and no recovery resets with `--hard`.** If a write refuses +over commits origin lacks, list them first — `git -C cherry -v +origin/main HEAD` — because in a shared checkout not every one is yours. If any +`+` line is not your own write, STOP and take it to the lane its subject names. +Never reset, stash or overwrite a peer's work. Publish your own with the +publish act; where origin has moved past them, archive them +(`git -C push origin HEAD:refs/heads/archive/-stranded-`), +then `git -C reset --keep origin/main`, which keeps every +uncommitted change, and make each edit again on the register as it now is. The script spells no path of its own: it finds the checkout root with `git rev-parse --show-toplevel` from its own directory and derives the pathspec @@ -886,40 +876,24 @@ workstation. So: 2. If another lane holds it, print `who` and **exit 2**: Rule 1 says the lane stops and reports, it does not author a successor. Nothing is written. 3. Run Rule 1's three reads and print them; warn if this is a crossing. -4. Append the `CLAIMED` line, commit, push. Inside that push, after every - `pull --rebase`, rescan: if another lane's `CLAIMED` or `TAKEOVER` is - already on this history, **theirs landed first** — append - `CLAIM-LOST → lane:`, push both lines, and **exit 7**. The claim is - *abandoned*, not queued; Rule 7's queueing is for substrates, which this - amendment does not touch. +4. Append the `CLAIMED` line, commit, push (Amendment 23 S3–S4: fetched and + fast-forwarded first, no pull). A push refused because origin moved drops + the `CLAIMED` commit and runs the claim again on origin's new tip, and the + re-run rescans first: if another lane's `CLAIMED` or `TAKEOVER` is there, + **theirs landed first** — it appends `CLAIM-LOST → lane:`, pushes + that line alone, and **exits 7**. The claim is *abandoned*, not queued; + Rule 7's queueing is for substrates, which this amendment does not touch. 5. Post the GitHub `CLAIMED —` comment **after** the push, citing the commit that carries the line. The line is never rewritten to cite the comment. -A claim needs a checkout it can rebase. Amendment 5(d) *skips* `pull --rebase` -when a peer has left uncommitted changes to other files in this shared -checkout — the right default for a row edit, and unacceptable for a race — so -`claim` **refuses (exit 2) and names the files** instead of racing unprotected. - -The guard is computed over the **whole checkout** — every tracked file that -differs from `HEAD`, staged or not — minus exactly the pathspecs this write -will commit, and `log` and `release` are guarded the same way. Scoped to -anything narrower it was a hole: measured against (the lane's log + `LANES.md`) -while a `CLAIMED` writes the log alone, a dirty `lanes/LANES.md` — the one file -this checkout routinely has dirty — passed the guard, took the skip-the-pull -branch, and raced with the rescan disabled, leaving two lanes holding one -object. The exception is plural on purpose: a `LANDING` or `LANDED` writes two -files, so a dirty register is *its* business and is captured as its own -attributed commit, exactly as before. - -`claim`, `release` and `log` first take that same pre-existing capture for the -register — a peer's uncommitted `lanes/LANES.md` lands as its own attributed -`LANES(pre-existing@): …` commit, with the warning every register -write already prints — and only then re-test the checkout, because this clone -carries a half-written register line most of the day and refusing every claim -on it would push lanes to stop using the tool. Any **other** modified tracked -file outside the write's own pathspecs is still a refusal (exit 2), and it is -taken before the lock and before the capture, so a write that refuses never -leaves a commit of somebody else's line behind. +A claim used to need a checkout it could rebase, and refused one with any +other tracked file dirty. Amendment 23 S3 narrows that (ratified choice 8): no +writer pulls, so a peer's unstaged change elsewhere refuses a `claim`, `log` or +`release` only where the fast-forward would overwrite it. A change to a path +the write commits, or a pending hand edit of `lanes/LANES.md`, still refuses +(2), with nothing written: the `LANES(pre-existing@)` capture +these three took for the register is withdrawn (ratified choice 9), and +`commit` is the one act that commits such an edit. **Reads 2 and 3 name the object, not its digits** (Amendment 8). They searched for the bare slug as a *substring*, and a number is a substring of almost diff --git a/lanes-edit.sh b/lanes-edit.sh index 4aac0872..36557504 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) @@ -574,27 +574,20 @@ # shape (#115): nothing can be read out of it as a snapshot, so it is # the same answer, and the message says which of the two it is. # -# --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 @@ -2036,10 +2029,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 @@ -2111,61 +2104,6 @@ remote_has_branch() { return 0 } -# 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 - 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 -} - # ==================================================== AMENDMENT 23 S3 ======= # # BEFORE THE EDIT: FETCH, CHECK, FAST-FORWARD — OR REFUSE WITH NOTHING WRITTEN. @@ -2198,7 +2136,10 @@ refuse_dirty_checkout() { # 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`). +# 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 @@ -3155,67 +3096,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. @@ -3529,7 +3409,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 @@ -12101,14 +11981,20 @@ migrate_state_cells() { # <1 = the act, 0 = the dry run> # 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 @@ -12122,7 +12008,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 @@ -12234,10 +12120,9 @@ EOF 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. @@ -12375,15 +12260,14 @@ 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 @@ -12392,8 +12276,8 @@ EOF # 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 @@ -12898,21 +12782,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 @@ -13085,9 +12969,8 @@ $(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.) @@ -13101,7 +12984,7 @@ $(session_ids_local_of_lane "$rr_l" 2>/dev/null || :)" # tree for the same answer. rr_pub="$rr_pubp" if [ "${rr_pub##*/}" != "$(log_path_for "$rr_l")" ] && [ "$rr_pub" != "$(log_path_for "$rr_l")" ] && [ "$rr_ln2" = 0 ]; then - die "lane $rr_l's object log is published as $rr_pub and this checkout does not have it: writing one here now would leave TWO files for one lane (Amendment 15). 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 and this checkout does not have it: writing one here now would leave TWO files for one lane (Amendment 15), 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 fi # THE LINE AND THE CELL, BOTH BUILT AND BOTH CHECKED BEFORE ANYTHING IS @@ -13139,9 +13022,9 @@ $(session_ids_local_of_lane "$rr_l" 2>/dev/null || :)" # `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 @@ -13207,24 +13090,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 @@ -13351,10 +13235,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 @@ -13576,7 +13460,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. @@ -16087,9 +15971,9 @@ Nothing was written." 2 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. @@ -16221,7 +16105,7 @@ 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 @@ -16424,8 +16308,10 @@ Nothing was written." 2 # 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. + # 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" # Refuse a spelling-only rename before probing both roots: on a # case-insensitive filesystem they are the same intent, whose stored lane @@ -16460,7 +16346,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 @@ -16593,8 +16479,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` @@ -16607,7 +16493,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 @@ -16630,8 +16516,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 @@ -16654,12 +16540,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 @@ -16827,10 +16712,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 @@ -16854,29 +16739,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 @@ -17031,7 +16900,6 @@ EOF # 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_publish "$rl_msg" "${rl_paths[@]}" rl_rc=$? # `commit_publish` RETURNED, so either it committed or it had nothing staged; diff --git a/tests/test_a23_lock.py b/tests/test_a23_lock.py index 4e5f8fef..d73c1bb1 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}' "$@" diff --git a/tests/test_deferred_97_findings_157.py b/tests/test_deferred_97_findings_157.py index 0f63a1d3..be93c304 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_register_scan_hardening_138.py b/tests/test_register_scan_hardening_138.py index ab181d01..24fdc304 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() diff --git a/tests/test_rename_publication_atomic_158.py b/tests/test_rename_publication_atomic_158.py index afc56f6d..8ca7c7ad 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 9480e194..9c69eea9 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -3264,6 +3264,104 @@ def test_the_exit_3_rule_is_green_on_a_rewording_that_keeps_the_meaning( 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), + (" # 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-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.""" + found = [] + for n, line in enumerate(text.splitlines(), 1): + if script and line.lstrip().startswith("#"): + continue + for rule in _CARRY_PROMISES: + m = rule.search(line) + 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), +], ids=["exit-3", "the-rename", "the-recovery", "the-negation", "the-refusal"]) +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 From 7da3d6178ee7af1891689c715dc15341fb6096c8 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 21:43:42 +0000 Subject: [PATCH 04/59] R4-1: under the opt-in, `log`, `commit`, `request-handoff` and `append-line` for a lane with no row and no log clone nothing, and under `LANES_NO_FETCH=1` the gate gives S3 step 1's refusal before any clone (#198, slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane openRepoTools-3's re-check of #282 at `eead86d` (R4-1, a SHOULD for slice 1b): under `LANES_ROUTE_WRITES=1`, `log NOTED lane:repoX-99 …`, a typo for repoX-9, cloned `-lanes/repoX-99/` for a lane that does not exist and left it. That is 0.35-0.5 GB of working tree on Eagle. The same happened on `commit`, `request-handoff` and `append-line`, and on `set-row-state` under `LANES_NO_FETCH=1`. - `lane_row_gate` now also gates those four verbs. A row OR the lane's object log on origin passes a lane, because an archived lane has no row and keeps its log (Amendment 16(e)). The four row verbs still need a row. A lane with neither, after the one fetch of W and the re-read, refuses (2), and nothing is cloned. `add-row`, a new lane's first act, is not gated, and neither are `claim`, `release` or the sweeps. - The fail-open is kept for a fetch that failed and for a read that could not be made, except under `LANES_NO_FETCH=1`. Every gated verb is a writer, and slice 1b's S3 step 1 refuses a writer whose fetch did not run (ratified choice 2), so failing open there only cloned a checkout the write could never use. On a miss the gate now gives that refusal itself. - `lrg_known` reads `lane_log_exists`' 2 (two logs whose names differ only by case) as no answer, and every other non-zero status as no log. `git cat-file -e` gives 128 for a path that is not in the tree. Tests, in `tests/test_a23_write_routing.py`: - `test_a_write_for_a_lane_with_no_row_and_no_log_clones_nothing`, for each of the four verbs; - `test_a_lane_with_a_published_log_and_no_row_is_no_reason_to_refuse`; - the `[no-fetch]` case of `test_a_row_W_has_not_fetched_is_no_reason_to_refuse` now also asserts that nothing was cloned. Before the change: 5 failed and 2 passed. All four typo cases cloned, `[no-fetch]` cloned, and the archived case and `[fetched]` passed, as designed. After: 8 passed, with N5's own case. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 58 ++++++++++++++++++++++++-------- tests/test_a23_write_routing.py | 59 +++++++++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 790b34e3..085d00cf 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -15500,24 +15500,56 @@ 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. `claim`, `release` and the sweeps are not gated, and +# `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. +# * EXCEPT `LANES_NO_FETCH=1` (slice 1b, R4-1): every gated verb is a writer, +# and for a writer a fetch that did not run is a fetch that failed, so S3 +# step 1 refuses it in the lane's checkout (ratified choice 2). Failing +# open there only cloned a checkout the write could never use, so on a +# miss the gate gives S3 step 1's refusal itself, and clones nothing. +# `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) 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 + if [ "$LOG_SYNC_FETCH" != yes ]; then + [ "${LANES_NO_FETCH:-0}" = 1 ] && [ "$NO_GIT" != 1 ] || return 0 + lrg_what="no row"; [ "$lrg_log" = 0 ] || lrg_what="no row and no object log" + die "lane $1 is not in W's origin/$LANES_BRANCH as it stands ($lrg_what there), and LANES_NO_FETCH=1 is set, so W was not fetched to make sure. For a writer a fetch that did not run is a fetch that failed (Amendment 23 S3 step 1, ratified choice 2), and \`$cmd\` would refuse in the lane's checkout for it, so that checkout, $2/, was not cloned. Nothing was written. Run this write without LANES_NO_FETCH (reads may keep it)." 2 + fi + 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 } diff --git a/tests/test_a23_write_routing.py b/tests/test_a23_write_routing.py index 2bc79abb..5aafc090 100644 --- a/tests/test_a23_write_routing.py +++ b/tests/test_a23_write_routing.py @@ -520,12 +520,12 @@ 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 gate refuses nothing and the write goes on. Since slice 1b the write - then refuses (2) in its own checkout at Amendment 23 S3 step 1 — for a - writer, a fetch that did not run is a fetch that failed (ratified choice - 2) — so that refusal, and never the gate's, is what it meets, and nothing - is published.""" + 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) @@ -537,7 +537,54 @@ def test_a_row_W_has_not_fetched_is_no_reason_to_refuse(box: Box, no_fetch: bool assert result.returncode == 2, (result.stdout, result.stderr) assert "LANES_NO_FETCH=1 is set" in result.stderr and "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}), +} + + +@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.""" + 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") From 3905009ef839edaf51f5acadf9135ed37b362c33 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 22:35:15 +0000 Subject: [PATCH 05/59] B23: a confirmed push still says `pushed origin/ (attempt N)`, byte for byte as on main, and now prints only after the read-back finds the commit on origin, so lane-start, the triad goldens and every caller that reads that line stay right (#198, slice 1b) Stage 2 printed `pushed origin/main: , confirmed by a read-back (attempt N)`. That moved T018's goldens, which hold c45a452's line `lanes-edit: pushed origin/main (attempt 1)`, in `test_triad_lane_tooling.py` and `test_triad_lane_tooling_handoff.py`. The words are main's again. What changed is when the line prints: since S4 it prints only after `cp_confirm` finds the commit on origin, so "pushed" now means confirmed. A push that cannot be confirmed is never this line: it exits 3 as `pushed, not confirmed: `. The replay module and #214's module now assert the line itself, and that attempt 1's line never appears once the push replays. Before (at e3b6810): triad 1 failed, 4 passed, 22 skipped; triad handoff 1 failed, 21 skipped. After: replay 28 passed; #214's module 25 passed; triad 5 passed, 22 skipped; triad handoff 1 passed, 21 skipped; hygiene 247 passed. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 7 ++++++- tests/test_a23_publish_replay.py | 9 +++++---- tests/test_commit_push_published_edit_194.py | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 085d00cf..4e54f715 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2822,9 +2822,14 @@ cp_why_unconfirmed() { # *) 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: $1, confirmed by a read-back (attempt $CP_ATTEMPT)" + note "pushed origin/$LANES_BRANCH (attempt $CP_ATTEMPT)" } # S4.3 — A PUSH IT CANNOT CONFIRM IS NEVER SUCCESS. The commit stays on HEAD; # the next write's S3 finds it by content either way: published, it reads as diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 23a41604..6e004dcd 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -165,7 +165,8 @@ def test_refused_push_replays_on_a_fresh_fast_forward(box: Box): assert result.returncode == 0, result.stderr err = result.stderr assert "push refused (attempt 1 of 6)" in err, err - assert "confirmed by a read-back (attempt 2)" 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" @@ -370,7 +371,7 @@ def test_unconfirmed_push_exits_3(box: Box): 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 "confirmed by a read-back" not in result.stderr + 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, @@ -392,7 +393,7 @@ def test_timed_out_push_is_read_back(box: Box, landed: bool): sha = box.head(c) if landed: assert result.returncode == 0, result.stderr - assert f"pushed origin/main: {sha}, confirmed by a read-back" in result.stderr + assert "pushed origin/main (attempt 1)" in result.stderr assert box.origin_head() == sha else: assert result.returncode == 3, result.stderr @@ -538,7 +539,7 @@ def test_publish_act_is_exempt_from_unpushed_only(box: Box, row): if row is None: assert result.returncode == 0, result.stderr assert box.origin_head() == shas[-1] - assert f"pushed origin/main: {shas[-1]}, confirmed by a read-back" in result.stderr + assert "pushed origin/main (attempt 1)" in result.stderr assert box.unpublished(c) == [] else: assert result.returncode == 2, result.stderr diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index bf3d111a..793e0ba9 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -250,7 +250,7 @@ def test_the_printed_recovery_publishes_the_edit_it_stranded(box): rerun = box.helper("commit", SUBJECT) assert rerun.returncode == 0, rerun.stderr - assert "pushed origin/main" in rerun.stderr and "confirmed by a read-back" in 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" not in box.origin_file(PEER_HANDOFF), "never carried" From a41762b33ab1465e60c28c412609cf2a8104d3b5 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 12:37:54 +0000 Subject: [PATCH 06/59] B31: supervised_restart's fixture no longer exports `LANES_NO_FETCH=1`, because slice 1b's S3 refuses every writer whose fetch did not run, and the module's sandbox origin is a local bare repository (#198, slice 1b) Every case in `tests/test_supervised_restart_regressions.py` builds its lane through real `add-row`, `log` and `claim` writes. With the fixture's `LANES_NO_FETCH=1`, Amendment 23 S3 step 1 refused each of them with exit 2 (ratified choice 2), and 153 of the 173 cases failed. 152 of those failures quote that refusal. The line is replaced by a comment. The sandbox's origin is a bare repository under `tmp_path`, so the fetch makes no network call. Runs: - Locally at 3905009 plus this file: 167 passed and 0 failed before a checkpoint signal stopped the run, with 6 cases not reached. - CI run 39585851301, at a9190aa, which is 3905009 plus this file and the unfinished stage 4 shell suite: the module passed, with 0 failures. - This commit sits on the 1a merge 2e2eee4, which brings R4-2's `lanes_home_check` guard. The next CI run covers that merge with it. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- tests/test_supervised_restart_regressions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_supervised_restart_regressions.py b/tests/test_supervised_restart_regressions.py index d24c3216..0f4cc842 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 From 6ecfc52cd82fd413a9fecc81c7ef8c34776bd379 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 12:47:52 +0000 Subject: [PATCH 07/59] MUST 3 (rv1b's pre-review): `lane-worktrees sweep --yes` writes its NOTED line with a fetch, because slice 1b's S3 refuses a writer whose fetch did not run, and the sweep stopped after its first removal (#198, slice 1b) `Helper.call` gave every call but the first fetching read `LANES_NO_FETCH=1`, and `note_line` called `log NOTED` with `fetch=False`. Under Amendment 23 S3 step 1 (ratified choice 2), a writer whose fetch did not run is refused with exit 2. So the sweep made its first removal, its register line was refused, and it stopped with "NOT WRITTEN". `call` takes `write=True`, which is never given `LANES_NO_FETCH=1`, and `note_line` passes it. No other call in `lane-worktrees` writes: rv1b checked every shipped caller, and the other `LANES_NO_FETCH=1` calls are reads or sidecar verbs that make no commit. Test: `tests/test_lane_worktrees.py::test_the_real_helper_inventory_and_register_line`. - Before: failed in CI run 39585851301 in both jobs ("was NOT written (exit 2)"), and locally on 3905009's copy (1 failed). - After: passed. The whole of `test_lane_worktrees.py` passed (118), and so did `test_lane_worktrees_report.py` (22). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lane-worktrees | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lane-worktrees b/lane-worktrees index 11833b6e..999ae968 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 [^,]+, " From c45424795c22640d62e8bfa8fb7b3d8d66c0af3d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 13:30:17 +0000 Subject: [PATCH 08/59] MUST 4 (rv1b's pre-review): a refused push whose commit is no longer HEAD is not replayed, so a commit another writer made on top of it in the same checkout is never dropped by the replay's reset; both are kept on HEAD and named, exit 6 (#198, slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. While routing is off, that checkout is W, every lane's checkout. One of those commits could land on top of this write's commit while its push was in flight. The replay's `reset --keep refs/remotes/origin/main` then dropped that commit and its file, and the write exited 0 with nothing said. The carry form's `reset --mixed ^` would have done the same. `cp_replay` now first asks for HEAD. If HEAD is not this write's commit, it names both commits and `git -C … log --oneline ^..HEAD`, and exits 6 before either reset. Every later write from that checkout then refuses under S3's unpushed row until the commits are published (the publish act) or archived. Exit row 6 names the new cause, in the code's table and in the manual. Test: `tests/test_a23_publish_replay.py::test_a_commit_made_on_top_before_the_replay_is_kept_exit_6`, which follows rv1b's probe. - Before: it failed on 3905009's helper, exiting 0 where it expects 6. - After: it passes, and the replay module has 29 passed in this commit's own tree. - On the worktree together with MUSTs 1-2 (the next commit), these all passed: replay 32, guard 122, #214's module 25, a23_write_routing 46, deferred_97 9, hygiene 247 and a23_lock 24. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 2 +- lanes-edit.sh | 21 +++++++++++++++++--- tests/test_a23_publish_replay.py | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 3769b06f..06a532fe 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1200,7 +1200,7 @@ else. | 3 | **pushed, not confirmed: ``** (Amendment 23 S4). 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 success. The commit stays on HEAD, 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 | | 5 | an edit moved more than one line and was refused | -| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): origin moved under six attempts (Amendment 23 S4's bound), a replay's `reset --keep` refused over an uncommitted change, or a push (a write's, or the publish act's) refused with origin unmoved, which no replay can mend. Every later write from that checkout refuses until the lane publishes or archives it | +| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): origin moved under six attempts (Amendment 23 S4's bound), a replay's `reset --keep` refused over an uncommitted change, a commit another writer made on top of this one in the checkout before the replay (the reset would drop it too), or a push (a write's, or the publish act's) refused with origin unmoved, which no replay can mend. Every later write from that checkout refuses until the lane publishes or archives it | | 7 | another act got there first and this one wrote nothing: `claim`'s `CLAIM-LOST` (another lane's claim landed first), and `set-lane-state` / `set-lane-tree`'s lifecycle fence (openRepoTools#91, below) | | 9 | `CLAIM-LOST` — issue #30's own dead-lane verdict could not be reconfirmed before a `--force` takeover's push landed: the source lane resumed, a live session now backs it up, or that could not be read at all (`claim` only). Never 7 — that code is a RIVAL's claim, and this is the same lane the takeover was granted over | | 10 | `lane-state`: the lane's lifecycle snapshot is there and could not be read, or is of this schema and not a whole record, so nothing can be read out of it as a snapshot (openRepoTools#91 and #115, below) — never the 8 that means it has none. It was 9 until #61 spent 9 on `claim`. `lane-trees`: the lane's `trees` directory is there and could not be listed (#157 item 4e) — never the 8 that means it recorded no tree | diff --git a/lanes-edit.sh b/lanes-edit.sh index 7c9241f9..3fdc48e2 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -527,9 +527,11 @@ # 6 git add or commit failed — or, AMENDMENT 23 S4, the commit is KEPT ON # HEAD AND NAMED (sha, subject, `git show`): origin moved under six # attempts (the bound, ratified choices 4 and 5), a replay's -# `reset --keep` refused over an uncommitted change, or a push (a -# write's, or the publish act's) refused with origin unmoved, which -# no replay can mend. Every later write from that +# `reset --keep` refused over an uncommitted change, a commit another +# writer made on top of this one in the checkout before the replay (the +# reset would drop it too), or a push (a write's, or the publish act's) +# refused with origin unmoved, which no replay can mend. 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. @@ -2852,6 +2854,19 @@ cp_not_confirmed() { # # 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. + # Either reset below would then drop that commit too, and say nothing. So + # HEAD must still be this write's commit, or both are kept on HEAD and named. + cr_head="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" + if [ "$cr_head" != "$1" ]; then + note "HEAD is ${cr_head:-unreadable}, not this write's commit $1: another commit was made on top of it in this checkout while its push was in flight, and dropping this write's commit to replay it would drop that one too." + note "look at both: git -C $LANES_REPO log --oneline $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)." + die "the push was refused because origin/$LANES_BRANCH moved, 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" 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" diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 6e004dcd..bf670d56 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -630,3 +630,36 @@ def test_no_sweep_is_still_accepted(box: Box): assert result.returncode == 0, result.stderr assert _set("LIVE · not swept") in box.origin_register() _settled(box, c) + +# ------------------------------------- the replay's reset (rv1b's pre-review) + +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 "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) From 10169f3124b6c5280540d1e3e095caf33534a021 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 13:30:44 +0000 Subject: [PATCH 09/59] MUSTs 1 and 2 (rv1b's pre-review): `claim` and `add-row` run again from the top on origin's new tip when S3's fetch finds it moved under their own checks, so a rival's claim, a peer's retirement or a peer's row that lands before S3's fetch is never written over (#198, slice 1b) `claim`'s holder checks and `add-row`'s retirement and duplicate checks run before the lock, 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, could land between that read and S3's fetch under the lock. S3 then fast-forwarded onto it, and the write was pushed on top at attempt 1. The results were two lanes holding one object, a retired name reissued, or a second row. S4's replay never ran, because the push was not refused. The base's rescan after every `pull --rebase` went with the pull. - `CS_RECHECK`, set at load and never read from the environment, is set by `claim` (beside its replay env, before `write_event`) and by `add-row` (before its lock). - `checkout_sync` reads `refs/remotes/origin/` before and after its fetch. Where that ref moved, nothing is written yet. `cs_recheck` then runs the verb again from the top on the new tip, with the lock held, as S4's replay does with `exec` and `LANES__ATTEMPT`: `claim`'s rescan comes first (CLAIM-LOST 7, or 9), and `add-row`'s checks refuse (2). This stays inside S4's six attempts, and at the sixth it refuses (2) with nothing written. - No other verb opts in. The row verbs, `rename-lane` and the sweeps read what they check after S3. An opt-in for every verb would add a fetch and an `exec` to every write made from a checkout behind origin. Tests, in `tests/test_a23_publish_replay.py`, after rv1b's probes: - `test_a_rival_claim_landing_before_S3s_fetch_loses_exit_7`; - `test_an_add_row_whose_name_lands_before_S3s_fetch_refuses[retires-it|adds-it]`. Before: all three failed on 3905009's helper, each exiting 0. After: all pass. On this tree these passed: replay 32, guard 122, #214's module 25, a23_write_routing 46, deferred_97 9, hygiene 247 and a23_lock 24. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 56 +++++++++++++++++++++--- tests/test_a23_publish_replay.py | 74 +++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 3fdc48e2..42b89486 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1261,6 +1261,11 @@ CP_REPLAY_ENV=() # 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 # 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: @@ -2228,6 +2233,35 @@ cs_flush() { 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 S3's fetch +# moved origin's tip, 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[@]}" + 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 +} checkout_sync() { # […] cs_what="$1"; cs_lane="${2:-}"; cs_mode="$3"; shift 3 CS_NO_ORIGIN_BRANCH=0; CS_CARRY_DIFF="" @@ -2245,6 +2279,7 @@ checkout_sync() { # [ 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)" \ @@ -2263,6 +2298,10 @@ checkout_sync() { # [ "re-run this write once origin answers: git -C $cs_c fetch origin $LANES_BRANCH" fi fi + if [ "$CS_RECHECK" = 1 ] && [ "$CS_NO_ORIGIN_BRANCH" != 1 ]; then + cs_now="$(git -C "$cs_c" rev-parse -q --verify "$cs_trk" 2>/dev/null || :)" + [ "$cs_now" = "$cs_was" ] || cs_recheck "$cs_what" "$cs_was" "$cs_now" + fi # ---- 2. an operation in progress, or unmerged entries cs_op="" @@ -16235,14 +16274,16 @@ Nothing was written." 2 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 + # 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" - # A PEER'S RETIREMENT OR ROW LANDING FIRST IS MET ON THE REPLAY (Amendment - # 23 S4). A refused push drops this add-row's commit and runs the verb again - # from the top on origin's new tip, so 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. commit_publish "$msg" exit $? ;; @@ -17416,6 +17457,10 @@ EOF CLAIM_OBJ="$obj"; CLAIM_LANE="$lane"; CLAIM_SKIP="$takeover_from"; CLAIM_SKIP_DEAD="$takeover_dead" CLAIM_ABANDON_REVIVED="" CP_REPLAY_ENV=("LANES__CLAIM_FROM=$takeover_from" "LANES__CLAIM_DEAD=$takeover_dead") + # 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" @@ -17426,6 +17471,7 @@ EOF fi wrc=$? CP_REPLAY_ENV=() + CS_RECHECK=0 CLAIM_SKIP_DEAD="" [ "$wrc" = 0 ] || exit "$wrc" diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index bf670d56..5826f2c2 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -631,7 +631,79 @@ def test_no_sweep_is_still_accepted(box: Box): assert _set("LIVE · not swept") in box.origin_register() _settled(box, c) -# ------------------------------------- the replay's reset (rv1b's pre-review) + +# ------------------------------------- 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 From 92fbe46da4a877d1174156979443b9d8c99f93cc Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 13:57:06 +0000 Subject: [PATCH 10/59] SHOULD 1 and NIT 1 (rv1b's pre-review): the manual no longer describes the withdrawn capture and dirty-checkout guard as current, the carry rule reads the manual a paragraph at a time and knows the capture's words, and the no-pull rule is no longer stopped by a `#` (#198, slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three passages of `docs/README-lanes.md` still told a reader that a peer's uncommitted register edit is taken into the next write as its own `LANES(pre-existing@…)` commit, and that `refuse_dirty_checkout` refuses the next run. Slice 1b deleted both: Amendment 23 S3 refuses a write over a pending change to a path it commits (ratified choice 9), and only `commit` carries a hand edit. The three passages (the sweeps' "Nothing is half-PUBLISHED", the hand-edit paragraph of "How to write", and the install's symlink note) now say so. `test_no_text_promises_the_carry` missed them because it read one line at a time and looked only for "carry". It now reads a document a paragraph at a time, its lines joined, and gains four patterns for the capture and the deleted guard ("captured by the next", "as its own LANES(pre-existing@", "by the dirty-checkout guard", `refuse_dirty_checkout`). A script is still read a line at a time, comments aside. `_PULL_REBASE` stopped at the first `#`, so `die "pull (#198) --rebase"` escaped the no-pull rule; the `#` is gone from its class (a comment line is still skipped whole). Tests (`tests/test_repo_hygiene.py`): - New fixture cases: `the-carry-rule…[a-wrapped-capture]`, `[a-wrapped-own-commit]`, `[the-guard]`, `[the-function]` (red), `[the-withdrawal]` (green); `the-no-pull-rule…[a-hash-inside-it]` (red). - Before: the five red cases FAIL under the old reader and regex (the new fixtures with HEAD's three definitions, 5 failed, 12 passed), and the widened rule finds 4 hits in HEAD's manual (:3160 twice, :4468, :4554) where the old one found none. - After: the whole module passed (253). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 45 +++++++++++++++++++----------------- tests/test_repo_hygiene.py | 47 +++++++++++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 06a532fe..e4e029d2 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -3159,13 +3159,14 @@ they are. **Nothing is half-PUBLISHED, because the commit is the only thing that publishes.** Every refusal above is made in the scan, before a byte is written. -That includes the commit of a peer's uncommitted register edit, which the act -makes as its own `LANES(pre-existing@…)` commit: it is made only after the -scan, so a refusal leaves HEAD where it was. A failure in the writes themselves — a log whose append could not be proved, a -row whose rewrite touched more than one line — leaves this checkout DIRTY and -the register unpublished: that is what `git status` then shows, `git checkout -- -lanes` undoes it whole, and the next run is REFUSED by the dirty-checkout guard -rather than made twice. `migrate-state-cells` has the same shape for the same +A peer's uncommitted edit to a path the act commits is one of them: Amendment 23 +S3 refuses the act over it (2) and leaves it as it was, for its author to commit +by its own act, `commit`; no write captures it any more. A failure in the writes +themselves — a log whose append could not be proved, a row whose rewrite touched +more than one line — leaves this checkout DIRTY and the register unpublished: +that is what `git status` then shows, `git checkout -- lanes` undoes it whole, +and the next run is REFUSED by S3, which refuses a write over a pending change to +a path it commits, rather than made twice. `migrate-state-cells` has the same shape for the same reason. ### The archive — a second act on a second word @@ -4465,17 +4466,19 @@ leave it sitting uncommitted while you do something else. Prefer first place: they commit in the same call, so there is no window where the edit sits uncommitted at all. -An uncommitted hand edit left in the worktree is captured by the *next* lane's -helper run, not by yours — and that capture now comes with a warning and, by -default, a **separate** `LANES(pre-existing@): …` commit (see -above), rather than silently landing inside that other lane's own commit with -no trace of whose it was (which is what happened in 0d84d34 and a1f2438: -content intact, authorship lost). The separation only helps if you commit -promptly — a hand edit is never truly "yours" in the history until it is -committed under your own lane's message. `lanes-edit.sh commit` itself cannot -split a hand edit that mixes your row with someone else's: it warns and -annotates its subject the same way when the dirty diff it is about to wrap -touches a row other than the one named by `$LANES_LANE`. +An uncommitted hand edit left in the worktree is no longer taken into anyone's +commit (Amendment 23 S3, ratified choice 9). A pending edit to the register +refuses every write but `commit` (2, nothing written), and a pending edit to a +path a write commits refuses that write. The capture that used to sweep it into +the next lane's own commit, or into a separate `LANES(pre-existing@…)` commit, +is withdrawn: the edit may be a peer's in this shared checkout, and only its +author knows what it is (0d84d34 and a1f2438 were captures: content intact, +authorship lost). So commit promptly — a hand edit is never truly "yours" in the +history until it is committed under your own lane's message, and until then it +stops the other writes from this checkout. `lanes-edit.sh commit` itself cannot +split a hand edit that mixes your row with someone else's: it warns, and +annotates its subject, when the dirty diff it is about to wrap touches a row +other than the one named by `$LANES_LANE`. ## What is forbidden @@ -4557,9 +4560,9 @@ every target in the bin directory and dies naming every one that is not a regula what it is, and the one `rm` that clears them. `cp` FOLLOWS A SYMLINK, so an install over these would leave the two commands UNINSTALLED — the targets stay links — and would write the post-move bytes into `opensoft/brett-wip`'s working -tree, which is the repository every lane writes: `lanes-edit.sh`'s -`refuse_dirty_checkout` then refuses `log`, `claim` and `release` on that -workstation until somebody runs `git checkout -- lanes/`. +tree, which is the repository every lane writes: `lanes-edit.sh`'s Amendment +23 S3 then refuses each write whose paths, or whose fast-forward, those bytes are +in the way of, on that workstation, until somebody runs `git checkout -- lanes/`. It is not tidiness. workBenches' `setup-estate-commands.sh` refuses the WHOLE estate install when any target "already exists as a symlink", and `setup.sh` diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 9c69eea9..32a35e10 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -3272,7 +3272,7 @@ def test_the_exit_3_rule_is_green_on_a_rewording_that_keeps_the_meaning( _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)") +_PULL_REBASE = re.compile(r"\bpull\b[^\n]*?(?:--rebase\b|\s-r\b)") def _pulls_with_rebase(src: str) -> list[int]: @@ -3299,10 +3299,11 @@ def test_no_lane_writer_pulls_with_rebase(name): (' 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-comment", +], 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 @@ -3319,18 +3320,40 @@ def test_the_no_pull_rule_reads_what_it_should(line, red): r"[^.|\n]{0,60}?\bcarr(?:y|ies)\b", re.I), re.compile(r"\bmay already carry\b", re.I), re.compile(r"\bown push publishes\b", re.I), + # AND THE CAPTURE, ITS TWIN (rv1b's pre-review of slice 1b, SHOULD 1): a + # pending edit taken into someone else's commit, or the deleted guard that + # stood in front of it, described as what happens now. The withdrawal's + # own sentences name the capture too, so these match what only a promise + # says. + re.compile(r"\bcaptured by the\W+next\b", re.I), + re.compile(r"\bas its own\W+LANES\(pre-existing@", re.I), + re.compile(r"\bby the dirty-checkout guard\b", re.I), + re.compile(r"\brefuse_dirty_checkout\b"), ) def _carry_promises(text: str, *, script: bool) -> list[str]: """`: ` for each promise of the carry in `text`; a script's - comments are history and are not read.""" + 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 = [] - for n, line in enumerate(text.splitlines(), 1): - if script and line.lstrip().startswith("#"): - continue + 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(line) + m = rule.search(unit) if m: found.append(f"{n}: {m.group(0)}") return found @@ -3357,7 +3380,15 @@ def test_no_text_promises_the_carry(): ("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), -], ids=["exit-3", "the-rename", "the-recovery", "the-negation", "the-refusal"]) + ("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 From 3c6b240dca88ab87f8cbd183b7888123453c670d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 14:50:14 +0000 Subject: [PATCH 11/59] rv1b's second report: one tracked and one untracked log is named as the stray copy a case-only rename leaves, not two logs for 15(d)'s hand merge; the 1312Z case asserts the stamp's first push; and slice 1b's composite asserts are split (#198, slice 1b) NIT 2. Amendment 23 S3 fast-forwards before `ensure_log` runs, so the state a failed case-only rename leaves (the canonical file here, untracked, and the old-cased one still published) reaches the two-logs check with both files in the working tree. Its recovery was 15(d)'s hand merge, and a commit of the stray copy would publish a second file for one lane. Where exactly one of the two is tracked, the refusal now says which is which: it names the tracked log and the untracked copy, the `diff` that compares them, the `rm -- ` and the re-run, after which the write renames the tracked log once, inside its own commit. Every other two-logs state keeps 15(d)'s words. `ensure_log`'s published-path guard is unchanged; it still answers where the published log is not in the working tree. SHOULD 2. `test_the_1312Z_shape_is_pushed` asserts the stamp pushed on its first attempt and met no refused push, as two asserts. Without S3's fast-forward the stamp edits the stale tree, its push is refused, and the replay catches up, so every other assert held and m1b's S10 survived. SHOULD 3. The 16 composite `assert A and B` in slice 1b's own lines are split, one condition per assert (Sonar S9073): the replay module 10, #214's module 3, the guard module 2, the routing module 1. The routing module's other 10 are slice 1a's lines and are left to it. Tests: - New: `tests/test_a23_guard_refusals.py::test_a_stray_copy_from_a_rename_that_never_landed_is_named_as_one`. Before (HEAD's helper): 1 failed, with the "Merge them by hand" refusal rv1b quoted. After: passed. - S10 against the strengthened 1312Z case: KILLED, at the first-attempt assert (the stamp's stderr shows `push refused (attempt 1 of 6)`). - The set on this tree, one pytest process per module: the guard module 123 passed, the replay module 32, the routing module 46, #214's module 25, hygiene 253, guard_launch_mode 13. The shell suite's repoDrop cases are re-pinned to the new refusal in the stage 4 commit, with the rest of the suite's rewrite. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 23 +++++++++ tests/test_a23_guard_refusals.py | 54 ++++++++++++++++++-- tests/test_a23_publish_replay.py | 30 +++++++---- tests/test_a23_write_routing.py | 3 +- tests/test_commit_push_published_edit_194.py | 9 ++-- 5 files changed, 102 insertions(+), 17 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 42b89486..1d63a658 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -3456,6 +3456,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 <"' 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" @@ -272,7 +273,8 @@ def test_a_clean_checkout_with_nothing_ahead_is_still_a_no_op(box): assert f"nothing staged for {REGISTER} — no commit made" in result.stderr assert "pushed" not in result.stderr, result.stderr calls = box.net_calls() - assert len(calls) == 1 and " fetch " in f" {calls[0]} ", 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) @@ -373,7 +375,8 @@ def test_a_no_op_write_never_publishes_what_the_checkout_queued(box, exported): 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 "1 unpublished commit(s)" in result.stderr and f"+ {queued}" in result.stderr + assert "1 unpublished commit(s)" in result.stderr + assert f"+ {queued}" in result.stderr assert "Nothing was written." in result.stderr From 6471fd178f5386f20e2d40da1d39d2acfaf0a764 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 15:16:36 +0000 Subject: [PATCH 12/59] MUST-N and SHOULD B (rv1b-c's re-check at 10169f3): `claim` and `add-row` compare origin's tip with the tip their checks read, so a move another process made in a shared checkout while they waited on the lock re-runs them too, and the six-attempt bound has a test (#198, slice 1b) MUST-N. B35 asked only whether S3's own fetch moved `refs/remotes/origin/`. In a checkout several writers share (W, every lane's while routing is off), another process moves that same ref while `claim` or `add-row` 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, so no re-run happened: two lanes' claims of one object both exited 0, and a peer's retirement fetched in by another write was answered by reissuing the name. The base settled both, through its rescan after `pull --rebase`, so this was a regression of 10169f3. Now `log_sync` records the tip it leaves (`cs_seen_mark`, `CS_SEEN`), once per process and never from the environment, and `checkout_sync` re-runs a `CS_RECHECK` verb when origin's tip after S3's fetch differs from that tip, or from the one just before the fetch (the old test, kept). The first tip is kept, not the latest: a later `log_sync` could only make a move seen twice, never missed. The re-run is B35's own: the replay's `exec` with the lock held, so `claim`'s rescan comes first (CLAIM-LOST 7, or 9) and `add-row`'s checks refuse (2). SHOULD B. `cs_recheck`'s six-attempt bound had no test, and rv1b-c's MB1 (`if false` for it) survived the replay module. Tests (`tests/test_a23_publish_replay.py`): - `test_a_rival_claim_a_reader_fetched_into_the_shared_checkout_loses_exit_7` and `test_an_add_row_whose_name_a_reader_fetched_into_the_shared_checkout_refuses`, modelled on rv1b-c's probe and made exact: a third write holds the checkout's lock at its push; the write under test is seen waiting in `mutex_try` (a `sleep` it started, read from /proc), so its checks have run; the rival claim lands from another clone, or the peer pushes the retirement; a real reader (`who`) in the shared checkout moves its `origin/main`; then the lock is let go. On 92fbe46's helper both fail (`assert 0 == 7`, `assert 0 == 2`: each pushed at attempt 1); here both pass. - `test_a_claim_that_meets_a_move_at_every_S3_fetch_stops_at_six_exit_2`: exit 2, "on each of six attempts", exactly six moves, the five attempt notes, nothing published. It passes on 92fbe46's helper, as the bound worked there, and kills MB1 (1 failed: the counter wraps to "attempt 1 of 6" and the claim goes on). - The set on this tree, one pytest process per module: the replay module 35 passed, the guard module 123, the routing module 46, #214's module 25, deferred_97 9, hygiene 253, a23_lock 24. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 40 +++++++- tests/test_a23_publish_replay.py | 171 +++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 5 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 1d63a658..d23a79a6 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1266,6 +1266,11 @@ CP_REPLAY_KEEPS_EDIT=0 # 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 # 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: @@ -2241,15 +2246,16 @@ cs_flush() { # 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 S3's fetch -# moved origin's tip, nothing is written yet, and the verb runs again from the +# 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() { # +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" @@ -2299,8 +2305,14 @@ checkout_sync() { # [ fi fi 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_now="$(git -C "$cs_c" rev-parse -q --verify "$cs_trk" 2>/dev/null || :)" - [ "$cs_now" = "$cs_was" ] || cs_recheck "$cs_what" "$cs_was" "$cs_now" + 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 @@ -6422,6 +6434,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 @@ -6435,11 +6463,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 @@ -6448,6 +6477,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 } diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 3aeb9005..e3def007 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -25,8 +25,11 @@ from __future__ import annotations import os +import re import shlex import shutil +import subprocess +import time from pathlib import Path import pytest @@ -745,3 +748,171 @@ def test_a_commit_made_on_top_before_the_replay_is_kept_exit_6(box: Box): 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) + + +# ------------------------ 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) + + +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) From d9ff4e6e6ff5520d8de09162ec67183888eab705 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 15:29:23 +0000 Subject: [PATCH 13/59] SHOULD A and NITs 1-2 (rv1b-c's re-check at 10169f3): a commit another writer rewrote is named as rewritten, not as kept on HEAD; a commit that lands during the replay's reset is put back; and exit rows 2 and 6 name the re-run's bound (#198, slice 1b) SHOULD A. Until slices 2b and 3, lane-start's stamp and lane-handoff 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 may push its copy. `cp_replay` then said "it and the commit on top of it are kept on HEAD", but the sha is on no branch there, and the change may already be on origin. Where HEAD is readable and does not contain the commit, `cp_rewritten` now says another writer rewrote it, and points to `git cherry -v origin/main HEAD`. That read tells whether its copy reached origin. The exit is still 6, and nothing is replayed. NIT 2. The replay's HEAD check and its reset are two git calls, and an unlocked commit landing between them was dropped with this write's commit, with exit 0. After either reset, `cp_reset_check` reads ORIG_HEAD, which `reset` writes with or without a reflog. Where that is not this write's commit, the reset is undone, but only while HEAD is still where the reset put it, so nothing that landed after it is touched. Both commits are kept on HEAD and named (6). Where HEAD has moved on, nothing is touched, and the reflog is named. NIT 1. Exit row 2, in the code's table and the manual, gains `claim` or `add-row` meeting origin moved under its own checks on each of six attempts, before any edit. Row 6 says the bound is a sixth refused push, and that S3 finding the sixth move before any edit is 2. It also names SHOULD A's rewrite and NIT 2's put-back. Tests (`tests/test_a23_publish_replay.py`): - `test_a_commit_another_writer_rewrote_is_named_as_rewritten_exit_6` (rv1b-c's probe): exit 6, "rewrote this write's commit", no "kept on HEAD", the named sha not on HEAD, its copy on origin, nothing unpublished. On 92fbe46's helper it fails at the words. - `test_a_commit_landing_during_the_replays_reset_is_put_back_exit_6`: a `git` first on PATH commits on top just before the replay's `reset --keep`, which makes the window exact. Exit 6, the reset undone, the other writer's commit on HEAD over this write's, both unpublished. On 92fbe46's helper it fails with `assert 0 == 6`: the other commit was dropped, and the write pushed at attempt 2. - The set on this tree, one pytest process per module: the replay module 37 passed, #214's module 25, hygiene 253, the guard module 123, a23_lock 24, the routing module 46, guard_launch_mode 13. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 4 +- lanes-edit.sh | 68 ++++++++++++++++++++++---- tests/test_a23_publish_replay.py | 83 ++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 11 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index e4e029d2..70a6e9c5 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1196,11 +1196,11 @@ else. |---|---| | 0 | done — and for a write, **confirmed**: the commit was read back on origin before the writer said `pushed` (Amendment 23 S4) | | 1 | environment — no register, no writer | -| 2 | refusal — bad arguments, the object is held, an unknown alias, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them); and, Amendment 23 S3, every writer with nothing written, where its checkout 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, or a fast-forward git refused; the withdrawn `--sweep`; and the publish act over a stack origin has moved past (S4) | +| 2 | refusal — bad arguments, the object is held, an unknown alias, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them); and, Amendment 23 S3, every writer with nothing written, where its checkout 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, or a fast-forward git refused; the withdrawn `--sweep`; the publish act over a stack origin has moved past (S4); and `claim` or `add-row` meeting origin moved under its own checks on each of six attempts, before any edit (S3, S4) | | 3 | **pushed, not confirmed: ``** (Amendment 23 S4). 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 success. The commit stays on HEAD, 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 | | 5 | an edit moved more than one line and was refused | -| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): origin moved under six attempts (Amendment 23 S4's bound), a replay's `reset --keep` refused over an uncommitted change, a commit another writer made on top of this one in the checkout before the replay (the reset would drop it too), or a push (a write's, or the publish act's) refused with origin unmoved, which no replay can mend. Every later write from that checkout refuses until the lane publishes or archives it | +| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): a sixth push refused because origin moved (Amendment 23 S4's bound; where S3 finds the sixth move before any edit, nothing is committed and it is 2), a replay's `reset --keep` refused over an uncommitted change, a commit another writer made on top of this one in the checkout before the replay or during its reset (the reset would drop it too, and one it did drop is put back), 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 (`git cherry -v origin/main HEAD`). Every later write from that checkout refuses until the lane publishes or archives it | | 7 | another act got there first and this one wrote nothing: `claim`'s `CLAIM-LOST` (another lane's claim landed first), and `set-lane-state` / `set-lane-tree`'s lifecycle fence (openRepoTools#91, below) | | 9 | `CLAIM-LOST` — issue #30's own dead-lane verdict could not be reconfirmed before a `--force` takeover's push landed: the source lane resumed, a live session now backs it up, or that could not be read at all (`claim` only). Never 7 — that code is a RIVAL's claim, and this is the same lane the takeover was granted over | | 10 | `lane-state`: the lane's lifecycle snapshot is there and could not be read, or is of this schema and not a whole record, so nothing can be read out of it as a snapshot (openRepoTools#91 and #115, below) — never the 8 that means it has none. It was 9 until #61 spent 9 on `claim`. `lane-trees`: the lane's `trees` directory is there and could not be listed (#157 item 4e) — never the 8 that means it recorded no tree | diff --git a/lanes-edit.sh b/lanes-edit.sh index d23a79a6..2d9fd2c3 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -470,8 +470,10 @@ # 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`; and the publish act over a stack origin has moved -# past (S4, Open question 2). EIGHT MEANINGS ON ONE NUMBER, and they +# 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 @@ -525,13 +527,17 @@ # 5 an edit moved more than one line and was refused — or, Amendment 15, a # lane's object log could not be renamed to the row's own spelling # 6 git add or commit failed — or, AMENDMENT 23 S4, the commit is KEPT ON -# HEAD AND NAMED (sha, subject, `git show`): origin moved under six -# attempts (the bound, ratified choices 4 and 5), a replay's -# `reset --keep` refused over an uncommitted change, a commit another -# writer made on top of this one in the checkout before the replay (the -# reset would drop it too), or a push (a write's, or the publish act's) -# refused with origin unmoved, which no replay can mend. Every later -# write from that +# 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 `reset --keep` refused over an uncommitted change, a commit +# another writer made on top of this one in the checkout before the +# replay or during its reset (the reset would drop it too, and one it +# did drop is put back), 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. 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. @@ -2895,6 +2901,42 @@ cp_not_confirmed() { # 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)." 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() { # + 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 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 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 "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)" 6 +} +# NOTHING ELSE WENT WITH THE REPLAY'S RESET (rv1b-c's NIT 2 on slice 1b). The +# HEAD check in `cp_replay` and its reset are two git calls, and an unlocked +# commit landing between them would be dropped with this write's. `reset` +# records the HEAD it moved from in ORIG_HEAD (with or without a reflog). +# Where that is not this write's commit, the reset is undone, but only while +# HEAD is still where the reset put it, so nothing that landed after it is +# touched; and both commits are kept and named. +cp_reset_check() { # + crk_from="$(git -C "$LANES_REPO" rev-parse -q --verify ORIG_HEAD 2>/dev/null || :)" + [ "$crk_from" = "$1" ] && return 0 + crk_now="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" + note "the replay's reset moved HEAD from ${crk_from:-an unreadable commit}, not from this write's commit $1: another commit was made on top of it in this checkout between the replay's HEAD check and its reset." + if [ -n "$crk_from" ] && [ "$crk_now" = "$3" ] && + git -C "$LANES_REPO" reset -q "--$4" "$crk_from" >/dev/null 2>&1; then + note "the reset is undone: HEAD is $crk_from again, and this write's commit and the one on top of it are both kept on it." + note "look at both: git -C $LANES_REPO log --oneline $1^..HEAD" + else + note "the reset could not be undone safely (HEAD is ${crk_now:-unreadable} now, and something may have landed after it): find the commits it moved past in git -C $LANES_REPO reflog -5, and restore them with git -C $LANES_REPO reset --keep once nothing else writes here." + fi + 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)." + die "the push was refused because origin/$LANES_BRANCH moved, and a commit landed on top of this write's in this checkout while the replay dropped it, so it is not replayed: $1 $2" 6 +} # S4.4 AND S4.5 — A REFUSED PUSH REPLAYS THE ONE EDIT ON A FRESH FAST-FORWARD, # SIX ATTEMPTS IN ALL. `reset --keep` drops exactly this write's commit — S3 # allowed no other unpublished one — and keeps every other change in the tree @@ -2913,6 +2955,10 @@ cp_replay() { # # HEAD must still be this write's commit, or both are kept on HEAD and named. cr_head="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" if [ "$cr_head" != "$1" ]; then + # A COMMIT ON TOP IS ONE CASE; A REWRITE IS ANOTHER (rv1b-c's SHOULD A). + if [ -n "$cr_head" ] && ! git -C "$LANES_REPO" merge-base --is-ancestor "$1" HEAD 2>/dev/null; then + cp_rewritten "$1" "$2" "$cr_head" + fi note "HEAD is ${cr_head:-unreadable}, not this write's commit $1: another commit was made on top of it in this checkout while its push was in flight, and dropping this write's commit to replay it would drop that one too." note "look at both: git -C $LANES_REPO log --oneline $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)." @@ -2930,17 +2976,21 @@ cp_replay() { # # 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" 2>/dev/null || :)" if [ "$CP_REPLAY_KEEPS_EDIT" = 1 ]; then if ! git -C "$LANES_REPO" reset -q --mixed "$1^" 2>"$cr_err" >/dev/null; then note "git said: $(head -n 4 -- "$cr_err" 2>/dev/null | tr '\n' ' ')" 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 + cp_reset_check "$1" "$2" "$cr_to" mixed 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)." elif ! git -C "$LANES_REPO" reset -q --keep "refs/remotes/origin/$LANES_BRANCH" 2>"$cr_err"; then note "git said: $(head -n 4 -- "$cr_err" 2>/dev/null | tr '\n' ' ')" 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 reset --keep\` refused over an uncommitted change), so it is kept on HEAD: $1 $2" 6 else + cp_reset_check "$1" "$2" "$cr_to" keep 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 diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index e3def007..1773cb64 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -916,3 +916,86 @@ def test_a_claim_that_meets_a_move_at_every_S3_fetch_stops_at_six_exit_2(box: Bo 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 origin/main HEAD" 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) + + +def test_a_commit_landing_during_the_replays_reset_is_put_back_exit_6(box: Box): + """NIT 2 of rv1b-c's re-check: the replay's HEAD check and its reset 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 in the checkout + just before the reset runs. Now the reset's ORIG_HEAD is asked: the reset + is undone, both commits are kept on HEAD and named (6), and nothing is + dropped.""" + c = box.seam() + box.race() + real = shutil.which("git") + assert real, "git is on PATH" + shim = box.root / "gitshim" + shim.mkdir() + mark = shlex.quote(str(box.root / "landed")) + cq = shlex.quote(str(c)) + rq = shlex.quote(real) + (shim / "git").write_text( + "#!/usr/bin/env bash" + "\n" + + 'case " $* " in *" reset -q --keep "*)' + "\n" + + f" if [ ! -e {mark} ]; then echo 1 > {mark}" + "\n" + + f" printf 'stamp' > {cq}/handoffs/stamp.md" + "\n" + + f" {rq} -C {cq} add -- handoffs/stamp.md" + "\n" + + f" {rq} -C {cq} commit -q -m 'handoff(repoS4-2@Eagle): a stamp from an unlocked writer' -- handoffs/stamp.md" + "\n" + + " fi ;;" + "\n" + + "esac" + "\n" + + f'exec {rq} "$@"' + "\n", encoding="utf-8") + (shim / "git").chmod(0o755) + result = box.helper("set-row-state", LANE, "LIVE · the write under it", + PATH=f"{shim}{os.pathsep}{box.env['PATH']}") + assert (box.root / "landed").exists(), "the commit landed in the window" + assert result.returncode == 6, result.stderr + assert "between the replay's HEAD check and its reset" in result.stderr + assert "the reset is undone" in result.stderr + 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 back" + assert len(box.unpublished(c)) == 2, "both kept on HEAD, for the publish act" + _settled(box, c) From c71621fc1d786f8e04da05188dcbf7443e63e1fb Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 16:47:57 +0000 Subject: [PATCH 14/59] SHOULD C (rv1b-c2's re-check at d9ff4e6): the replay's drop is a compare-and-swap of the branch from this write's own commit, so a commit that lands in its window is never dropped, and an up-to-date pull that rewrites ORIG_HEAD no longer loses the write while saying it is kept (#198, slice 1b) SHOULD C. After the replay's reset, `cp_reset_check` read ORIG_HEAD to learn what the reset moved from. ORIG_HEAD is one slot that every git process in the checkout writes, and an up-to-date `pull --rebase`, `merge --ff-only` or `pull --ff-only` sets it to HEAD without moving HEAD. Those are the unlocked writers NIT 2 is about (lane-start's stamp, lane-handoff, until slices 2b and 3). One that ran just after the reset turned the "undo" into a reset to where HEAD already was. The helper then said both commits were kept on HEAD and exited 6, with the write on neither HEAD nor origin. At 6471fd1 the same race replayed and the write landed (0), so this was a regression. A second gap: the keep-form reset named `refs/remotes/origin/`, which a reader's fetch can move after the check resolved it. The drop is now a compare-and-swap, and ORIG_HEAD is read nowhere: - `git update-ref -m ... refs/heads/ `, which git refuses unless the branch still holds this write's commit. Where it is refused, nothing has moved, and `cp_not_head` (the HEAD analysis the pre-check uses) names the state: on top of it, both kept on HEAD (6); not in HEAD, rewritten (`cp_rewritten`); HEAD unreadable, named as such. - Then the index and the working tree follow: `read-tree -m -u `, the same two-tree merge `reset --keep` makes, or `read-tree -m -i ` for `commit`'s carry form, as `--mixed`. Both name the resolved sha. - A tree step git refuses (a change in the way) puts the branch back the same way, from where the drop put it, and the commit is kept on HEAD (6). Where that is refused too (a commit landed on the branch in between), `cp_lost_hold` names the commit as off HEAD with `reflog -5`, never as kept. - "Kept on HEAD" is said only where `merge-base --is-ancestor` finds the commit on HEAD. `cp_reset_check` is deleted. The reviewer's minimum fix (undo only to an ORIG_HEAD that holds this write's commit, otherwise put the commit back) was weighed. It cannot tell case A from a real commit on top, so it would put the commit back and exit 6 where the write can land. Measured on git 2.43.0, the CAS form leaves the same branch, index and working tree as `reset --keep` and `reset --mixed`: clean, an unrelated unstaged change, a change in the way (refused alike, nothing moved), untracked files, and the carry form. The one difference is a change another process STAGED on an unrelated path in the window: it stays staged instead of being unstaged, and the re-run's S3 names it. Exit row 6, in the code's table and the manual, says the drop never takes another commit and names the off-HEAD case. "How to write" step 5 says the drop is made only while the branch still holds the commit. Tests (`tests/test_a23_publish_replay.py`; a `git` first on PATH makes the window exact, and its patterns match the drop at d9ff4e6 and after): - `test_an_up_to_date_pull_after_the_replays_drop_is_no_commit_on_top` (the reviewer's case A): exit 0, the replay at attempt 2, the write on origin once, nothing said to be kept, nothing unpublished. On d9ff4e6's helper it fails with `assert 6 == 0`. - `test_a_stamp_committed_and_pulled_around_the_replays_drop_is_never_lost` (case B, lane-start's order): exit 6, nothing replayed, the write and the stamp on HEAD, and every commit of this write's that the words call kept is on HEAD. On d9ff4e6's helper it fails: the write is on neither HEAD nor origin. - `test_a_commit_landing_before_the_replays_drop_is_never_dropped_exit_6` (NIT 2's case, re-pinned): the CAS is refused, both commits are kept on HEAD, and the branch's reflog has no drop. On d9ff4e6's helper it fails at the words. - Before, on an export of d9ff4e6 with this module (`runs/c3-shouldc-before.log`): the three cases, 3 failed, each for the reason above. - After, on this tree, one pytest process per module, paced (`runs/c3-shouldc-set.log`): the replay module 39 passed, the guard module 123, hygiene 253. The shell suite is left to CI. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 7 +- lanes-edit.sh | 120 ++++++++++++++----------- tests/test_a23_publish_replay.py | 145 +++++++++++++++++++++++++------ 3 files changed, 193 insertions(+), 79 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 70a6e9c5..d5551dee 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -209,8 +209,9 @@ creating a second one; two rows differing only by case are a refusal naming both confirmed is **3, `pushed, not confirmed: `**, never success: the commit stays on HEAD, and the next write's S3 finds it by content (published, nothing to do; not, every write refuses until the publish act sends it). A - push refused because origin moved drops this write's commit - (`reset --keep`) and runs the same write again on origin's new tip, so its + push refused because origin moved drops this write's commit (only while + the branch still holds it, then the tree, as `reset --keep` moves it) and + runs the same write again on origin's new tip, so its own checks re-run on the new content — `claim`'s rescan included, which ends a lost race as `CLAIM-LOST` (7). After six attempts, or a refusal with origin unmoved, the commit is kept on HEAD and named (6). @@ -1200,7 +1201,7 @@ else. | 3 | **pushed, not confirmed: ``** (Amendment 23 S4). 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 success. The commit stays on HEAD, 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 | | 5 | an edit moved more than one line and was refused | -| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): a sixth push refused because origin moved (Amendment 23 S4's bound; where S3 finds the sixth move before any edit, nothing is committed and it is 2), a replay's `reset --keep` refused over an uncommitted change, a commit another writer made on top of this one in the checkout before the replay or during its reset (the reset would drop it too, and one it did drop is put back), 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 (`git cherry -v origin/main HEAD`). Every later write from that checkout refuses until the lane publishes or archives it | +| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): a sixth push refused because origin moved (Amendment 23 S4's bound; 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 (the drop moves the branch only from this write's own commit, so it never takes another), 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 (`git cherry -v origin/main HEAD`). 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 until the lane publishes or archives it | | 7 | another act got there first and this one wrote nothing: `claim`'s `CLAIM-LOST` (another lane's claim landed first), and `set-lane-state` / `set-lane-tree`'s lifecycle fence (openRepoTools#91, below) | | 9 | `CLAIM-LOST` — issue #30's own dead-lane verdict could not be reconfirmed before a `--force` takeover's push landed: the source lane resumed, a live session now backs it up, or that could not be read at all (`claim` only). Never 7 — that code is a RIVAL's claim, and this is the same lane the takeover was granted over | | 10 | `lane-state`: the lane's lifecycle snapshot is there and could not be read, or is of this schema and not a whole record, so nothing can be read out of it as a snapshot (openRepoTools#91 and #115, below) — never the 8 that means it has none. It was 9 until #61 spent 9 on `claim`. `lane-trees`: the lane's `trees` directory is there and could not be listed (#157 item 4e) — never the 8 that means it recorded no tree | diff --git a/lanes-edit.sh b/lanes-edit.sh index 2d9fd2c3..147b6506 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -530,14 +530,16 @@ # 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 `reset --keep` refused over an uncommitted change, a commit -# another writer made on top of this one in the checkout before the -# replay or during its reset (the reset would drop it too, and one it -# did drop is put back), or a push (a write's, or the publish act's) +# 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 (the drop moves the branch only from this write's own +# commit, so it never takes another), 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. Every later write from that +# 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. @@ -2915,32 +2917,37 @@ cp_rewritten() { # 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 "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)" 6 } -# NOTHING ELSE WENT WITH THE REPLAY'S RESET (rv1b-c's NIT 2 on slice 1b). The -# HEAD check in `cp_replay` and its reset are two git calls, and an unlocked -# commit landing between them would be dropped with this write's. `reset` -# records the HEAD it moved from in ORIG_HEAD (with or without a reflog). -# Where that is not this write's commit, the reset is undone, but only while -# HEAD is still where the reset put it, so nothing that landed after it is -# touched; and both commits are kept and named. -cp_reset_check() { # - crk_from="$(git -C "$LANES_REPO" rev-parse -q --verify ORIG_HEAD 2>/dev/null || :)" - [ "$crk_from" = "$1" ] && return 0 - crk_now="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" - note "the replay's reset moved HEAD from ${crk_from:-an unreadable commit}, not from this write's commit $1: another commit was made on top of it in this checkout between the replay's HEAD check and its reset." - if [ -n "$crk_from" ] && [ "$crk_now" = "$3" ] && - git -C "$LANES_REPO" reset -q "--$4" "$crk_from" >/dev/null 2>&1; then - note "the reset is undone: HEAD is $crk_from again, and this write's commit and the one on top of it are both kept on it." - note "look at both: git -C $LANES_REPO log --oneline $1^..HEAD" - else - note "the reset could not be undone safely (HEAD is ${crk_now:-unreadable} now, and something may have landed after it): find the commits it moved past in git -C $LANES_REPO reflog -5, and restore them with git -C $LANES_REPO reset --keep once nothing else writes here." - fi - 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)." - die "the push was refused because origin/$LANES_BRANCH moved, and a commit landed on top of this write's in this checkout while the replay dropped it, so it is not replayed: $1 $2" 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, and again where the drop's +# compare-and-swap was refused. "Kept on HEAD" is said only where HEAD holds +# this write's commit, read by `merge-base --is-ancestor`, never assumed. +cp_not_head() { # + 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 "the push was refused because origin/$LANES_BRANCH moved, and HEAD cannot be read, so it is not replayed: $1 $2 (Amendment 23 S4)" 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" + note "HEAD is $cnh_head, not this write's commit $1: another commit was made on top of it in this checkout $3, and dropping this write's commit to replay it would drop that one too." + note "look at both: git -C $LANES_REPO log --oneline $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)." + die "the push was refused because origin/$LANES_BRANCH moved, 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" 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. `reset --keep` drops exactly this write's commit — 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 +# 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 @@ -2951,19 +2958,10 @@ cp_replay() { # # 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. - # Either reset below would then drop that commit too, and say nothing. So - # HEAD must still be this write's commit, or both are kept on HEAD and named. + # 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. cr_head="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" - if [ "$cr_head" != "$1" ]; then - # A COMMIT ON TOP IS ONE CASE; A REWRITE IS ANOTHER (rv1b-c's SHOULD A). - if [ -n "$cr_head" ] && ! git -C "$LANES_REPO" merge-base --is-ancestor "$1" HEAD 2>/dev/null; then - cp_rewritten "$1" "$2" "$cr_head" - fi - note "HEAD is ${cr_head:-unreadable}, not this write's commit $1: another commit was made on top of it in this checkout while its push was in flight, and dropping this write's commit to replay it would drop that one too." - note "look at both: git -C $LANES_REPO log --oneline $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)." - die "the push was refused because origin/$LANES_BRANCH moved, 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" 6 - fi + [ "$cr_head" = "$1" ] || cp_not_head "$1" "$2" "while its push was in flight" 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" @@ -2977,20 +2975,40 @@ cp_replay() { # # 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" 2>/dev/null || :)" - if [ "$CP_REPLAY_KEEPS_EDIT" = 1 ]; then - if ! git -C "$LANES_REPO" reset -q --mixed "$1^" 2>"$cr_err" >/dev/null; then - note "git said: $(head -n 4 -- "$cr_err" 2>/dev/null | tr '\n' ' ')" + 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 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 + 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 - cp_reset_check "$1" "$2" "$cr_to" mixed - 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)." - elif ! git -C "$LANES_REPO" reset -q --keep "refs/remotes/origin/$LANES_BRANCH" 2>"$cr_err"; then - note "git said: $(head -n 4 -- "$cr_err" 2>/dev/null | tr '\n' ' ')" 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 reset --keep\` refused over an uncommitted change), so it is kept on HEAD: $1 $2" 6 + 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 - cp_reset_check "$1" "$2" "$cr_to" keep 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 diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 1773cb64..5cbda90b 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -6,8 +6,8 @@ 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 (`reset --keep`, the only unpublished commit -S3 allows) and runs the verb again from the top on origin's new tip, so the +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 @@ -337,7 +337,7 @@ def test_push_refused_with_origin_unmoved_is_kept_exit_6(box: Box): def test_replay_refused_by_a_change_in_the_way_keeps_the_commit_exit_6(box: Box): - """`reset --keep` never runs over an uncommitted change: a hand edit of + """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() @@ -959,43 +959,138 @@ def test_a_commit_another_writer_rewrote_is_named_as_rewritten_exit_6(box: Box): _settled(box, c) -def test_a_commit_landing_during_the_replays_reset_is_put_back_exit_6(box: Box): - """NIT 2 of rv1b-c's re-check: the replay's HEAD check and its reset 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 in the checkout - just before the reset runs. Now the reset's ORIG_HEAD is asked: the reset - is undone, both commits are kept on HEAD and named (6), and nothing is - dropped.""" - c = box.seam() - box.race() +# 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() - mark = shlex.quote(str(box.root / "landed")) - cq = shlex.quote(str(c)) rq = shlex.quote(real) + mark = shlex.quote(str(box.root / "fired")) (shim / "git").write_text( "#!/usr/bin/env bash" + "\n" + - 'case " $* " in *" reset -q --keep "*)' + "\n" + + f'case " $* " in {pattern})' + "\n" + f" if [ ! -e {mark} ]; then echo 1 > {mark}" + "\n" + - f" printf 'stamp' > {cq}/handoffs/stamp.md" + "\n" + - f" {rq} -C {cq} add -- handoffs/stamp.md" + "\n" + - f" {rq} -C {cq} commit -q -m 'handoff(repoS4-2@Eagle): a stamp from an unlocked writer' -- handoffs/stamp.md" + "\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) - result = box.helper("set-row-state", LANE, "LIVE · the write under it", - PATH=f"{shim}{os.pathsep}{box.env['PATH']}") - assert (box.root / "landed").exists(), "the commit landed in the window" + 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 reset" in result.stderr - assert "the reset is undone" in 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 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 back" + 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_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) From e6ea8f8abbef939643f59d5bf207257ecf538ff9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 18:34:02 +0000 Subject: [PATCH 15/59] Amendment 23 S3-S4 in the shell suite: every case that pinned the pull, the capture, the carry or a race at `pull --rebase` now pins what a write does instead, and the thirteen cases CI found red at a9190aa are rewritten (#198, slice 1b stage 4) Slice 1b's writers fetch and fast-forward under the lock (S3) and push with no pull (S4), so `tests/test_lane_helpers.sh` is brought to them: - The claim-guard and R11 capture cases pin ratified choices 8 and 9: a pending register edit refuses every writer but `commit` (2, nothing written), and an unstaged change origin did not touch does not block. - The CLAIM-LOST races (repoH#4, #60, #70, repoT#1, repoTx#1) and `repoRace-1` run on a `race-at-push` `LANES_GIT` wrapper, which lands the peer's prepared commit at this claim's push, so the replay's rescan is what finds the rival. They no longer export `LANES_NO_FETCH=1`. - The hung push says what the read-back found, and then the publish act; the #32 section takes the replay's words, and a same-row rewrite on origin's tip is exit 0 now. - The deliberate strands are published by the fixture after each case, and nine fixture commits that were never pushed are pushed by `wip_publish` (the old carry pushed them with the next write). - add-row's, `ensure_log`'s and the alias table's refusals take their reworded recoveries; the own-log case is S3 step 4; the alias-unreadable rename runs without `LANES_NO_FETCH=1`; B23's line. The thirteen cases red in CI run 39585851301, by rv1b's four groups: - R33 (two cases): the durable half no longer hand-edits the row on a current HEAD, which S3 now refuses every writer over. The checkout is left genuinely behind (HEAD before the peer's stamp, asserted an ancestor of origin/main, the register clean), and S3 fast-forwards it. - repoDrop (three): re-pinned to the stray-copy refusal of `3c6b240` (NIT 2), which S3's fast-forward now reaches first. - add-row losing to a peer retirement (four): the race lands at the first fetch carrying S3's refspec, inside add-row's lock and after its checks, so the case meets MUSTs 1-2's re-run and add-row's own RETIRED refusal. A new case asserts that the race fired. NIT 3: the "no resurrected active row" check reads the bare origin. It used to read a peer's tracking ref that nothing moved, so it passed whatever happened. - migrate-state-cells over an unpushed marker (four): the seam's 1/UNKNOWN answer is pinned on the dry run, which is a read with no S3, and `--yes` is pinned to S3's unpushed row (2, nothing changed). `bash -n` passes. The suite runs whole only (each section builds on the state the ones before it leave), so it was not run here; CI's `tests` job is its run of record. CI record: run 39638244009, at 1d8cdee (c71621f plus this file, byte for byte): `tests` 1 failed, 3058 passed, 75 skipped; `tests-no-submodule` 1 failed, 2261 passed, 872 skipped. The one failure in both is tests/test_claude_current.py::test_a_command_that_ignores_term_is_killed_after_the_grace[host] ("KILL never came: 30.0s", the Ubuntu 26.04 image's uutils `timeout`), which this file does not touch, so the shell suite passes there. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_lane_helpers.sh | 521 ++++++++++++++++++------------------- 1 file changed, 260 insertions(+), 261 deletions(-) diff --git a/tests/test_lane_helpers.sh b/tests/test_lane_helpers.sh index aeeddb9b..b39e49b4 100755 --- a/tests/test_lane_helpers.sh +++ b/tests/test_lane_helpers.sh @@ -749,6 +749,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 @@ -1620,90 +1628,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 @@ -2111,9 +2080,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" @@ -2124,10 +2106,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 " @@ -2136,24 +2118,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" @@ -2189,20 +2169,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" @@ -2215,17 +2204,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" @@ -2241,17 +2229,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" @@ -3146,9 +3133,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)" @@ -3161,14 +3147,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" \ @@ -3773,6 +3766,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, @@ -3837,6 +3837,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 # @@ -4550,20 +4557,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. @@ -4621,32 +4635,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 @@ -4654,17 +4663,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 @@ -4681,17 +4690,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")" @@ -4706,67 +4716,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 # @@ -5156,6 +5111,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" @@ -5963,6 +5919,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 @@ -8477,6 +8434,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 @@ -8940,6 +8898,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 |" @@ -9113,10 +9072,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 @@ -9124,7 +9083,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. @@ -9138,6 +9097,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' @@ -9145,34 +9112,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 -------------------------------- # @@ -9223,8 +9196,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 @@ -9242,14 +9215,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" @@ -9689,7 +9660,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 @@ -9781,7 +9754,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 @@ -13883,22 +13856,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" @@ -13907,12 +13886,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" \ @@ -15130,6 +15114,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 @@ -15157,6 +15142,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 @@ -15314,6 +15300,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' \ @@ -15349,6 +15336,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 @@ -15962,9 +15950,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. @@ -16059,6 +16057,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 From 948e1e0f034b232062998cf2a0be2c49e755b820 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 21:45:07 +0000 Subject: [PATCH 16/59] NIT 3 (rv1b-c2's re-check at d9ff4e6, re-aimed by SHOULD C): the carry form's compare-and-swap and a put-back that cannot be made each have a case, so neither refusal of the replay's drop can be lost without a test failing (#198, slice 1b) NIT 3 found two branches of the replay's drop that no test reached. SHOULD C (c71621f) replaced both with the compare-and-swap, so the two cases are aimed at what it made: - `test_a_commit_landing_before_the_carry_forms_drop_is_never_dropped_exit_6`: `commit`'s carry form, with an unlocked commit landing on top just before its drop. git refuses the compare-and-swap and nothing moves: the hand-edit commit and the one on top are kept on HEAD and named (6), the working tree is clean, nothing is replayed, and the branch's reflog has no drop. - `test_a_drop_whose_tree_step_and_put_back_are_both_refused_is_named_off_HEAD_exit_6`: the drop moved the branch, the tree step after it is refused, and an unlocked writer commits on the branch in that moment, so the put-back's compare-and-swap is refused too (`cp_lost_hold`). The other writer's commit stays HEAD, and this write's commit is named as off HEAD with `reflog -5`, never as kept (6). A `git` first on PATH makes each moment exact (`_git_shim`). Red before: on an export of d9ff4e6 with this module (`runs/c5-nit3-red.log`), 2 failed. The carry-form case fails at the words: d9ff4e6's ORIG_HEAD undo kept both commits too, but said "while the replay dropped it". The put-back case cannot fire there: d9ff4e6's drop is one `reset` with no tree step, so its `fired` assert fails. Mutants, one pytest run each (`runs/c5-mut.log`): - B14, the put-back without its old value: KILLED by the put-back case (the branch is put back over the other writer's commit, and the words say "kept on HEAD"). - B15, new, the carry form's drop without the compare-and-swap (the keep form keeps it): KILLED by the carry-form case (exit 2 after a replay that dropped the stamp's commit). Its control B15c, the same mutant against the keep form's case alone, SURVIVED, which is why the carry form needs its own case. After, on this tree, one pytest process per module, paced (`runs/c5-nit3-set.log`): the replay module 41 passed (39 and the two new cases), the guard module 123, routing 46, #214's module 25, hygiene 253, a23_lock 24. The shell suite is left to CI. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- tests/test_a23_publish_replay.py | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 5cbda90b..f3e9a519 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1094,3 +1094,75 @@ def test_a_stamp_committed_and_pulled_around_the_replays_drop_is_never_lost(box: assert named, result.stderr assert all(on for _, on in named), f"called kept, and not on HEAD: {named}" _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) From 9683fd18a59e4d6b961e665b78fea8f9606a575f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 21:58:48 +0000 Subject: [PATCH 17/59] NIT 4 (rv1b-c2's re-check at d9ff4e6): migrate-state-cells' skip line for a log published under one case and held here under another no longer says "Pull first, or merge them": it tells the rename's stray copy from a rename committed here, and gives each its recovery (#198, slice 1b) The migration's scan skipped such a lane with "a case-only rename whose commit never landed. Pull first, or merge them (15(d))". No writer pulls now, and a hand pull in a shared checkout is the 1312Z hazard; a hand merge of a stray copy would publish a second file for one lane, which is why `ensure_log` stopped advising it (B36, 3c6b240). The dry run reaches this line only with the checkout at origin's tip (its behind test refuses first), so the other name is this checkout's own, in one of two states, and the line now says which: - NOT TRACKED: the copy a rename's two `mv`s left when its commit was never made. B36's recovery: compare it with the published log, re-run as its own write any line only the copy holds, remove the copy, put the published log back (`checkout HEAD -- `) and re-run; the lane's next write renames it once, inside its own commit. - committed here and not on origin: the publish act refuses it (#214's fence takes no rename, which deletes and re-adds the log) and no later write carries it, so it is archived and dropped (S6.3: the archive push, then `reset --keep origin/main`) and the dry run re-run. Tracked is asked with `core.ignorecase=false` (`CP_EXACT`), as the other pathspecs here are. Test (`tests/test_a23_guard_refusals.py`): `test_a_migration_dry_run_names_a_rename_that_never_landed_and_never_says_pull`, for both states: the line names the state and its recovery, the output has no "Pull first", the line no "merge them", the dry run writes nothing, and the recovery it gives, followed, leaves no row skipped. Red before, on 948e1e0's helper with this module (`runs/c5-nit4-red2.log`): 2 failed, each at "Pull first". Mutant B16 (the tracked test made `false`) is KILLED by the committed case (`runs/c5-mut.log`). After, on this tree, one pytest process per module, paced (`runs/c5-nit4-set.log`): the guard module 125 passed (123 and the two new cases), the replay module 41, routing 46, #214's module 25, hygiene 253, a23_lock 24. The shell suite is left to CI. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 13 +++++++- tests/test_a23_guard_refusals.py | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 147b6506..bd5cf632 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -12313,7 +12313,18 @@ EOF elif [ "$msc_ln" = 1 ]; then msc_lf="$msc_loc"; msc_rel="${LANES_LOG_PREFIX}${msc_loc##*/}" if [ "${msc_rel##*/}" != "${msc_pub##*/}" ]; then - msc_why="its object log is published as $msc_pub while this checkout has ${msc_rel##*/} — a case-only rename whose commit never landed. Pull first, or merge them (15(d))" + # NOT A PULL, AND NOT 15(d)'s HAND MERGE (rv1b-c2's NIT 4 on slice + # 1b; `ensure_log`'s stray-copy refusal names the same state). The + # behind test above refuses a dry run first, so this checkout is at + # origin's tip and the other name is its own: a rename it committed + # and never published, or the copy a rename's two `mv`s left when + # its commit was never made. A commit of both would publish a second + # file for one lane, and no writer pulls. + if [ "$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##*/}, 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 origin/$LANES_BRANCH, and re-run; the lane's next write renames the log 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 diff --git a/tests/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py index 0d11a9c3..2c7d9028 100644 --- a/tests/test_a23_guard_refusals.py +++ b/tests/test_a23_guard_refusals.py @@ -407,3 +407,55 @@ def test_W_behind_is_fast_forwarded_by_a_write_through_it(tmp_path: Path): 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 origin/main" in line, line + 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" From 9cdf8abff0453cc6739d17e9e80c354288596d0b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 22:12:18 +0000 Subject: [PATCH 18/59] A37, slice 2a's, built in 1b: `CP_KEEP_IF_GONE` names the one file a write edits in place, so a refused push whose origin no longer has that file keeps the commit on HEAD and names it, instead of replaying into nothing and dropping the write (#198, slice 1b) Slice 2a's A37: a `write-handoff --stamp` or `--prepend` that loses its race to a peer's delete of a TRACKED handoff is dropped by the replay with its commit, and the re-run finds no file and says "Nothing was written", so the write is only in the commit the replay dropped. The check belongs to 1b's `commit_publish`, as A11's and A26's did, so 1b makes it, and slice 2a's `write-handoff` sets the global for those two modes (2a uses it; no verb of 1b sets it). - `CP_KEEP_IF_GONE=""`, set at load beside the other CP_* and never from the environment: the path a write edits in place. - `cp_replay`, after its HEAD check and before the bound and the drop: where the global names a path this write's base (`^`) had and origin, just fetched by the read-back, does not, the commit is kept on HEAD and named (6), with the look and the S3 unpushed-row notes the bound gives. Asked of the base, so a file the write itself created is never "gone". Asked after the HEAD check, so "kept on HEAD" is said only of a commit HEAD is (`cp_not_head` answers otherwise). A file its base had and origin lacks means origin moved, so the origin-unmoved refusal before it can never be this case; the check sits in `cp_replay` rather than beside 2a's S4.4 block, which keeps the two textually apart for 2a's merge of this head. Exit row 6 is not reworded here: its "kept on HEAD and named" already covers the case, and the verb that reaches it is 2a's, whose row-6 clause (A27, A32) can name it. Test (`tests/test_a23_publish_replay.py`), slice 1b's own: `test_a_file_the_write_edits_in_place_deleted_on_origin_keeps_the_commit_exit_6`, on a copy of the helper whose one load-time line names the lane's log (`_helper_naming`; asserted to occur once; the shipped `repos.tsv` beside it), with `log` as the write: - `deletes-it`: a peer's `git rm` of the log lands at the push: 6, the words, `kept on HEAD: `, no replay, the write in this checkout's log, origin without the file, one commit unpublished; - `keeps-it`: a peer's empty commit lands: replayed and pushed at attempt 2 (0); - `never-had-it`: the global names a path neither the base nor origin has: replayed and pushed (0). Red before: the check made `if false &&` (mutant A01) fails `deletes-it` with exit 0, the log re-made by the re-run and pushed at attempt 2 (`runs/c5-mut.log`, `runs/c5-a37-red.out`); at 948e1e0 the global does not exist, so the copy cannot be made. Mutant A02 (the base test made `true`) is KILLED by `never-had-it` (`runs/c5-mut.log`). After, on this tree, one pytest process per module, paced (`runs/c5-a37-set.log`): the replay module 44 passed (41 and the three new cases), the guard module 125, routing 46, #214's module 25, hygiene 253, a23_lock 24, guard_launch_mode 13, deferred_97 9. The shell suite is left to CI. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 24 +++++++++++ tests/test_a23_publish_replay.py | 68 +++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index bd5cf632..808a739c 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1265,6 +1265,15 @@ 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`). @@ -2962,6 +2971,21 @@ cp_replay() { # # must still be this write's commit, or both are kept on HEAD and named. cr_head="$(git -C "$LANES_REPO" rev-parse -q --verify HEAD 2>/dev/null || :)" [ "$cr_head" = "$1" ] || cp_not_head "$1" "$2" "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" + 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 origin/$LANES_BRANCH\` run." + 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" diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index f3e9a519..02f4a831 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -35,7 +35,7 @@ import pytest from a23_box import REGISTER, RegisterBox, register_text -from conftest import WINDOWS_SKIP +from conftest import REPO, WINDOWS_SKIP pytestmark = [pytest.mark.skipif(shutil.which("bash") is None, reason="lanes-edit.sh is a bash script"), @@ -1166,3 +1166,69 @@ def test_a_drop_whose_tree_step_and_put_back_are_both_refused_is_named_off_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) + + +# ------------------------------- 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"]) +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".""" + c = box.seam() + log = f"lanes/log/{LANE}.md" + 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 "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) From 4b94f8774031089c55e1fc37e44ccbf77a55770b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 22:15:24 +0000 Subject: [PATCH 19/59] B04 (m1b-c2's mutant run): the replay's HEAD check has a case that only it can answer, and the on-top case asserts that the HEAD check answered it, so removing the check no longer survives behind the drop's compare-and-swap (#198, slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m1b-c2's re-run of slice 1b's mutants found B04 alive: with `cp_replay`'s HEAD check removed (`[ "$cr_head" = "$1" ] || cp_not_head …` made `:`), every case still passed, because the drop's compare-and-swap (SHOULD C, c71621f) refuses a commit on top as well, and exits 6 with the same outcome in other words. The two cases (m1b-c2's, from `S/act2/m1b/probe-b04.patch`, the docstring reworded): - `test_a_commit_made_on_top_before_the_replay_is_kept_exit_6` gains one assert: the words are the HEAD check's, "in this checkout while its push was in flight", not the drop's. - `test_a_commit_made_on_top_at_the_sixth_push_is_named_before_the_bound_exit_6`, new: five refused pushes, and at the sixth an unlocked writer's stamp lands on top. The HEAD check comes before the six-attempt bound, so the stamp is named as not this write's (6), never folded into the bound's "kept on HEAD", and both commits stay on HEAD. Run on this tree (`runs/c5-b04-new.log`): the two cases, 2 passed; hygiene 253 passed (`runs/c5-b04-hyg.log`). B04 against each case alone, one pytest run each (`runs/c5-mut.log`, B04a and B04b): KILLED by both. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- tests/test_a23_publish_replay.py | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 02f4a831..3d3802d4 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -742,6 +742,7 @@ def test_a_commit_made_on_top_before_the_replay_is_kept_exit_6(box: Box): 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 while its push was in flight" in result.stderr, "the HEAD check fired, not the drop's" 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" @@ -750,6 +751,39 @@ def test_a_commit_made_on_top_before_the_replay_is_kept_exit_6(box: Box): _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: From 638b13a88c278343214b63802ae6cb6acf153fb8 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 02:15:21 +0000 Subject: [PATCH 20/59] The refusal with origin unmoved reads HEAD before it says "kept on HEAD": a commit an unlocked writer made on top while the push was in flight, or a rewrite that took this write's commit off the branch, is named in `cp_not_head`'s words, as the replay names it (#198, slice 1b) w2a-c6 found it, the shape slice 2a's A41 took out of its S4.4: `commit_publish`'s exit 6 for a push refused with origin/ unmoved (a hook, a permission, a protected branch) said "the commit is local and kept on HEAD: " without reading HEAD. Until slices 2b and 3, lane-start's stamp and lane-handoff commit in the same checkout with no lock, so while the push is in flight: - a commit made on top sat at HEAD, and the words named this write's commit as HEAD; - a rewrite (an amend, a rebase) left HEAD without this sha at all, and the words still said it was kept there. Now HEAD is read first. Where it is not this write's commit, `cp_not_head` answers, as `cp_replay` does: a commit on top keeps both and names both, a rewrite goes to `cp_rewritten`, and an unreadable HEAD says so. All exit 6, nothing replayed. Where HEAD is this write's commit, the words are as before, byte for byte. `cp_not_head` keeps its three arguments, so slice 2a's call (`cp_not_head "$cp_sha" "$msg" "while its push was in flight"`, its A41) is unchanged. A fourth, optional argument, given only by this refusal, replaces "the push was refused because origin/ moved" with this refusal's own clause, "the push of was refused with origin/ unmoved (no lost race: a replay would meet the same refusal)". It also drops the note's "dropping this write's commit to replay it would drop that one too", since no drop would be made. `cp_rewritten` takes the same clause as an optional fourth. With three arguments, every word of both is as it was. Tests (`tests/test_a23_publish_replay.py`), on a push hook that runs the other writer, then refuses the push with origin unmoved: - `test_a_push_refused_with_origin_unmoved_names_a_commit_on_top_exit_6`: a stamp is committed on top. Both commits are kept on HEAD and named, every sha of this write's named is on HEAD, there is one push and no replay. - `test_a_push_refused_with_origin_unmoved_names_a_rewritten_commit_exit_6`: the commit is amended. The words say "rewrote this write's commit" and never "kept on HEAD", and the sha they name is not on HEAD. Red before the fix: both failed, with the old words "kept on HEAD: " of a HEAD that was not it (`runs/c6-i1-red.log`). Mutant B17 (the HEAD check made `true ||`, the code before the fix) was run against each case alone and KILLED by both (`runs/c6-mut.log`, B17a and B17b). After, on this tree: the two cases with the four neighbours they share words with, 6 passed (`runs/c6-i1-green.log`). The replay module had 47 passed (45 and the two new cases) and hygiene 253 (`runs/c6-i1-set.log`). The exit table's row 6 already names both causes ("a commit another writer made on top", "REWROTE"), so it is not reworded. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 37 ++++++++++++----- tests/test_a23_publish_replay.py | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 808a739c..2c04c21b 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2868,8 +2868,16 @@ EOF # 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. + # 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" "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)" 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 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 @@ -2919,30 +2927,37 @@ cp_not_confirmed() { # # 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() { # +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 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 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 "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)" 6 + 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)" 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, and again where the drop's -# compare-and-swap was refused. "Kept on HEAD" is said only where HEAD holds -# this write's commit, read by `merge-base --is-ancestor`, never assumed. -cp_not_head() { # +# 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 that refusal alone, is its 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. +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 "the push was refused because origin/$LANES_BRANCH moved, and HEAD cannot be read, so it is not replayed: $1 $2 (Amendment 23 S4)" 6 + die "$cnh_why, and HEAD cannot be read, so it is not replayed: $1 $2 (Amendment 23 S4)" 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" - note "HEAD is $cnh_head, not this write's commit $1: another commit was made on top of it in this checkout $3, and dropping this write's commit to replay it would drop that one too." + cp_rewritten "$1" "$2" "$cnh_head" "$cnh_why" + 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" 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)." - die "the push was refused because origin/$LANES_BRANCH moved, 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" 6 + 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" 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 diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 3d3802d4..1bffcb36 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -336,6 +336,76 @@ def test_push_refused_with_origin_unmoved_is_kept_exit_6(box: Box): _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 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" + _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 From 514b0c48fc53ecd5d1b3a5e84a340699ab7c19e9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 02:23:19 +0000 Subject: [PATCH 21/59] migrate-state-cells' line for two logs whose names differ only by case no longer says "15(d)'s hand merge" of the stray copy: the published log tracked with an untracked copy beside it gets `ensure_log`'s B36 recovery, and every other pair keeps 15(d)'s words (#198, slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit w1b-c5 found it beside NIT 4 and left it for the reviewer (~:12312 at 4b94f87). The migration's scan reads the lane's log in the working tree whatever its case. Where it found two, it said "this checkout holds 2 object logs for it whose names differ only by case — 15(d)'s hand merge". One such pair is `ensure_log`'s B36 state: the published log, tracked, and beside it the untracked copy that a case-only rename leaves when its commit never landed, once the published log is back. A hand merge commits that copy as a second published file for one lane, the thing Amendment 15 refuses. The state reaches the line. The published log is fast-forwarded into the checkout (the dry run's behind test passes), the copy is untracked, and `log_path_ci` finds one log on origin, so the two-logs branch is where the scan lands. Now, where the scan finds exactly two logs, one tracked under the published log's own name (an exact `ls-files --error-unmatch`, as NIT 4's line uses) and one untracked, the line gives B36's recovery: compare the two (`diff -- `), re-run as its own write any line only the copy holds, remove the copy (`rm -- `), and re-run. Any other pair keeps 15(d)'s words. That includes a rename committed here with the published spelling back beside it untracked, where removing the untracked file would remove the published spelling. Test: `test_a_migration_dry_run_names_a_stray_copy_beside_the_published_log_as_one` (`tests/test_a23_guard_refusals.py`), parametrised: - [stray-copy]: the dry run names the copy NOT TRACKED with the diff and the `rm`, never "hand merge", and writes nothing. Following the recovery, the next dry run skips no row. - [committed-rename]: the stray-copy recovery is never given. Red before the fix, on 638b13a's helper (`runs/c6-i2-red.log`): [stray-copy] failed on "15(d)'s hand merge", and [committed-rename] passed, since it guards the new condition. Mutants, run against the case each one targets (`runs/c6-mut.log`), all KILLED: - B18: the condition made `false`; - B19: the tracked test made `false`; - B20: the published-name check removed, which tells the user to remove the published spelling in [committed-rename]. After, on this tree: the migration cases, 4 passed (`runs/c6-i2-green.log`); the guard module 127 passed and hygiene 253 (`runs/c6-i2-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 26 ++++++++++++++++- tests/test_a23_guard_refusals.py | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 2c04c21b..5637abb5 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -12348,7 +12348,31 @@ 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 < Date: Thu, 22 Oct 2026 02:43:18 +0000 Subject: [PATCH 22/59] The carry form's tree step refused has a case: the branch is put back on this write's commit and the commit is kept on HEAD and named (6), so neither the carry form's own refusal nor its put-back can be lost without a test failing (#198, slice 1b) `cp_replay`'s carry form (`commit`'s, which gives its commit back as the hand edit it was) drops the branch to the commit's parent by a compare-and-swap, then moves the index alone (`read-tree -m -i`). Where git refuses that step, the branch is put back by a compare-and-swap from where the drop put it, and exit 6 says "this commit could not be turned back into the hand edit it was to replay it, so it is kept on HEAD". The keep form's refusal has a case (`test_replay_refused_by_a_change_in_the_way_keeps_the_commit_exit_6`), and so does the put-back that is refused too (NIT 3). The carry form's own refusal had none. `test_a_carry_forms_drop_whose_tree_step_is_refused_keeps_the_commit_exit_6` (`tests/test_a23_publish_replay.py`): a `git` first on PATH refuses the carry form's `read-tree -m -i`. The exit is 6, with git's words quoted. The sha the refusal names is HEAD, and the reflog shows the put-back. The hand edit is in HEAD's register, the working tree is clean, nothing is replayed, nothing reached origin, and the one commit is kept for the publish act. Mutants, each run against this case alone (`runs/c6-mut.log`), both KILLED: - B21: the carry form's refusal branch made `if false`, so the keep form's words were printed; - B22: the put-back made `true ||`, which left the branch on the parent while the words said HEAD. After, on this tree: the replay module 48 passed (47 and this case) (`runs/c6-i7-set.log`), hygiene 253. The case alone: 1 passed (`runs/c6-i7-new.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- tests/test_a23_publish_replay.py | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 1bffcb36..bc8940c1 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1272,6 +1272,44 @@ def test_a_drop_whose_tree_step_and_put_back_are_both_refused_is_named_off_HEAD_ _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" + 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: From f9b1de9fb7df320897381bc6ae2baf7f1be4f29d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 03:15:07 +0000 Subject: [PATCH 23/59] The last two places that said "kept on HEAD" without reading HEAD read it first: an unconfirmed push (3) and the publish act's refusal with origin unmoved (6) name a commit on top, or a rewrite, in `cp_not_head`'s and `cp_rewritten`'s words (#198, slice 1b) w1b-c6 found them with 638b13a, which took the same flaw out of `commit_publish`'s refusal with origin unmoved, and rv1b-c3 listed them as left. Both still said the commit(s) were "kept on HEAD" of a HEAD nobody had read. Until slices 2b and 3, lane-start's stamp and lane-handoff commit and rebase in the same checkout with no lock, so while a push is in flight a commit can land on top of this write's, or a rebase can take it off the branch altogether. - `cp_not_confirmed` (exit 3): HEAD is read first. Where it is not this write's commit, `cp_not_head` answers (a commit on top, or an unreadable HEAD) or hands on to `cp_rewritten` (a rewrite), each with the clause "pushed, not confirmed: - (never reported as success)" in place of "the push was refused because origin moved". The number stays 3: the one cause the table gives it is that nothing can settle where the push went, and a commit on top does not change that. So `cp_not_head` and `cp_rewritten` take an optional fifth argument, the exit status (6 unless given). Where HEAD is this write's commit, the die is byte for byte what it was. - The publish act's refusal with origin unmoved (6): HEAD is read against the commit the fence read (`CP_FENCE_TIP`); where it differs, `cp_not_head` answers with "the push of the N commit(s) up to failed with origin/ unmoved". Where it is the same, the die is as it was. A commit on top is the one the fence leaves local by design; it is now named instead of left unsaid. `cp_not_head`'s three-argument form is unchanged, so slice 2a's call (`cp_not_head "$cp_sha" "$msg" "while its push was in flight"`, its A41) is the same call. Its fourth argument (638b13a) is unchanged; the fifth is new and optional. Tests (`tests/test_a23_publish_replay.py`), hooks that land the other writer's act while the push is in flight: - `test_an_unconfirmed_push_reads_head_before_it_says_kept_on_head_exit_3` [on-top, rewritten]: the push lands (the hook runs it itself, then the other writer), the read-back's fetch fails: exit 3, the die names HEAD and this write's commit, or says the commit was rewritten and names no "kept on HEAD"; origin holds this write's commit. - `test_a_publish_act_push_refused_with_origin_unmoved_reads_head_exit_6` [head-is-the-tip, on-top, rewritten]: the first pins the die's words as they were; the others are the new ones. One push, no replay. Red before the fix: the four new cases failed on the old words ("the commit is kept on HEAD, and this is never reported as success", "they are kept on HEAD, and nothing of them reached origin") and the head-is-the-tip case passed (`runs/c7-i1-red.log`). Green after: the five and their seven neighbours, 12 passed (`runs/c7-i1-green2.log`). Mutants B23a/B23b (the exit-3 HEAD check, each case alone), B24a/B24b (the publish act's), B25 and B26 (the die's number, on top and rewritten) and B27 (the number handed on to `cp_rewritten`): all KILLED (`runs/c7-mut-final-i1.log`). The replay module 53 passed and hygiene 253 passed (`runs/c7-i1-set.log`, `runs/c7-i1-hyg2.log`); the exit-3 rule reads the die as before, and the call that carries its number is spelled with a variable, since the rule reads a line ending in a quoted `... 3` only as a `die`. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 --- lanes-edit.sh | 41 ++++++++++--- tests/test_a23_publish_replay.py | 98 ++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 5637abb5..273fab4c 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2913,11 +2913,23 @@ cp_pushed() { # # the next write's S3 finds it by content either way: published, it reads as # nothing to do; not published, as an unpushed commit, refused until the lane # publishes or archives it. +# +# "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 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" "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). @@ -2927,37 +2939,39 @@ cp_not_confirmed() { # # 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] +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 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 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)" 6 + 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 that refusal alone, is its 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. -cp_not_head() { # [] +# 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. +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)" 6 + die "$cnh_why, and HEAD cannot be read, so it is not replayed: $1 $2 (Amendment 23 S4)" "${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" + 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" 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)." - 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" 6 + 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 @@ -3098,6 +3112,15 @@ cp_publish_act() { 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)" "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 origin/$LANES_BRANCH HEAD" 6 } # OPEN QUESTION 2: A STACK ORIGIN HAS MOVED PAST IS NEVER REBASED. The commits diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index bc8940c1..bc28802b 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -481,6 +481,64 @@ def test_timed_out_push_is_read_back(box: Box, landed: bool): _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 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): @@ -683,6 +741,46 @@ def test_publish_act_on_moved_origin_refuses(box: Box): _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 "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", [ From 3a4bc4ac6d55bf9cb71f1f76fb0cf63479952cf4 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 03:20:04 +0000 Subject: [PATCH 24/59] A drop's compare-and-swap refused for a reason that is not a moved branch (a held `refs/heads/.lock`) shows git's own words and says nothing was moved, where it named a commit on top that nobody made (#198, slice 1b) rv1b-c3's NIT E (its probe P2). The replay's drop is `update-ref `, a compare-and-swap that git refuses when the branch is not at . It refuses for another reason too: another git process holds `refs/heads/.lock` (a peer's commit in flight, or an auto `pack-refs`). HEAD is then still this write's commit, and nothing is lost, but 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, read into `cr_err`, were never shown. - `cp_replay`: where the compare-and-swap is refused, `cr_err`'s first lines are printed as "git said: ...", as at the tree step below it, before `cp_not_head` answers. - `cp_not_head`: after the unreadable-HEAD case, where HEAD is still `$1`, it says that no commit was made on top, that git could not move `refs/heads/` off it, and that nothing was moved, and dies "git could not move the branch off this write's commit to drop it, so nothing was moved and it is kept on HEAD: " (6). A commit on top, a rewrite and an unreadable HEAD keep their words. The three- and four-argument forms are unchanged. Test (`tests/test_a23_publish_replay.py`): `test_a_drop_refused_for_another_reason_names_git_and_no_commit_on_top_exit_6`, rv1b-c3's P2 in the module's own fixtures: a `git` first on PATH creates `refs/heads/main.lock` just before the drop. Exit 6, git's "cannot lock ref ... main.lock" shown, "HEAD is still this write's commit " in place of "another commit was made on top", one commit kept for the publish act, nothing replayed. Red before the fix: 1 failed, "git's own words are shown" (`runs/c7-i2-red.log`). Green after: that case and the three neighbours that share the drop's words, 4 passed (`runs/c7-i2-green.log`). Mutants B28 (git's words not printed) and B29 (the HEAD-is-this-commit branch off): both KILLED (`runs/c7-mut.log`). The replay module 54 passed and hygiene 253 passed (`runs/c7-i2-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 --- lanes-edit.sh | 17 +++++++++++++++- tests/test_a23_publish_replay.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 273fab4c..3c3488a1 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2955,7 +2955,11 @@ cp_rewritten() { # [, as `cp_not_head` tak # 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. +# 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. 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" @@ -2965,6 +2969,11 @@ cp_not_head() { # [ $SELF commit \"\", the publish act — or archived (S6.3)." + 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}" @@ -3046,6 +3055,12 @@ cp_replay() { # 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 diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index bc28802b..6b608205 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1248,6 +1248,39 @@ def test_a_commit_landing_before_the_replays_drop_is_never_dropped_exit_6(box: B _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 + 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 From 251e90056dd317c692965bbeb387347127f27a0b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 03:23:05 +0000 Subject: [PATCH 25/59] `migrate-state-cells` calls a case-only rename "committed here" only where HEAD holds the file, so a rename that is only staged is unstaged and never archived, as it was when the index answered for HEAD (#198, slice 1b) rv1b-c3's NIT D (its probe P1). The dry run's line for a lane whose log is published under one case while this checkout has it under another asked `ls-files --error-unmatch` whether the rename was committed, and that reads the INDEX. A rename that was STAGED and never committed, which is what a write's `git commit` failure leaves after its `add` (exit 6), was told it was "committed here and not on origin" and given the archive recovery. Following it pushed an archive branch identical to origin's main, and the `reset --keep` after it only unstaged the rename, leaving the copy for the NEXT dry run to name as NOT TRACKED. Nothing was lost, but the recovery was two acts too many and its first words were wrong. "Committed" is now asked of HEAD (`git cat-file -e HEAD:`). The line has three states: - in HEAD: "committed here and not on origin", as before, with the archive recovery; - only in the index: "STAGED and not committed", with no archive step: `git restore --staged -- ` unstages it, which leaves the copy NOT TRACKED, and then the stray-copy recovery (compare, remove the copy, put the published log back, re-run) that line already gave; - in neither: "NOT TRACKED", as before. Test (`tests/test_a23_guard_refusals.py`): `test_a_migration_dry_run_never_calls_a_staged_rename_committed`, rv1b-c3's P1 in the module's own fixtures: the rename staged with HEAD = origin. The line must not say "committed here", must say "STAGED and not committed", give the `restore --staged` command, and give no archive or `reset --keep`; the dry run writes nothing. The test then follows the line: the command is run, the next dry run names the copy NOT TRACKED, that recovery is run, and the third dry run has nothing SKIPPED. Red before the fix: 1 failed, the old "committed here" line (`runs/c7-i3-red.log`). Green after: that case and the four neighbours (`runs/c7-i3-green.log`, 5 passed). Mutants B31 (the index asked again, the code before the fix), B32 (no line of its own for a staged rename), B33 (the unstage paths in the wrong order): all KILLED (`runs/c7-mut.log`). The guard module 128 passed and hygiene 253 passed (`runs/c7-i3-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 --- lanes-edit.sh | 11 ++++++- tests/test_a23_guard_refusals.py | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 3c3488a1..70349d2b 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -12421,8 +12421,17 @@ EOF # and never published, or the copy a rename's two `mv`s left when # its commit was never made. A commit of both would publish a second # file for one lane, and no writer pulls. - if [ "$NO_GIT" != 1 ] && git -C "$LANES_REPO" -c "$CP_EXACT" ls-files --error-unmatch -- "$msc_rel" >/dev/null 2>&1; then + # + # "COMMITTED" IS ASKED OF HEAD, NOT OF THE INDEX (rv1b-c3's NIT + # D): `ls-files --error-unmatch` answers for a STAGED rename too, + # which a failed `git commit` leaves, and the archive below would + # then push a branch identical to origin's. A staged one has no + # commit to archive: it is unstaged, and the copy it leaves is + # the NOT TRACKED state. + if [ "$NO_GIT" != 1 ] && git -C "$LANES_REPO" cat-file -e "HEAD:$msc_rel" 2>/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 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 diff --git a/tests/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py index a1d050b9..4bc9bf54 100644 --- a/tests/test_a23_guard_refusals.py +++ b/tests/test_a23_guard_refusals.py @@ -22,6 +22,7 @@ from __future__ import annotations import os +import shlex import shutil from pathlib import Path @@ -461,6 +462,55 @@ def test_a_migration_dry_run_names_a_rename_that_never_landed_and_never_says_pul 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 From 24efc8accd864b43f2037ab6bed25230149dec66 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 03:24:04 +0000 Subject: [PATCH 26/59] A file a write itself creates has a case in the A37 check: the `^` of `$1^:$CP_KEEP_IF_GONE`, asked of the commit's base and not the commit, is pinned, where the mutant that took it out survived (#198, slice 1b) rv1b-c3's NIT F, found by its mutant M4. `cp_replay` asks, before it drops anything, whether the file a write edits in place was in the write's BASE (`$1^:`) and is gone from origin's tip. The `^` matters: a file the write itself creates (a lane's first log line) is in the commit and in neither the base nor origin, and asked of the commit (`$1:`) it would read as deleted on origin, so an ordinary lost race on that write's first push would be refused (6) and kept instead of replayed. The three cases that existed (deleted, kept, a file nobody has) all passed with the `^` taken out: "a file nobody has" is absent from the commit too, so it does not tell the base from the commit. M4, `$1^:` turned into `$1:`, survived 3 passed (`runs/c7-mut.log`, B30x). The case is `creates-it` in `test_a_file_the_write_edits_in_place_deleted_on_origin_keeps_the_commit_exit_6` (`tests/test_a23_publish_replay.py`): origin has no log for the lane when the write starts (a peer removed it before this checkout was cloned), the write is `log NOTED lane:` naming that log as the file it edits in place, and a peer's commit that touches nothing lands at the push. The write creates the log at the replay: exit 0, one refused push and one that landed, the line in origin's log, nothing left unpublished. No change to `lanes-edit.sh`. Green: the four cases, 4 passed (`runs/c7-i4-green2.log`). Mutant B30 (M4) is KILLED by `[creates-it]` (`runs/c7-mut.log`), and B30x shows it SURVIVES the three old cases, which is the finding. The PR body's A02 row said a file the base never had is never "gone"; its case pins the existence test as a whole, not the `^`, and the body now says so and lists B30 for the `^`. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_a23_publish_replay.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 6b608205..29f79fcd 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1462,7 +1462,7 @@ def _helper_naming(box: Box, rel: str) -> list: return ["bash", str(copy)] -@pytest.mark.parametrize("peer", ["deletes-it", "keeps-it", "never-had-it"]) +@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 @@ -1473,9 +1473,21 @@ def test_a_file_the_write_edits_in_place_deleted_on_origin_keeps_the_commit_exit 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".""" - c = box.seam() + 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) From 558830708aa3329a06d5212171a918283c1c3d9d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 03:26:00 +0000 Subject: [PATCH 27/59] The manual says what `add-row`'s refusal of a row a peer pushed says: it names the row verbs, which fast-forward the checkout first, and no `git pull --rebase` (#198, slice 1b) `docs/README-lanes.md` said `add-row`'s refusal of a row on origin and not yet in this checkout names "the `git pull --rebase` that settles it". Since this slice no writer pulls: `ar_where` in the refusal says the row is on origin and not yet here, and to use `set-row-state` / `replace-in-row` on it, "each fast-forwards this checkout to it first (Amendment 23 S3)". The manual is the code's words now, in its own wrapping. Found by w1b-c6 with item 1, listed by rv1b-c3 as left. Text only: no code, no test names the sentence. Hygiene 253, `test_skill_shell_blocks` 6 and `test_test_roots` 39 passed, 1 skipped, the three tests that read the manual (`runs/c7-i5-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 --- docs/README-lanes.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index d5551dee..5df7fcf1 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -326,11 +326,12 @@ pair, **64** usage — and that 2 is RELAYED by `lane-start`, `lane-end` and `lane` rather than carried past, because the pair may be on `origin/` while this checkout still holds one row. `add-row` asks the published register as well as this copy of it for the -same reason: a row a peer pushed is a refusal here, naming it and the `git pull ---rebase` that settles it, and never a second row. The LOG is held to the same -rule — two files under `lanes/log/` whose names differ only by case are two logs -for one lane, and every read and every write under that name refuses, naming -both, until they are merged. +same reason: a row a peer pushed is a refusal here, naming it and the row verbs +(`set-row-state`, `replace-in-row`) that act on it, each of which fast-forwards +this checkout to it first (Amendment 23 S3), and never a second row. The LOG is +held to the same rule — two files under `lanes/log/` whose names differ only by +case are two logs for one lane, and every read and every write under that name +refuses, naming both, until they are merged. Honouring that by hand is five acts at one end of a lane and one at the other, and this register records where they get dropped: rows added hours late (`ROW From c71068bc9c25e52c529b2431e31dbd1f59c6c4ff Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 07:30:51 +0000 Subject: [PATCH 28/59] S3 fast-forwards to the sha its own fetch read, not to `refs/remotes/origin/` by name, so a reader's fetch in the shared checkout between S3's compare and its fast-forward is no longer brought in unchecked (#198, slice 1b) Lane openRepoTools-3's review of #307 at 5588307, MUST 281 (its P1 and P7). `checkout_sync` compared origin's tip after its fetch with the tip the checks read (decision 22), and then, about ten git calls later, fast-forwarded to the tracking ref by name. In a checkout several processes share (W while routing is off; a lane's checkout under the opt-in), anything that fetches without the lock moves that ref in between: every reader's `log_sync` (`who`, `history`, `lane-dir` ...) and every other writer's pre-lock one. What it moved in was fast-forwarded in unchecked, and the push, a plain fast-forward, landed at attempt 1: a rival's CLAIMED under this claim (two holders of one object), a peer's retirement under this `add-row` (a retired name issued again). - `checkout_sync`: `cs_now` is read once, right after S3's fetch, for every writer (it was read only inside the re-check), and step 8's ancestry test and `merge --ff-only` use `cs_to="${cs_now:-$cs_trk}"`. The write is made on that sha, so where origin moved since, its push is refused and S4's replay decides on the new tip. - Steps 5 and 6 keep the ref by name, on purpose: 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 "unpublished". Tests (`tests/test_a23_publish_replay.py`), with a `git` first on PATH that runs between S3's compare and its fast-forward (armed at S3's own fetch, fired at step 3's `symbolic-ref -q HEAD`): - `test_a_rival_claim_a_reader_fetched_in_after_S3s_compare_loses_exit_7` (P1): a rival's claim lands and a reader's `who` fetches it in. Now push refused at attempt 1, CLAIM-LOST (7), one holder. - `test_an_add_row_whose_retirement_a_reader_fetched_in_after_S3s_compare_refuses` (P7): a peer's retirement lands and is fetched in. Now push refused, the re-run refuses "is RETIRED" (2), the name not issued. Red before the fix: 2 failed, "two lanes hold one object" and "a retired name was issued again" (`runs/c8-281-red.log`). Green after, with the three neighbours of the window before S3's fetch: 5 passed (`runs/c8-281-green.log`). Mutant C01 (`cs_to="$cs_trk"`, the ref by name again): KILLED (`runs/c8-mut.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 23 +++++-- tests/test_a23_publish_replay.py | 106 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 70349d2b..de755f4d 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2321,11 +2321,22 @@ checkout_sync() { # [ "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_now="$(git -C "$cs_c" rev-parse -q --verify "$cs_trk" 2>/dev/null || :)" 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" @@ -2421,10 +2432,14 @@ CS_EOF [ -z "$cs_reg_pending" ] || cs_refuse_register "$cs_what" "$cs_lane" [ "$#" -eq 0 ] || cs_own_pending "$cs_what" "$cs_lane" "$@" - # ---- 8. the fast-forward + # ---- 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_trk" 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_trk" >"$cs_tmp/ff.err" 2>&1; then + ! 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. diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 29f79fcd..e51b599f 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1091,6 +1091,112 @@ def test_an_add_row_whose_name_a_reader_fetched_into_the_shared_checkout_refuses _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) + + 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 From dba848a4144028c02e569eef5e2102dacb05fd83 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 07:37:35 +0000 Subject: [PATCH 29/59] A write knows its own commit by what it is, not by reading HEAD after `git commit`, and the replay's drop is made only where that commit sits on origin's history, so a commit an unlocked writer lands beside it is never dropped with it (#198, slice 1b) Lane openRepoTools-3's review of #307 at 5588307, SHOULD 291 (its E8). `commit_publish` took this write's sha from `rev-parse HEAD` after `git commit`. 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, and one landing between those two calls was taken for this write's commit: the replay's HEAD check and its compare-and-swap both passed on the stamp's sha, the replay dropped it ("Commit is dropped", exit 0), and `stamp.md` left the working tree, on no branch and not on origin. That falsified decision 18 and exit row 6's "so it never takes another". Lane 3's sketch reads the parent before the commit and, where HEAD's parent is not it, takes the first commit after it, and notes that a commit landing just BEFORE `git commit` would still be taken, so "also match the subject". That window has a second half: a commit landed there is UNDER this write's commit, the HEAD check passes, and the keep form's drop to origin's tip takes it too. - `commit_publish` reads `cp_parent` (HEAD) before the commit, and `cp_own_commit` names this write's commit: HEAD where HEAD's parent is `cp_parent`, as before; otherwise the one first-parent commit since `cp_parent` whose message is this write's as git stored it (`git stripspace`, the cleanup `-m` gets). None, or two, is no answer: `cp_untold` says so, pushes nothing, and keeps both on HEAD (6). - `cp_replay`, the keep form: after the drop's target is named, the drop is made only where `$1^` is an ancestor of origin's tip; otherwise `cp_under` names the commit(s) under this write's, keeps them and it on HEAD, and replays nothing (6). The carry form drops to `$1^`, which takes this write's commit alone by construction. - Exit row 6 (code and manual) and the manual's step 5 say both. Tests (`tests/test_a23_publish_replay.py`, a `git` first on PATH at this write's `git commit -q -m LANES(...)`): - `test_a_commit_landing_just_after_this_writes_commit_is_never_taken_for_it_exit_6` (E8: the stamp after the commit): HEAD is the stamp, not this write's commit, both kept and named (6), nothing dropped. - `test_a_commit_landing_just_before_this_writes_commit_is_never_dropped_with_it_exit_6` (the stamp under it): "sits on 1 commit(s) it did not make", both kept (6), `stamp.md` still there. - `test_a_commit_with_this_writes_own_message_made_with_it_is_no_answer_exit_6`: two commits with one message, nothing pushed, both kept (6). Red on c71068b's helper: 3 failed, each exit 0 with "Commit is dropped" (`runs/c8-291-red.log`). Green: 7 passed with four neighbours (`runs/c8-291-green.log`). Mutants C02 (HEAD read after the fact), C03 (the drop's ancestry guard off) and C04 (two messages taken as one answer): all KILLED (`runs/c8-mut.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 5 +- lanes-edit.sh | 72 +++++++++++++++++++++++++-- tests/test_a23_publish_replay.py | 84 ++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 5 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 5df7fcf1..37c993d9 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -210,7 +210,8 @@ creating a second one; two rows differing only by case are a refusal naming both stays on HEAD, and the next write's S3 finds it by content (published, nothing to do; not, every write refuses until the publish act sends it). A push refused because origin moved drops this write's commit (only while - the branch still holds it, then the tree, as `reset --keep` moves it) and + the branch still holds it and it sits on origin's history, then the tree, + as `reset --keep` moves it) and runs the same write again on origin's new tip, so its own checks re-run on the new content — `claim`'s rescan included, which ends a lost race as `CLAIM-LOST` (7). After six attempts, or a refusal with @@ -1202,7 +1203,7 @@ else. | 3 | **pushed, not confirmed: ``** (Amendment 23 S4). 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 success. The commit stays on HEAD, 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 | | 5 | an edit moved more than one line and was refused | -| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): a sixth push refused because origin moved (Amendment 23 S4's bound; 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 (the drop moves the branch only from this write's own commit, so it never takes another), 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 (`git cherry -v origin/main HEAD`). 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 until the lane publishes or archives it | +| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): a sixth push refused because origin moved (Amendment 23 S4's bound; 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), 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 (`git cherry -v origin/main HEAD`). 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 until the lane publishes or archives it | | 7 | another act got there first and this one wrote nothing: `claim`'s `CLAIM-LOST` (another lane's claim landed first), and `set-lane-state` / `set-lane-tree`'s lifecycle fence (openRepoTools#91, below) | | 9 | `CLAIM-LOST` — issue #30's own dead-lane verdict could not be reconfirmed before a `--force` takeover's push landed: the source lane resumed, a live session now backs it up, or that could not be read at all (`claim` only). Never 7 — that code is a RIVAL's claim, and this is the same lane the takeover was granted over | | 10 | `lane-state`: the lane's lifecycle snapshot is there and could not be read, or is of this schema and not a whole record, so nothing can be read out of it as a snapshot (openRepoTools#91 and #115, below) — never the 8 that means it has none. It was 9 until #61 spent 9 on `claim`. `lane-trees`: the lane's `trees` directory is there and could not be listed (#157 item 4e) — never the 8 that means it recorded no tree | diff --git a/lanes-edit.sh b/lanes-edit.sh index de755f4d..a091beb4 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -532,8 +532,12 @@ # 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 (the drop moves the branch only from this write's own -# commit, so it never takes another), or a push (a write's, or the publish act's) +# 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), 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 @@ -2832,6 +2836,9 @@ commit_publish() { 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="" @@ -2856,7 +2863,7 @@ EOF else git -C "$LANES_REPO" -c "$CP_EXACT" commit -q -m "$msg" -- "${CP_PATHS[@]}" || die "git commit failed" 6 fi - cp_sha="$(git -C "$LANES_REPO" rev-parse HEAD 2>/dev/null)" + 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. @@ -2900,6 +2907,58 @@ EOF 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 + 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. +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) origin/$LANES_BRANCH does not have, which this write did not make: another writer in this checkout committed while this write was being made (an unlocked one: lane-start's stamp, lane-handoff), and dropping this write's commit to origin's tip would drop them too." + note "look at them: git -C $LANES_REPO log --oneline $3..$1" + 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 "the push was refused because origin/$LANES_BRANCH moved, and this write's commit sits on $cu_n commit(s) it did not make, so it is not replayed: it and they are kept on HEAD (Amendment 23 S4): $1 $2" 6 +} + # 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() { # @@ -3055,6 +3114,13 @@ cp_replay() { # 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 diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index e51b599f..a265a3df 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1437,6 +1437,90 @@ def test_a_stamp_committed_and_pulled_around_the_replays_drop_is_never_lost(box: _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 + 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 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 it did not make, so both are kept on HEAD and + named (6), and nothing is replayed.""" + 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) it 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" + assert len(box.unpublished(c)) == 2, "both kept on HEAD, for the publish act" + _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 From db4d2a40210c76026424b560905d870b60332695 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 07:44:26 +0000 Subject: [PATCH 30/59] `commit`'s carry retries a hand edit with `git apply -C0` only where every line it replaces occurs once in origin's version, so the second of two identical lines, or a blank line, is refused and kept instead of edited at another copy (#198, slice 1b) Lane openRepoTools-3's review of #307 at 5588307, SHOULD 292, and its answer to the body's Q3 ("not as written: accept it only behind the uniqueness guard, and refuse otherwise"). B22 retried a carried hand edit that no longer applied with `git apply -C0` wherever every hunk removes a line. `-C0` drops the context and edits the first copy of the removed line it finds, searching out from near its old line number. Through the real `commit`, a hand edit of the second of two identical lines, with origin changing the line after it, was pushed onto the first copy (exit 0, "anchored by those lines alone"); a replaced blank line landed elsewhere in the file. The real register has 32 duplicated blank lines and Rule 6 lines repeated up to three times; its rows are unique. - `cs_removed_unique `: every `-` line of the saved diff, read from a file (never through `$(...)`, so a last blank line is asked too), must occur exactly once (`grep -cxF`) in the register as the fast-forward left it. - `checkout_sync`'s carry: where the plain apply failed and the edit is anchored but a replaced line is not unique, `-C0` is not tried; the refusal (2) says "a line it replaces occurs more than once there, or not at all, so that line is no anchor for it", keeps the diff and names it, as any carry that no longer applies. The `-C0` note says the lines occur once. - The manual's step 2 qualifies the carry the same way. Test (`tests/test_a23_publish_replay.py`): `test_commit_refuses_a_hand_edit_whose_line_occurs_twice_on_origin` `[a-repeated-line|a-blank-line]`: the line twice on origin, the second copy edited by hand, origin changes the line after it; refused (2), the diff kept and named, the register at origin's version, nothing pushed. Red on dba848a's helper: 2 failed, exit 0, "anchored by those lines alone"; on origin the repeated line's edit sat on the FIRST copy (`runs/c8-292-red.log`). Green: 6 passed with the four carry neighbours (`runs/c8-292-green.log`). Mutant C05 (the uniqueness test off): KILLED (`runs/c8-mut.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 4 ++- lanes-edit.sh | 31 +++++++++++++++++++--- tests/test_a23_publish_replay.py | 44 ++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 37c993d9..edad1310 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -197,7 +197,9 @@ creating a second one; two rows differing only by case are a refusal naming both fetch that failed, for a writer. A pending hand edit of `lanes/LANES.md` refuses every writer but `commit`, which carries it across the fast-forward and commits it as its own act. A row edit carries even where - origin changed the row beside it, and anything that no longer applies is + origin changed the row beside it, where every line it replaces occurs + once in origin's version (a repeated or blank line is no anchor, so such + an edit is refused, kept and named), and anything that no longer applies is kept in a file under the checkout's `.git/lanes-edit/` and named. The old `LANES(pre-existing@…)` capture and `--sweep` are withdrawn (2); 3. re-reads the file, edits **exactly one line** in place, and refuses if diff --git a/lanes-edit.sh b/lanes-edit.sh index a091beb4..d4463d03 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2289,6 +2289,18 @@ cs_recheck() { # + 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 + done < "$cs_tmp/removed" + return 0 +} checkout_sync() { # […] cs_what="$1"; cs_lane="${2:-}"; cs_mode="$3"; shift 3 CS_NO_ORIGIN_BRANCH=0; CS_CARRY_DIFF="" @@ -2456,15 +2468,26 @@ CS_EOF fi fi if [ -n "$CS_CARRY_DIFF" ]; then - cs_applied=0 + cs_applied=0; cs_dup="" git -C "$cs_c" apply --whitespace=nowarn -- "$CS_CARRY_DIFF" 2>"$cs_tmp/apply.err" && cs_applied=1 - if [ "$cs_applied" = 0 ] && [ "$CS_CARRY_ANCHORED" = 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. + 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 there, or not at all, 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, so the edit was applied anchored by those lines alone" + note "origin changed a line beside the hand edit and none of the lines it replaces, each of which occurs once there, so the edit was applied anchored by those lines alone" fi if [ "$cs_applied" = 0 ]; then - cs_refuse "$cs_what" "the hand edit of $LANES_PATH no longer applies to origin's version of it, so it is KEPT in $CS_CARRY_DIFF 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' ' ')" \ + 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 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 diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index a265a3df..791795b4 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -604,6 +604,50 @@ def test_commit_refuses_an_insertion_whose_context_moved(box: Box): _settled(box, c) +TWICE = "LANDING — lane repoS4-7, session x@Eagle, 2026-10-20T00:00:00Z, PR #7 into main" + + +@pytest.mark.parametrize("dup", ["a-repeated-line", "a-blank-line"]) +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.""" + line = TWICE if dup == "a-repeated-line" else "" + after = "| `repoS4-8` | the line after the second copy |" + box.peer_edits(REGISTER, _row(NEIGHBOUR, uuid=UUID2), + _row(NEIGHBOUR, uuid=UUID2) + "\n" + line + "\n" + + "| `repoS4-6` | a line between the copies |" + "\n" + line + "\n" + after, + "the same line twice") + c = box.seam() + path = c / REGISTER + text = path.read_text(encoding="utf-8") + second = text.rindex("\n" + line + "\n" + after) + 1 + path.write_text(text[:second] + "LANDED — the second copy, edited by hand" + text[second + len(line):], + encoding="utf-8") + box.peer_edits(REGISTER, after, "| `repoS4-8` | the line after it, changed on origin |", + "origin changes the line after the second copy") + tip = box.origin_head() + result = box.helper("commit", "the second copy, by hand", LANES_LANE=LANE) + assert result.returncode == 2, result.stderr + 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 "the second copy, edited by hand" 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 From 21655516df1fb1913e6e5c29a7c7c014dcbbb81e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 07:46:04 +0000 Subject: [PATCH 31/59] The read-back that stops a doubled log line has a case: a push git calls failed that landed under a peer's commit is pushed once (0), and the mutant that replays it instead is killed (#198, slice 1b) Lane openRepoTools-3's review of #307 at 5588307, SHOULD 293 (its E17). `commit_publish` reads origin back after a refused push, because a push git reports as failed may have landed (decision 5), and a replay of a write that landed appends its line a second time to an append-only log. That read-back had no case: lane 3's mutant PB-2 (`0) cp_pushed "$cp_sha"; return 0 ;;` made `0) : ;;`) survived all 80 cases of the replay and #194 modules. Test (`tests/test_a23_publish_replay.py`), no helper change: `test_a_push_git_called_failed_that_landed_under_a_peers_commit_is_written_once`. A `log NOTED` whose push lands, a peer's commit lands on top of it at once, and the hook then reports the push failed ("the remote end hung up unexpectedly", exit 1). Origin holds this write's commit, so it is `pushed origin/main (attempt 1)` (0), nothing is replayed, one push, and the line is in the lane's log on origin once. Green: 4 passed with the read-back's neighbours (`runs/c8-293-green.log`). Red under PB-2 (C06 here): KILLED, rc 6, the write replayed at every attempt and stopped at the bound, with the line appended each time (`runs/mut-C06.out`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- tests/test_a23_publish_replay.py | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 791795b4..db8187a5 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -481,6 +481,39 @@ def test_timed_out_push_is_read_back(box: Box, landed: bool): _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 From 85575ee23eb3a1d56d0aaaa0cc4ebcf76bcfabba Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 07:56:21 +0000 Subject: [PATCH 32/59] The managed seam is asked again right after S3, at the five legacy sites that asked it only before the lock, so a ledger marker that S3's fast-forward brings in refuses the act (2) instead of being written over (#198, slice 1b) Lane openRepoTools-3's review of #307 at 5588307, SHOULD 283 (a MUST before the flip or the ledger's first enrolment; its P2). The legacy row verbs, `rename-lane` and `write_event`'s lane verbs asked `managed_seam_refuse` 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. With the ledger's `MANAGED OWNER` marker landing in the lane's state cell in that window, `set-row-state` wrote over it (exit 0) and the marker was gone from origin; at the base, a rebase conflict (3) had kept it. That refutes decision 19's "cannot be changed by a peer's landing". Latent today (origin's register has no marker), and worse after the flip, when a lane checkout's copy is only as fresh as that lane's last write. - `managed_seam_refuse` again right after `checkout_sync`, under the lock and before a byte, in `set-row-state`, `replace-in-row`, `append-session-id`, `rename-lane` (on its old name) and `write_event` (its lane verbs). The pre-lock asks stay: they refuse with no lock and no fetch where the marker was already there. Test (`tests/test_a23_guard_refusals.py`): `test_a_marker_S3_brings_in_is_asked_again_and_refuses_the_legacy_act` `[set-row-state|replace-in-row|append-session-id|rename-lane|log-PAUSED]`: the ledger's marker commit lands on origin at the write's S3 fetch; refused (2), "lane is owned by the managed ledger", "Nothing was written", origin's row as the ledger wrote it, no push, nothing local. Red on 2165551's helper: 5 failed, each exit 0 (the row overwritten, the line appended, the lane renamed) (`runs/c8-283-red.log`). Green: 5 passed (`runs/c8-283-green2.log`). Mutants C07-C11, one per site: all KILLED, each at its exit code (`runs/c8-mut.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 12 ++++++ tests/test_a23_guard_refusals.py | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/lanes-edit.sh b/lanes-edit.sh index d4463d03..e0cd1fe4 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -6631,6 +6631,14 @@ write_event() { # 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. @@ -16362,6 +16370,7 @@ Nothing was written." 2 # hands without the ledger: refused before the lock, nothing written. managed_seam_refuse "$lane" "set-row-state" 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=$? @@ -16401,6 +16410,7 @@ Nothing was written." 2 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; 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 @@ -16465,6 +16475,7 @@ Nothing was written." 2 lane="$(canon_lane "$lane")" || exit 2 # Amendment 15 managed_seam_refuse "$lane" "append-session-id" # Copilot round 3 on #97 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 @@ -16825,6 +16836,7 @@ Nothing was written." 2 # 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 diff --git a/tests/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py index 4bc9bf54..2442356e 100644 --- a/tests/test_a23_guard_refusals.py +++ b/tests/test_a23_guard_refusals.py @@ -559,3 +559,72 @@ def test_a_migration_dry_run_names_a_stray_copy_beside_the_published_log_as_one( 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() From c2fa058a8feb932bd8a8a36b96805f230b4a8117 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 08:07:04 +0000 Subject: [PATCH 33/59] Under the opt-in, `claim`, `release` and the sweeps' runner lane clone nothing for a lane with no row and no log, and under `LANES_NO_FETCH=1` every routed writer refuses before any clone where its lane has no checkout yet (#198, slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane openRepoTools-3's review of #307 at 5588307, SHOULD 285 (a MUST before the flip; its P4), with its answer to the body's Q4 (its P6). 285: R4-1's gate (`lane_row_gate`) left out `claim` and `release` (`*) return 0`), though they take their lane from `LANES_LANE` exactly as `log` and `commit` do, and the three sweeps' runner lane. Under `LANES_ROUTE_WRITES=1`, `LANES_LANE=repoP7-99 claim ...` for a lane with no row and no log cloned `-lanes/repoP7-99` (about 0.4 GB) and published `CLAIMED — lane repoP7-99`, exit 0; `release` the same. - `lane_row_gate`: `claim`, `release`, `retire-rows`, `archive-rows` and `migrate-state-cells` join the verbs a row OR the lane's published log passes (`lrg_log=1`). The two sweeps are routed only with `--yes`, so their dry runs are untouched. Q4: under `LANES_NO_FETCH=1`, S3 step 1 refuses every write in the lane's checkout (ratified choice 2), so cloning that checkout first is pure cost: the network, and about 0.4 GB on a 97%-full volume. P6 shows a lane W knows cloned and then refused (2). - `lane_checkout_enter`: before the gate, a routed writer under `LANES_NO_FETCH=1` (and not `LANES_NO_GIT=1`) whose lane's checkout does not exist refuses (2) in S3 step 1's words, saying the checkout "was not cloned". An existing checkout goes on, and S3 refuses there at no cost, as before. - The gate's own `LANES_NO_FETCH` branch could no longer run, and is gone: a fetch that did not run or failed is no answer there, as R22 has it. - One side effect, as lane 3 notes: `request-handoff`'s no-write exit 8 becomes 2, only for a target with no checkout and only under `LANES_NO_FETCH=1`. No shipped caller is affected: every shipped `LANES_NO_FETCH=1` call in lane-start and lane-handoff is a read or a sidecar verb, which `write_lane_of` does not route. Tests (`tests/test_a23_write_routing.py`): - `test_a_write_for_a_lane_with_no_row_and_no_log_clones_nothing` gains `[claim|release|retire-rows|archive-rows|migrate-state-cells]`. - `test_no_fetch_refuses_every_routed_writer_before_any_clone` `[set-row-state|log|claim|retire-rows|request-handoff]`, for lanes W knows. - `test_no_fetch_through_an_existing_checkout_is_refused_there_as_before`. Red on 85575ee's helper: 10 failed (the five typo cases cloned, `claim` and `release` exit 0; the five no-fetch cases cloned before S3's refusal), 7 passed (`runs/c8-285-red.log`). Green: 19 passed with the gate's neighbours (`runs/c8-285-green.log`). Mutants C12 (the gate's old verb list) and C13 (Q4's refusal off): both KILLED (`runs/c8-mut.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 36 ++++++++++++------- tests/test_a23_write_routing.py | 62 ++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index e0cd1fe4..e29c12f7 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -15923,8 +15923,12 @@ ws_pending_refuse() { # # 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. `claim`, `release` and the sweeps are not gated, and -# `add-row`, a new lane's first act, makes the row. +# 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, a log, or a rename's new name that another # checkout published and W has not fetched yet is never refused. @@ -15932,11 +15936,10 @@ ws_pending_refuse() { # # (`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. -# * EXCEPT `LANES_NO_FETCH=1` (slice 1b, R4-1): every gated verb is a writer, -# and for a writer a fetch that did not run is a fetch that failed, so S3 -# step 1 refuses it in the lane's checkout (ratified choice 2). Failing -# open there only cloned a checkout the write could never use, so on a -# miss the gate gives S3 step 1's refusal itself, and clones nothing. +# * `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. @@ -15950,18 +15953,14 @@ lrg_known() { # <1 = its published log answers too> — 0 kno lane_row_gate() { # case "$cmd" in set-row-state|replace-in-row|append-session-id|rename-lane) lrg_log=0 ;; - log|commit|request-handoff|append-line) lrg_log=1 ;; + 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_rc=0; lrg_known "$1" "$lrg_log" || lrg_rc=$? [ "$lrg_rc" = 1 ] || return 0 log_sync - if [ "$LOG_SYNC_FETCH" != yes ]; then - [ "${LANES_NO_FETCH:-0}" = 1 ] && [ "$NO_GIT" != 1 ] || return 0 - lrg_what="no row"; [ "$lrg_log" = 0 ] || lrg_what="no row and no object log" - die "lane $1 is not in W's origin/$LANES_BRANCH as it stands ($lrg_what there), and LANES_NO_FETCH=1 is set, so W was not fetched to make sure. For a writer a fetch that did not run is a fetch that failed (Amendment 23 S3 step 1, ratified choice 2), and \`$cmd\` would refuse in the lane's checkout for it, so that checkout, $2/, was not cloned. Nothing was written. Run this write without LANES_NO_FETCH (reads may keep it)." 2 - fi + [ "$LOG_SYNC_FETCH" = yes ] || return 0 lrg_rc=0; lrg_known "$1" "$lrg_log" || lrg_rc=$? [ "$lrg_rc" = 1 ] || return 0 if [ "$lrg_log" = 1 ]; then @@ -16024,6 +16023,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" diff --git a/tests/test_a23_write_routing.py b/tests/test_a23_write_routing.py index 46531467..a63690c7 100644 --- a/tests/test_a23_write_routing.py +++ b/tests/test_a23_write_routing.py @@ -556,6 +556,16 @@ def test_a_row_W_has_not_fetched_is_no_reason_to_refuse(box: Box, no_fetch: bool "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}), } @@ -566,7 +576,10 @@ def test_a_write_for_a_lane_with_no_row_and_no_log_clones_nothing(box: Box, verb 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.""" + 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) @@ -589,3 +602,50 @@ def test_a_lane_with_a_published_log_and_no_row_is_no_reason_to_refuse(box: Box) 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 From a465cb57c6fc1e7a634744abf2edcf12d02256a9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 08:16:25 +0000 Subject: [PATCH 34/59] A claim that runs again from the top with the lock held makes no network read: Rule 1's three reads and a takeover's stale-claim URL, made at attempt 1 before the lock, cross the `exec` and are printed and cited as they were (#198, slice 1b) Lane openRepoTools-3's review of #307 at 5588307, SHOULD 284 (its P3). A claim's re-run (S4's replay, or `cs_recheck`'s when origin moved under its checks) runs with the checkout's lock held from the top, and its top held Rule 1's reads (`gh_reads`: `gh issue view`, two `gh pr list` and an ssh `git ls-remote --heads git@github.com:...`) and, on a takeover, `gh_stale_claim_url`: none bounded by `git_net`, and no lock refresh. With routing off the lock is W's, so a stalled `gh` or `ssh` made every lane's writer exit 4 after 60 s, and past ten minutes `mutex_stale` calls the live holder stale and two writers run in W. Lane 3: skip the reads on a re-run ("they inform; the rescan decides the race"), carry the takeover URL across the exec as `CP_CLAIM_FROM` is, and keep the re-run itself. - The replay's markers gain `LANES__CLAIM_READS` and `LANES__CLAIM_URL`, read and unset at load with the others (`CP_CLAIM_READS`, `CP_CLAIM_URL`); `claim` puts its reads and its takeover payload in `CP_REPLAY_ENV`, on attempt 1 and on a re-run alike. - `claim`, step 2: on a re-run (with GitHub asked), the reads are attempt 1's, printed again and cited in the comment, with a note that they were made before the lock; otherwise `gh_reads`, as before. - The takeover's payload on a re-run is the URL attempt 1 read, where the stale claim's holder is still the takeover's source; otherwise `lane:`, as `--no-github` gives. No `gh_stale_claim_url`. Tests (`tests/test_a23_publish_replay.py`; a `gh` and a `git ls-remote` of GitHub that log whether the checkout's lock is held, origin moved once at S3's fetch): - `test_a_claims_re_run_makes_no_network_read_while_it_holds_the_lock` (P3): the claim runs again, no read under the lock, the reads printed at both attempts. - `test_a_takeovers_re_run_cites_the_claim_url_attempt_1_read_and_reads_nothing_with_the_lock`: a stale CLAIMED taken over with `--force`; the TAKEOVER line on origin cites the URL attempt 1 read, and nothing is read under the lock. Red on c2fa058's helper: 2 failed, each read logged "LOCK HELD" on the re-run (`runs/c8-284-red.log`). Green: 6 passed with the claim neighbours (`runs/c8-284-green2.log`). Mutants C14 (the reads skip off), C15 (the URL skip off), C16 (the URL not carried), C17 (the reads not carried): all KILLED (`runs/c8-mut.log`). (The first apply put "Rule 1's" inside a `${...:-...}` default, a syntax error under bash; it is a plain assignment now, before any run counted.) Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 38 +++++++++++--- tests/test_a23_publish_replay.py | 89 ++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index e29c12f7..11587ea8 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1152,16 +1152,19 @@ LANES_ROUTED="${LANES__CHECKOUT_LANE:-}" # the lane, in the re-run process onl # `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. Not `LANES__PID`: an unrouted write has no routing +# 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 network read with the lock held. 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_ATTEMPT=1; CP_REPLAYING=0; CP_CLAIM_FROM=""; CP_CLAIM_DEAD=""; CP_CLAIM_READS=""; CP_CLAIM_URL="" 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:-}" fi -unset LANES__REPLAY_PID LANES__ATTEMPT LANES__CLAIM_FROM LANES__CLAIM_DEAD +unset LANES__REPLAY_PID LANES__ATTEMPT LANES__CLAIM_FROM LANES__CLAIM_DEAD LANES__CLAIM_READS LANES__CLAIM_URL # THE ROUTING IS OPT-IN UNTIL SLICE 1B (the review of opensoft/openRepoTools#282, # 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 @@ -17688,7 +17691,8 @@ EOF 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") + 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") 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 @@ -17804,14 +17808,33 @@ 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 network 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" + else + reads="$(gh_reads "$obj")" || exit 1 + fi printf '%s\n' "$reads" # 3. decision 2 — a crossing warns and proceeds. @@ -17824,7 +17847,8 @@ EOF utc="$(utc_now)" CLAIM_OBJ="$obj"; CLAIM_LANE="$lane"; CLAIM_SKIP="$takeover_from"; CLAIM_SKIP_DEAD="$takeover_dead" CLAIM_ABANDON_REVIVED="" - CP_REPLAY_ENV=("LANES__CLAIM_FROM=$takeover_from" "LANES__CLAIM_DEAD=$takeover_dead") + CP_REPLAY_ENV=("LANES__CLAIM_FROM=$takeover_from" "LANES__CLAIM_DEAD=$takeover_dead" + "LANES__CLAIM_READS=$reads" "LANES__CLAIM_URL=$takeover_payload") # 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`). diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index db8187a5..858e0d02 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1274,6 +1274,95 @@ def test_an_add_row_whose_retirement_a_reader_fetched_in_after_S3s_compare_refus _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" + + +def _network_reads_logged(box: Box, c: Path) -> 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. 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' + 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}" ;; 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 + 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_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 From 333af4ec8831f9ee66232874de093d5c92521c25 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 08:17:44 +0000 Subject: [PATCH 35/59] The manual names the re-run decisions 19 and 22 made: where S3's fetch finds origin moved under `claim`'s or `add-row`'s own checks, nothing is written and the verb runs again from the top with the lock held, reading nothing from GitHub again (#198, slice 1b) Lane openRepoTools-3's review of #307 at 5588307, NIT 286. `docs/README-lanes.md`'s "How to write" step 2 and the `claim` section's step 4 described only S4's replay after a refused push. They now also say, in lane 3's words: for `claim` and `add-row`, if S3's fetch finds origin moved since their checks, nothing is written and the verb runs again with the lock held (within the six attempts; 2 at the sixth). And, with 284 (`a465cb5`), that the re-run makes no network read: Rule 1's three reads made at attempt 1 are printed and cited as they were. Docs only. Hygiene 253, skill_shell_blocks 6 and test_roots passed (`runs/c8-286-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index edad1310..6b9f831d 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -201,7 +201,12 @@ creating a second one; two rows differing only by case are a refusal naming both once in origin's version (a repeated or blank line is no anchor, so such an edit is refused, kept and named), and anything that no longer applies is kept in a file under the checkout's `.git/lanes-edit/` and named. The old - `LANES(pre-existing@…)` capture and `--sweep` are withdrawn (2); + `LANES(pre-existing@…)` capture and `--sweep` are withdrawn (2). For + `claim` and `add-row`, whose own checks ran before the lock, if S3's fetch + finds origin moved since those checks read it, nothing is written and the + verb runs again from the top with the lock held (within S4's six attempts; + 2 at the sixth). The re-run makes no network read: Rule 1's three reads + made at attempt 1 are printed and cited as they were; 3. re-reads the file, edits **exactly one line** in place, and refuses if `git diff --numstat` says more than one line moved; 4. commits as `LANES(@): ` — workstation from @@ -882,7 +887,11 @@ workstation. So: stops and reports, it does not author a successor. Nothing is written. 3. Run Rule 1's three reads and print them; warn if this is a crossing. 4. Append the `CLAIMED` line, commit, push (Amendment 23 S3–S4: fetched and - fast-forwarded first, no pull). A push refused because origin moved drops + fast-forwarded first, no pull). If S3's fetch finds origin moved since + steps 1–3 read it, nothing is written and the claim runs again from the + top with the lock held (within the six attempts; 2 at the sixth), and its + rescan comes first, as below; the re-run makes no network read, and prints + and cites the three reads attempt 1 made. A push refused because origin moved drops the `CLAIMED` commit and runs the claim again on origin's new tip, and the re-run rescans first: if another lane's `CLAIMED` or `TAKEOVER` is there, **theirs landed first** — it appends `CLAIM-LOST → lane:`, pushes From 93475aedea3131d3d5df546de5108079602271a9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 08:21:02 +0000 Subject: [PATCH 36/59] S3's diverged refusal tells a checkout holding a hand merge to archive first, since `git cherry` skips merge commits and the printed `reset --keep` would drop what the merge alone carries (#198, slice 1b) Lane openRepoTools-3's review of #307 at 5588307, NIT 287. Step 6 said "every commit on it is on origin already by content" and printed `reset --keep origin/main`. `git cherry` skips merge commits: in lane 3's scratch repo a hand merge carrying an extra line gave plus=0, and the printed reset would drop that line. The refusal is right, and the protocol forbids hand merges. - `checkout_sync`, step 6: where `git rev-list --merges --count ..HEAD` is not 0, the refusal (2, nothing written) names the merge commit(s) origin lacks, and its recovery gives the list with the stop line, S6.3's archive push (`archive/-stranded-`), then `reset --keep refs/remotes/origin/` and the re-made edits, in that order. With no merge, the old words stand. Test (`tests/test_a23_guard_refusals.py`): `test_a_diverged_checkout_holding_a_hand_merge_is_told_to_archive_first`: a `--no-ff` merge of origin, amended to carry a file of its own; cherry calls it published; `set-row-state` refuses (2) with the merge named, the archive before the reset, and nothing written. Red on 333af4e's helper: 1 failed, the old words (`runs/c8-287-red.log`). Green: 3 passed with the two diverged rows (`runs/c8-287-green.log`). Mutant C18 (the merge branch off): KILLED (`runs/c8-mut.log`). Guard 134, hygiene 253 (`runs/c8-287-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 13 +++++++++++++ tests/test_a23_guard_refusals.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lanes-edit.sh b/lanes-edit.sh index 11587ea8..7e5e340b 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2422,6 +2422,19 @@ CS_EOF 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 origin/$LANES_BRANCH # the content is already published" fi diff --git a/tests/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py index 2442356e..1f63e8bd 100644 --- a/tests/test_a23_guard_refusals.py +++ b/tests/test_a23_guard_refusals.py @@ -628,3 +628,33 @@ def test_a_marker_S3_brings_in_is_asked_again_and_refuses_the_legacy_act(box: Bo 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() From afad3e585b03b8c1db5a221ecf4ba055e0ebdc8e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 08:25:00 +0000 Subject: [PATCH 37/59] The NOT TRACKED recoveries of the migration, `retire-rows` and `archive-rows` say `git add`, then the pathspec commit in this checkout, then the publish act, where they gave a pathspec commit git refuses for an untracked file (#198, slice 1b) Lane openRepoTools-3's review of #307 at 5588307, NIT 288 (pre-existing: the base has it). The migration's skip line for a log that is here and NOT TRACKED said "Commit it first (`git -C commit -m "" -- `)". On an untracked file git answers "error: pathspec '' did not match any file(s) known to git", exit 1 (shown again in this round's red sandbox). And under S3 such a commit is an unpublished one, which refuses every writer until it is published. `retire-rows`' refusal for an untracked log and `archive-rows`' for an untracked archive file gave the same command; they are fixed with it. - `untracked_recovery `: "Whoever made it commits it as their own write, in this checkout (git -C add -- && git -C commit -m "()" -- ), and publishes it with the publish act (LANES_LANE= lanes-edit.sh commit "")". - The three places use it, then "re-run". Tests (`tests/test_a23_guard_refusals.py`), each following the line to the end (add, commit, the publish act, the act again): - `test_a_migration_dry_run_gives_an_untracked_log_a_recovery_that_works` - `test_retire_rows_gives_an_untracked_log_the_same_recovery_and_it_works` Red on 93475ae's helper: each failed on the old command (`runs/c8-288-red.log`, `runs/c8-288-red2.log`). Green: 2 passed with the staged-rename neighbour, and 1 (`runs/c8-288-green.log`, `runs/c8-288-green2.log`). Mutants C19 and C20 (each line back to its old words): KILLED (`runs/c8-mut.log`). Guard 136, hygiene 253 (`runs/c8-288-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 18 +++++++++-- tests/test_a23_guard_refusals.py | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 7e5e340b..437d8557 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2998,6 +2998,18 @@ cp_under() { # die "the push was refused because origin/$LANES_BRANCH moved, and this write's commit sits on $cu_n commit(s) it 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. +untracked_recovery() { # + 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 commit "")' \ + "$LANES_REPO" "$1" "$LANES_REPO" "$1" "$SELF" +} + # 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() { # @@ -12572,7 +12584,7 @@ EOF # 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##*/}" @@ -13422,7 +13434,7 @@ $(session_ids_local_of_lane "$rr_l" 2>/dev/null || :)" # 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 @@ -13562,7 +13574,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 diff --git a/tests/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py index 1f63e8bd..8e118ea5 100644 --- a/tests/test_a23_guard_refusals.py +++ b/tests/test_a23_guard_refusals.py @@ -658,3 +658,56 @@ def test_a_diverged_checkout_holding_a_hand_merge_is_told_to_archive_first(box: 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() + + +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 "the publish act (LANES_LANE=" in result.stderr + 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=NEIGHBOUR) + 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) From 7faa577fa1b58404fe7ae7a99938db8edfde62f3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 08:30:16 +0000 Subject: [PATCH 38/59] The publish act's refusal over a stack origin has moved past fills the lane into its archive branch, gives the list and the stop line first, and resets to `refs/remotes/origin/` in full (#198, slice 1b) Lane openRepoTools-3's review of #307 at 5588307, NIT 294 (its E13), and its answer to the body's Q2: yes to the fast-forward-only refusal (2), with this wording. `cp_publish_moved` printed a literal `archive/-stranded-` (E13, with `LANES_LANE=repoS4-1` set), where S3's own refusal fills in `${cs_lane:-}`; and it lacked the line S3, S6.3 and the manual give before the archive: if any `+` line is not your own write, STOP and take it to the lane its subject names. - `cp_publish_moved`: its RECOVERY lists the stack (`cherry -v`) with the stop line, then the archive push named `archive/${LANES_LANE:-}-stranded-`, then `reset --keep refs/remotes/origin/`, Q2's option, since a local branch named `origin/main` wins the short name; then the re-made edits. - `test_publish_act_on_moved_origin_refuses` asserts the full ref. Test (`tests/test_a23_publish_replay.py`): `test_the_publish_acts_moved_origin_recovery_names_the_lane_and_stops_first`: the queue, a peer moves origin past it, the publish act refuses (2): no ``, and the stop line, the archive and the reset, in that order. Red on afad3e5's helper: 2 failed (`runs/c8-294-red.log`). Green: 3 passed with the queue-listing neighbour (`runs/c8-294-green.log`). Mutants C21 (the stop line off), C22 (the literal `` again) and C23 (the short ref again): KILLED (`runs/c8-mut.log`). Replay 66, #194 25, hygiene 253 (`runs/c8-294-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 9 +++++++-- tests/test_a23_publish_replay.py | 26 +++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 437d8557..1e01bae2 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -3275,9 +3275,14 @@ cp_publish_act() { # 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. note "RECOVERY (S6.3), in this order:" - note " git -C $LANES_REPO push origin HEAD:refs/heads/archive/-stranded-$(date -u +%Y%m%dT%H%MZ) # the archive, on origin" - note " git -C $LANES_REPO reset --keep origin/$LANES_BRANCH # then drop them here" + note " list them: git -C $LANES_REPO cherry -v 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" + note " git -C $LANES_REPO push origin HEAD:refs/heads/archive/${LANES_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." 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 } diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 858e0d02..173a9166 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -811,13 +811,37 @@ def test_publish_act_on_moved_origin_refuses(box: Box): 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 origin/main" 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) + + @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 From fc6f34d9f14e4c3bb2a1ea8d26c451a5a5a95e52 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 08:32:05 +0000 Subject: [PATCH 39/59] Exit row 3, in both tables and in the manual's step 5, says the unconfirmed commit stays on HEAD unless another writer rewrote it while its push was in flight, which the words then say (#198, slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane openRepoTools-3's review of #307 at 5588307, NIT 296. Exit row 3 (`lanes-edit.sh`'s table and the manual's) and "How to write" step 5 said "The commit stays on HEAD". Where another writer rewrote the commit, `cp_rewritten` exits 3 saying it is no longer on HEAD (decision 30). The body's "Not changed" kept the row as it was on the reason that hygiene pins it, and lane 3 showed that reason does not hold: the exit-3 rule needs "pushed, not confirmed" and ``, and one more clause passes it. - Row 3 in both tables: "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 rule's withdrawn-cause check refuses the word "pull" in the row, so the clause names a rebase, not a `pull --rebase`. - Step 5: the same, in one parenthesis. Docs and comments only. Hygiene 253, skill_shell_blocks 6, test_roots 39 passed and 1 skipped (`runs/c8-296-set2.log`; the first wording, with "`pull --rebase`", failed the exit-3 rule: `runs/c8-296-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 6 ++++-- lanes-edit.sh | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 6b9f831d..33cfb26f 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -214,7 +214,9 @@ creating a second one; two rows differing only by case are a refusal naming both 5. **S4, after the edit:** pushes `main` with no pull and reads origin back. Confirmed is 0 and `pushed`. A push that went, or timed out, and cannot be confirmed is **3, `pushed, not confirmed: `**, never success: the commit - stays on HEAD, and the next write's S3 finds it by content (published, + stays on HEAD (unless another writer in the checkout rewrote it while its + push was in flight, which the words then say), and the next write's S3 + finds it by content (published, nothing to do; not, every write refuses until the publish act sends it). A push refused because origin moved drops this write's commit (only while the branch still holds it and it sits on origin's history, then the tree, @@ -1211,7 +1213,7 @@ else. | 0 | done — and for a write, **confirmed**: the commit was read back on origin before the writer said `pushed` (Amendment 23 S4) | | 1 | environment — no register, no writer | | 2 | refusal — bad arguments, the object is held, an unknown alias, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them); and, Amendment 23 S3, every writer with nothing written, where its checkout 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, or a fast-forward git refused; the withdrawn `--sweep`; the publish act over a stack origin has moved past (S4); and `claim` or `add-row` meeting origin moved under its own checks on each of six attempts, before any edit (S3, S4) | -| 3 | **pushed, not confirmed: ``** (Amendment 23 S4). 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 success. The commit stays on HEAD, 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 | +| 3 | **pushed, not confirmed: ``** (Amendment 23 S4). 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 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 | | 5 | an edit moved more than one line and was refused | | 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): a sixth push refused because origin moved (Amendment 23 S4's bound; 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), 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 (`git cherry -v origin/main HEAD`). 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 until the lane publishes or archives it | diff --git a/lanes-edit.sh b/lanes-edit.sh index 1e01bae2..2255f6aa 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -518,7 +518,10 @@ # 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, and the next write's S3 finds it +# 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. From 15e71d9ea2a832a46f4fd263effda9c1f7b24f1e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 12:20:45 +0000 Subject: [PATCH 40/59] `commit`'s carry tries `git apply -C0` only where every line the hand edit replaces occurs once in origin's version and once in the version it was made on, so an edit of a copy a peer changed or removed is refused and kept instead of landing on the copy nobody touched (#198, slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane openRepoTools-3's re-check of #307 at fc6f34d, SHOULD 341 (before #307 lands; not a regression: all four of its cases exit 0 at 5588307 too). 292's guard, `cs_removed_unique`, counted each replaced line in origin's version alone. A line can occur twice in the version the hand edit was made on and once on origin, because a peer removed or changed the copy the person edited; the full-context apply then fails, `-C0` is tried, and it edits the copy nobody touched. Through the real `commit`: a correction made after a peer changed that copy left both values on origin (exit 0, where decision 9 refuses), and the register's own shape, a LANDING then two identical LANDED lines with each side removing one, kept the LANDING with no LANDED (exit 0), which reopens Rule 6's hold. Origin's register holds such pairs today. - `checkout_sync` step 7 records the version the edit was made on, HEAD's, as its blob id (`CS_CARRY_BASE`), before it puts the file back to HEAD's. - `cs_removed_unique` also counts each `-` line in that blob (`git cat-file blob … | grep -cxF`), and a blob that cannot be read counts 0, so no line is an anchor. - Lane 3's sketch saved the base as `.git/lanes-edit/carry-base`. It is read from git instead: #308's fix for 351 holds a lane checkout on anything in `.git/lanes-edit/` but its four scratch files, and a fifth, written at every carry, would hold every checkout that ever carried a hand edit. - The refusal says the line "occurs more than once, or not at all, in origin's version or in the version the edit was made on"; the `-C0` note says once in both. The manual's step 2 says the same. Test (`tests/test_a23_publish_replay.py`): `test_commit_refuses_a_hand_edit_whose_line_occurs_twice_on_origin` gains lane 3's third and fourth cases as parameters, `[a-copy-a-peer-changed]` and `[the-registers-own-shape]`: refused (2), the diff kept and named, the register at origin's version, nothing pushed. Red on fc6f34d's helper: the 2 new cases failed, each exit 0 and pushed, the second leaving the LANDING with no LANDED (`runs/c9-341-red.log`). Green: 8 passed with the carry neighbours (`runs/c9-341-green.log`). Mutants C24 (the base count off) and C25 (the base read from origin's tip instead of HEAD): KILLED (`runs/c9-mut.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 5 +-- lanes-edit.sh | 28 ++++++++++++----- tests/test_a23_publish_replay.py | 52 ++++++++++++++++++++++++-------- 3 files changed, 63 insertions(+), 22 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 33cfb26f..d31f320c 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -198,8 +198,9 @@ creating a second one; two rows differing only by case are a refusal naming both refuses every writer but `commit`, which carries it across the fast-forward and commits it as its own act. A row edit carries even where origin changed the row beside it, where every line it replaces occurs - once in origin's version (a repeated or blank line is no anchor, so such - an edit is refused, kept and named), and anything that no longer applies is + once in origin's version and once in the version it was made on (a + repeated or blank line is no anchor, so such an edit is refused, kept and + named), and anything that no longer applies is kept in a file under the checkout's `.git/lanes-edit/` and named. The old `LANES(pre-existing@…)` capture and `--sweep` are withdrawn (2). For `claim` and `add-row`, whose own checks ran before the lock, if S3's fetch diff --git a/lanes-edit.sh b/lanes-edit.sh index 2255f6aa..470e5981 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2211,6 +2211,7 @@ remote_has_branch() { 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 @@ -2297,19 +2298,23 @@ cs_recheck() { # 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 } checkout_sync() { # […] cs_what="$1"; cs_lane="${2:-}"; cs_mode="$3"; shift 3 - CS_NO_ORIGIN_BRANCH=0; CS_CARRY_DIFF="" + CS_NO_ORIGIN_BRANCH=0; CS_CARRY_DIFF=""; CS_CARRY_BASE="" [ "$NO_GIT" = 1 ] && return 0 cs_c="$LANES_REPO" cs_trk="refs/remotes/origin/$LANES_BRANCH"; cs_br="refs/heads/$LANES_BRANCH" @@ -2460,6 +2465,10 @@ CS_EOF 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="" @@ -2497,13 +2506,18 @@ CS_EOF # 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. + # 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 there, or not at all, so that line is no anchor for it and it is not placed by that line alone" + 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, so the edit was applied anchored by those lines alone" + 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_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 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' ' ')" \ diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 173a9166..72f1cc42 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -638,9 +638,12 @@ def test_commit_refuses_an_insertion_whose_context_moved(box: Box): 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"]) +@pytest.mark.parametrize("dup", ["a-repeated-line", "a-blank-line", + "a-copy-a-peer-changed", "the-registers-own-shape"]) 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 @@ -650,31 +653,54 @@ def test_commit_refuses_a_hand_edit_whose_line_occurs_twice_on_origin(box: Box, 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.""" - line = TWICE if dup == "a-repeated-line" else "" + (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.""" + line = "" if dup == "a-blank-line" else TWICE after = "| `repoS4-8` | the line after the second copy |" + if dup == "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" + line + "\n" + - "| `repoS4-6` | a line between the copies |" + "\n" + line + "\n" + after, - "the same line twice") + _row(NEIGHBOUR, uuid=UUID2) + "\n" + block, "the same line twice") c = box.seam() path = c / REGISTER text = path.read_text(encoding="utf-8") - second = text.rindex("\n" + line + "\n" + after) + 1 - path.write_text(text[:second] + "LANDED — the second copy, edited by hand" + text[second + len(line):], - encoding="utf-8") - box.peer_edits(REGISTER, after, "| `repoS4-8` | the line after it, changed on origin |", - "origin changes the line after the second copy") + if dup == "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") + 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 + 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 "the second copy, edited by hand" in saved[0].read_text(encoding="utf-8") + 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() == "" From 53108b4b81654af296bc481824d39d77fa92ffb2 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 13:30:15 +0000 Subject: [PATCH 41/59] Under the opt-in, the NOT TRACKED recovery a dry run reads in W gives no commit in W: whoever made the file moves it into their own lane's checkout and publishes it from there, or removes it; and a routed sweep's recovery names its runner's lane in the publish act (#198, slice 1b) Lane openRepoTools-3's re-check of #307 at fc6f34d, SHOULD 331 (before the flip). 288's recovery for a file that is here and NOT TRACKED said to commit it "in this checkout". Under `LANES_ROUTE_WRITES=1` the migration's dry run is not routed, so it reads W, and followed word for word the commit went into W: the publish act then routed to the lane's own checkout, found nothing there to publish and exited 0, origin never got the log, and W was left holding a commit that S5 ("the readers' copy: no tool writes there") and `refresh-workspace` ("diverged ... Repairing W is a person's act") then refused. And a routed sweep printed `LANES_LANE=`, which routes back to its runner's checkout only if the reader puts in the runner's lane. - `untracked_recovery`: where the opt-in is on and `LANES_REPO` is W, it says W takes no commit (S5), and gives the move into the maker's own lane checkout (`mv -- / -lanes//`, after `lane-checkout --create` where that checkout is not there yet), the add and the pathspec commit there, and the publish act; or the removal, where the file is nobody's. Anywhere else it prints `LANES_LANE=${LANES_ROUTED:-${LANES_LANE:-}}`. - `repo_is_w`: W by repository, as S5 compares it: the git common directory of `LANES_REPO` is W's (W, a directory inside it, or a linked worktree of it). Tests (`tests/test_a23_guard_refusals.py`): - `test_under_the_opt_in_a_migration_dry_run_in_W_gives_no_commit_in_W` follows the line word for word with the maker's lane for ``: the log reaches origin, W holds no commit and no file of its own, `refresh-workspace` exits 0, and the dry run no longer names it. - `test_a_routed_sweep_names_its_runners_lane_in_the_publish_act`: a routed `retire-rows` names its runner's lane, and following the line publishes the log and lets the sweep run. - `test_retire_rows_gives_an_untracked_log_the_same_recovery_and_it_works` (288's) now asserts the lane `retire-rows` ran for. Red on 15e71d9's helper: 3 failed, 1 passed (288's migration case, which is unchanged) (`runs/c10-331-red.log`). Green: 4 passed (`runs/c10-331-green.log`). Mutants C26 (the W branch off), C27 (the lane back to ``) and C28 (W never recognised): KILLED (`runs/c10-mut.log`). Guard 138, hygiene 253 (`runs/c10-331-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 27 +++++++++++- tests/test_a23_guard_refusals.py | 75 +++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 470e5981..5522f18a 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -3022,9 +3022,32 @@ cp_under() { # # 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() { # - 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 commit "")' \ - "$LANES_REPO" "$1" "$LANES_REPO" "$1" "$SELF" + 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; diff --git a/tests/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py index 8e118ea5..53356559 100644 --- a/tests/test_a23_guard_refusals.py +++ b/tests/test_a23_guard_refusals.py @@ -702,12 +702,85 @@ def test_retire_rows_gives_an_untracked_log_the_same_recovery_and_it_works(box: 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 "the publish act (LANES_LANE=" in 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) From aa0f864f5d0963af4708a58a05c638b89eb8406d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 13:34:40 +0000 Subject: [PATCH 42/59] A claim's re-run is said to make no GitHub read, not no network read: S3's fetch, the push and its read-back still run on it, each bounded by `git_net` (#198, slice 1b) Lane openRepoTools-3's re-check of #307 at fc6f34d, NIT 332. 284 made a re-run print and cite Rule 1's reads from attempt 1 instead of making them again with the lock held. The manual (step 2 of "How to write", and the claim section's step 4), the load-time comment and the re-run's own note said it "makes no network read". On the re-run, with the lock held, an `ls-remote`, `log_sync`'s fetch, S3's fetch, the push and its read-back still run, all bounded by `git_net`; no GitHub read runs, which is what 284 asked. All four places now say "makes no GitHub read", as 284's commit title does. Test: `test_a_claims_re_run_makes_no_network_read_while_it_holds_the_lock` also asserts the note's words. Red on 53108b4's helper: 1 failed (`runs/c10-332-red.log`). Green: 2 passed with the takeover case (`runs/c10-332-green.log`). Mutant C29 (the note back to "no network read"): KILLED (`runs/c10-mut.log`). Hygiene 253, skill_shell_blocks 6, test_roots 39 + 1 skipped (`runs/c10-332-set.log`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 4 ++-- lanes-edit.sh | 4 ++-- tests/test_a23_publish_replay.py | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index d31f320c..07c6bf8c 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -206,7 +206,7 @@ creating a second one; two rows differing only by case are a refusal naming both `claim` and `add-row`, whose own checks ran before the lock, if S3's fetch finds origin moved since those checks read it, nothing is written and the verb runs again from the top with the lock held (within S4's six attempts; - 2 at the sixth). The re-run makes no network read: Rule 1's three reads + 2 at the sixth). The re-run makes no GitHub read: Rule 1's three reads made at attempt 1 are printed and cited as they were; 3. re-reads the file, edits **exactly one line** in place, and refuses if `git diff --numstat` says more than one line moved; @@ -893,7 +893,7 @@ workstation. So: fast-forwarded first, no pull). If S3's fetch finds origin moved since steps 1–3 read it, nothing is written and the claim runs again from the top with the lock held (within the six attempts; 2 at the sixth), and its - rescan comes first, as below; the re-run makes no network read, and prints + rescan comes first, as below; the re-run makes no GitHub read, and prints and cites the three reads attempt 1 made. A push refused because origin moved drops the `CLAIMED` commit and runs the claim again on origin's new tip, and the re-run rescans first: if another lane's `CLAIMED` or `TAKEOVER` is there, diff --git a/lanes-edit.sh b/lanes-edit.sh index 5522f18a..b9b087f6 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1157,7 +1157,7 @@ LANES_ROUTED="${LANES__CHECKOUT_LANE:-}" # the lane, in the re-run process onl # 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 network read with the lock held. Not `LANES__PID`: an unrouted write has no routing +# #307, 284), so a re-run makes no GitHub read with the lock held. 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="" @@ -17899,7 +17899,7 @@ EOF # 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 network read with it held (Amendment 23 S4; the rescan above decides the race)." + 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" else diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 72f1cc42..f40d86e8 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1386,6 +1386,11 @@ def test_a_claims_re_run_makes_no_network_read_while_it_holds_the_lock(box: Box) 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) From 921dd58e983f208e95fe81b235f7c3fec851150b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 13:41:53 +0000 Subject: [PATCH 43/59] S3's list and reset recoveries and the publish act's list line name `refs/remotes/origin/` in full, so a local branch named `origin/main` no longer makes them list nothing or reset to that branch (#198, slice 1b) Lane openRepoTools-3's re-check of #307 at fc6f34d, NITs 333 and 342. With a local branch named `origin/main`, the short name resolves to that branch, not to the tracking ref. S3 compares by the full ref (decision B3), but four of the commands its refusals and the publish act's give a person still used the short form: - S3 step 5's list line, `cherry -v origin/ HEAD`, which then listed nothing, so the STOP check it exists for read nothing; - step 5's archive recovery, `reset --keep origin/`, and step 6's no-merge recovery, the same, which reset to that branch; - the publish act's list line before its moved-origin recovery (294's), which listed 0 `+` lines where the full ref lists the queue. Each now names `refs/remotes/origin/$LANES_BRANCH`. Tests: - `test_s3s_recovery_commands_read_origin_past_a_local_branch_named_origin_main[unpushed|diverged]` (guard): with the shadowing branch at HEAD, the printed list line, run word for word, lists the unpushed commit, and the printed reset reaches origin's tip. - `test_the_publish_acts_list_line_reads_origin_past_a_local_branch_named_origin_main` (replay): the printed list line lists the whole queue. - The unpushed and diverged rows of `test_every_S3_row_refuses_every_writer_with_nothing_written`, and #194's `test_a_peers_unpushed_commit_refuses_the_write_and_is_never_reset_away` and `test_a_queue_origin_already_holds_by_content_is_the_diverged_refusal`, assert the full ref. Red on aa0f864's helper: 7 failed; the short list printed nothing, the reset left HEAD off origin's tip, the publish act's list gave no `+` line (`runs/c10-333-red.log`). Green: 7 passed (`runs/c10-333-green.log`). Mutants C30 to C33 (each line back to the short name): KILLED (`runs/c10-mut.log`). Hygiene 253, #194 25 (`runs/c10-333-set.log`). Not changed here: eight more commands in this slice's texts still name the short form (the origin-unmoved, deleted-file and sixth-attempt notes' `reset --keep`, `cp_not_confirmed`'s and `cp_rewritten`'s `cherry -v` and `log`, the publish act's origin-unmoved refusal, and the migration's case-only rename line); and slice 1a's two (`lane-checkout`'s unusable checkout, `refresh-workspace`) belong to #282's branch. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 8 ++--- tests/test_a23_guard_refusals.py | 37 ++++++++++++++++++-- tests/test_a23_publish_replay.py | 22 ++++++++++++ tests/test_commit_push_published_edit_194.py | 4 +-- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index b9b087f6..2f7ea5f1 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2423,10 +2423,10 @@ CS_EOF 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 origin/$LANES_BRANCH HEAD" \ + "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 origin/$LANES_BRANCH" + "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 @@ -2444,7 +2444,7 @@ CS_EOF "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 origin/$LANES_BRANCH # the content is already published" + "git -C $cs_c reset --keep refs/remotes/origin/$LANES_BRANCH # the content is already published" fi fi @@ -3320,7 +3320,7 @@ cp_publish_moved() { # 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. note "RECOVERY (S6.3), in this order:" - note " list them: git -C $LANES_REPO cherry -v 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" + 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" note " git -C $LANES_REPO push origin HEAD:refs/heads/archive/${LANES_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." diff --git a/tests/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py index 53356559..3ed6d798 100644 --- a/tests/test_a23_guard_refusals.py +++ b/tests/test_a23_guard_refusals.py @@ -22,6 +22,7 @@ from __future__ import annotations import os +import re import shlex import shutil from pathlib import Path @@ -180,15 +181,15 @@ def _staged(box: Box, c: Path): 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 origin/main HEAD", - "the publish act", "reset --keep origin/main"] + 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 origin/main"] + return {}, ["this checkout has diverged", "reset --keep refs/remotes/origin/main"] def _fetch_fails(box: Box, c: Path): @@ -660,6 +661,36 @@ def test_a_diverged_checkout_holding_a_hand_merge_is_told_to_archive_first(box: 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 diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index f40d86e8..1ee1b128 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -868,6 +868,28 @@ def test_the_publish_acts_moved_origin_recovery_names_the_lane_and_stops_first(b _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 diff --git a/tests/test_commit_push_published_edit_194.py b/tests/test_commit_push_published_edit_194.py index 660b821a..eed396ae 100644 --- a/tests/test_commit_push_published_edit_194.py +++ b/tests/test_commit_push_published_edit_194.py @@ -335,7 +335,7 @@ def test_a_peers_unpushed_commit_refuses_the_write_and_is_never_reset_away(box): # 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") - keep = _line_of(err, "reset --keep origin/main") + 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 @@ -576,7 +576,7 @@ def test_a_queue_origin_already_holds_by_content_is_the_diverged_refusal(box): result = box.helper("commit", SUBJECT) assert result.returncode == 2, result.stderr assert "this checkout has diverged" in result.stderr, result.stderr - assert "reset --keep origin/main" in 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 not any(" pull " in c or " push " in c for c in calls), calls From a44b73d8d9f74286ac71ff8ff3572e13139e1dcb Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 15:28:49 +0000 Subject: [PATCH 44/59] A hand edit that is also staged is carried against HEAD's version, never the index's: a test now stages the register's own shape, so a base read from the index can no longer pass (#198, slice 1b) 382 of lane openRepoTools-3's re-check of #307 at `15e71d9`. 341's guard counts each line the hand edit replaces in the version the edit was made on, `CS_CARRY_BASE`, the blob of `HEAD:` (S3 step 7), the same version the kept diff is taken against (`diff HEAD`). No test staged the edit, so a mutant reading the base from the index (`":$LANES_PATH"`) passed the replay module: with the edit staged, the base is the edited version, the LANDED line counts once there, and the register's own shape is pushed onto the wrong copy again (341). `test_commit_refuses_a_hand_edit_whose_line_occurs_twice_on_origin` gains `[the-registers-own-shape-staged]`, lane 3's tested parameter: the same edit as `[the-registers-own-shape]`, staged with `git add` before the peer's push. No helper change. - Red on `fc6f34d`'s helper: 3 failed (the two 341 cases and the new one, each `assert 0 == 2`), 2 passed. - Green: 5 passed; the replay module 70 passed. - Mutant C34 (the base read from the index) against the new case alone: KILLED (`assert 0 == 2`). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- tests/test_a23_publish_replay.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 1ee1b128..c5cd22cc 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -643,7 +643,8 @@ def test_commit_refuses_an_insertion_whose_context_moved(box: Box): @pytest.mark.parametrize("dup", ["a-repeated-line", "a-blank-line", - "a-copy-a-peer-changed", "the-registers-own-shape"]) + "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 @@ -662,10 +663,17 @@ def test_commit_refuses_a_hand_edit_whose_line_occurs_twice_on_origin(box: Box, 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.""" + 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 == "the-registers-own-shape": + 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 @@ -674,9 +682,11 @@ def test_commit_refuses_a_hand_edit_whose_line_occurs_twice_on_origin(box: Box, c = box.seam() path = c / REGISTER text = path.read_text(encoding="utf-8") - if dup == "the-registers-own-shape": + 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" From 0e56301fb0c63179635dfe2d11c09cb3c8a9fce0 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 15:30:01 +0000 Subject: [PATCH 45/59] The comment over `cs_removed_unique` says `.git/lanes-edit/` holds five scratch files, and names them, where it said four (#198, slice 1b) 383 of lane openRepoTools-3's re-check of #307 at `15e71d9`. Beside a kept hand edit, `checkout_sync` writes `apply.err`, `fetch.err`, `ff.err` and `removed` there, and the replay writes `reset.err`: five, the five #308's sweep lists at `e78e9fd`. A comment change only, with no test. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 5871a57a..fa32879e 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2303,7 +2303,8 @@ cs_recheck() { # 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 From aa45be1c750329ba99cb78bde4f477c6f9747503 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 17:37:28 +0000 Subject: [PATCH 46/59] A claim's re-run carries attempt 1's reads capped at 32,768 bytes, so an object with thousands of comments no longer makes the exec fail with exit 126 (#198, slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 334 of lane openRepoTools-3's re-check of #307 at `fc6f34d`. 284 carries attempt 1's whole `gh_reads` output across the re-run's `exec` as one environment string, `LANES__CLAIM_READS`. Linux caps one environment string at 128 KiB (MAX_ARG_STRLEN): past that the `exec` failed with "Argument list too long" and the write exited 126, outside the exit table, with no "Nothing was written". `claim` now puts a capped copy in `CP_REPLAY_ENV`: where the reads exceed 32,768 bytes, the copy is cut at 32,768 bytes, back to the last whole line, and ends with a line saying it was truncated and that attempt 1 printed all of Rule 1's reads. Attempt 1 still prints and cites them in full; a re-run prints and cites the copy it was given. The count strips BSD `wc`'s padding, as hygiene's portability rule asks. The takeover URL is one line and is not capped. New: `test_a_claims_re_run_carries_reads_too_long_for_one_environment_string` (replay module). `_network_reads_logged` gains `bulk`: its `gh` stub then answers `gh issue view --comments` with that many `CLAIMED —` lines; the test asks for 1,800 (about 190 KB of reads), and origin moves once at S3's fetch so the claim runs again from the top. - Red at `0e56301`: 1 failed, exit 126, "line 2296: /usr/bin/bash: Argument list too long". - Green: 3 passed with the two 284 cases; hygiene 253 passed; the replay module 71 passed (run before the `tr -d ' '`, which hygiene asked for and which changes nothing under GNU `wc`). - Mutant C35 (the cap's size test made `false`) against the new case: KILLED, exit 126. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 13 +++++++++++- tests/test_a23_publish_replay.py | 35 ++++++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index fa32879e..3a3c5b60 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -17906,8 +17906,19 @@ EOF 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" else reads="$(gh_reads "$obj")" || exit 1 + # 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)" + fi fi printf '%s\n' "$reads" @@ -17922,7 +17933,7 @@ EOF CLAIM_OBJ="$obj"; CLAIM_LANE="$lane"; CLAIM_SKIP="$takeover_from"; CLAIM_SKIP_DEAD="$takeover_dead" CLAIM_ABANDON_REVIVED="" CP_REPLAY_ENV=("LANES__CLAIM_FROM=$takeover_from" "LANES__CLAIM_DEAD=$takeover_dead" - "LANES__CLAIM_READS=$reads" "LANES__CLAIM_URL=$takeover_payload") + "LANES__CLAIM_READS=$reads_carry" "LANES__CLAIM_URL=$takeover_payload") # 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`). diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index c5cd22cc..47166dfc 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1363,18 +1363,23 @@ def test_an_add_row_whose_retirement_a_reader_fetched_in_after_S3s_compare_refus CLAIM_URL = "https://github.com/opensoft/repoS4/issues/1#issuecomment-284" -def _network_reads_logged(box: Box, c: Path) -> tuple: +def _network_reads_logged(box: Box, c: Path, bulk: int = 0) -> 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. Nothing reaches the network. Returns the - log and the env that puts both first and lets the claim ask GitHub.""" + answers a claim comment's URL, and with `bulk`, `gh issue view + --comments` answers that many `CLAIMED —` lines of about 100 bytes each. + 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 "" 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}" ;; esac' + "\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") @@ -1450,6 +1455,28 @@ def test_a_takeovers_re_run_cites_the_claim_url_attempt_1_read_and_reads_nothing _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 From 50e8916e01f528cd8267c410694eee0cf0b8457e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 17:46:11 +0000 Subject: [PATCH 47/59] Once the read-back finds a carried hand edit on origin, each copy of it this run saved is removed by name, so lane-end's sweep no longer holds the checkout for good (#198, slice 1b) 393 of lane openRepoTools-3 on #307 (comment 6345448649), the companion of #308's 351. S3 step 7 saves `commit`'s hand edit as `.git/lanes-edit/hand-edit--.diff` before the fast-forward, and nothing removed it after a carry that landed. #308's fix holds a checkout on anything in `.git/lanes-edit/` but its five scratch files, since a KEPT edit there may be a person's only copy, so every checkout that ever carried would be held for good. - `checkout_sync` records each copy it writes, by name, in `CP_CARRY_COPIES`. - The record crosses the replay's `exec` (and `cs_recheck`'s) as `LANES__CARRY_COPIES`, read and unset at load and honoured only where `LANES__REPLAY_PID` is this process, as the other markers are. A lost push race replays the carry under the same pid and saves a second copy, while `CS_CARRY_DIFF` names only the last. - `cp_pushed`, reached only after `cp_confirm`'s fresh fetch and `merge-base --is-ancestor` have found this write's commit on origin, removes each recorded copy (`cp_drop_carry_copies`). It removes a name only if it has no `/` and ends in this run's own pid, and it never uses a glob: pids recur, and an older run's kept copy can carry this pid. A copy that cannot be removed is named, and the write is still 0. - A refused, kept edit (2), an unconfirmed push (3) and every 6 keep their copies. The carry note says the copy stays "until the read-back finds the edit on origin". Tests (replay module): - `test_commit_carries_a_hand_edit_across_the_ff` (`:594-596`) now asserts the copy is gone after a success, not that it exists. - New: `test_a_lost_races_two_copies_of_the_hand_edit_are_both_removed_once_origin_has_it`. - New: `test_a_carry_removes_only_the_copies_it_wrote_never_another_runs_under_the_same_pid`. A kept copy under another pid stays. So does one planted under this run's own pid by a `git` first on PATH at the carry's `diff --binary HEAD`, whose parent is this run. - `test_a_carry_forms_drop_whose_tree_step_is_refused_keeps_the_commit_exit_6` also asserts that the copy stays (6: origin does not have the edit). Results: - Red on `aa45be1`'s helper: 3 failed and 1 passed (the exit-6 guard). Each failure leaves the copies behind, and the lost race left two distinct copies. - Green: 6 passed with the two carry-replay neighbours. The replay module 73 passed; hygiene 253 passed. - Mutants C36-C42, each KILLED at its own assertion: - C36: no removal. - C37: only the last copy recorded. - C38: the record not exported at the replay's `exec`. - C39: the record not read at load. - C40: a glob on the pid. - C41: a glob on every copy. - C42: removal at the commit, before the proof. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 48 ++++++++++++++++++-- tests/test_a23_publish_replay.py | 76 ++++++++++++++++++++++++++++++-- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 3a3c5b60..7a6f7ea0 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1157,17 +1157,21 @@ LANES_ROUTED="${LANES__CHECKOUT_LANE:-}" # the lane, in the re-run process onl # 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. Not `LANES__PID`: an unrouted write has no routing +# #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`). 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_ATTEMPT=1; CP_REPLAYING=0; CP_CLAIM_FROM=""; CP_CLAIM_DEAD=""; CP_CLAIM_READS=""; CP_CLAIM_URL=""; CP_CARRY_COPIES="" 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:-}" fi -unset LANES__REPLAY_PID LANES__ATTEMPT LANES__CLAIM_FROM LANES__CLAIM_DEAD LANES__CLAIM_READS LANES__CLAIM_URL +unset LANES__REPLAY_PID LANES__ATTEMPT LANES__CLAIM_FROM LANES__CLAIM_DEAD LANES__CLAIM_READS LANES__CLAIM_URL LANES__CARRY_COPIES # THE ROUTING IS OPT-IN UNTIL SLICE 1B (the review of opensoft/openRepoTools#282, # 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 @@ -2293,6 +2297,7 @@ cs_recheck() { # "$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 @@ -2525,7 +2533,7 @@ CS_EOF "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)" + 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 @@ -3074,6 +3082,37 @@ cp_why_unconfirmed() { # 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 + 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 < 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; } 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 } diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 47166dfc..0de51c23 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -591,12 +591,22 @@ def test_commit_carries_a_hand_edit_across_the_ff(box: Box): assert "LIVE · a hand edit" in register assert box.head(c) == box.origin_head() assert box.git("-C", str(c), "status", "--porcelain").strip() == "" - saved = list((c / ".git" / "lanes-edit").glob("hand-edit-*.diff")) - assert len(saved) == 1 - assert "a hand edit" in saved[0].read_text(encoding="utf-8") + # 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 @@ -738,6 +748,63 @@ def test_commit_replay_keeps_the_hand_edit(box: Box): _settled(box, c) +def test_a_lost_races_two_copies_of_the_hand_edit_are_both_removed_once_origin_has_it(box: Box): + """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 under a new name, 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.""" + 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 "push refused (attempt 1 of 6)" in result.stderr, "the race was lost once" + copies = _copies_named(result.stderr) + assert len(copies) == 2, result.stderr + assert len(set(copies)) == 2, "attempt 1 and its replay each saved a copy of its own" + for copy in copies: + assert f"its copy is removed: {copy}" in result.stderr, copy + 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_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: @@ -1907,6 +1974,9 @@ def test_a_carry_forms_drop_whose_tree_step_is_refused_keeps_the_commit_exit_6(b 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) From af29be6cb923a8c58ba25cb244d0637e92097551 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 20:20:42 +0000 Subject: [PATCH 48/59] `cp_under` names the commits under this write's as ones origin lacks and this write did not make, with both causes, and gives S6.3's archive after the list and the STOP line, never the publish act, which refuses there (#198, slice 1b) 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: origin's history rewritten at the push, and a new branch's first write raced by another, gave the same words with no such writer. Its first remedy was the publish act, which always refuses there, since origin has moved past HEAD ("moved past", 2). - The note now says "commits that origin/ lacks and this write did not make: another writer's here ..., or history origin no longer has (rewritten there) or never had (a new branch's first write, raced by another)", and the die says "commit(s) that origin lacks and this write did not make". - The recovery is the publish act's refusal's own order: the list (`log --oneline ..`) with the STOP line, then the archive on origin, the `reset --keep refs/remotes/origin/` and the redo. The three archive lines move into `cp_archive_steps`, which `cp_publish_moved` and `cp_under` both call, so their words cannot drift. - The smaller half: the HEAD checks said a commit on top came "while its push was in flight", but 291's case (a), a stamp just after `git commit`, comes before the push. The four callers of `cp_not_head` that read HEAD after a push (the replay, the origin-unmoved refusal, the unconfirmed push and the publish act) now say "before or while its push was in flight". `cp_not_head` keeps its three-argument form. New: `test_history_origin_no_longer_has_under_this_writes_commit_is_never_called_a_writer_here_exit_6` (a force-push at the push takes the peer commit S3 fast-forwarded onto). `test_a_commit_landing_just_before_this_writes_commit_is_never_dropped_with_it_exit_6` asserts the new words and the recovery's order; the case (a) test asserts "before or while"; three tests that pinned "in this checkout while its push was in flight" now pin "before or while". - Red at `50e8916`: 6 failed, 1 passed (the rewritten parameter of the exit-3 case, which `cp_rewritten` words). - Green: 8 passed, with the publish act's recovery case; hygiene 253 passed; the replay module 74 passed. - Mutants C43-C46 (the old cause, the archive dropped, the publish act offered again, "while" alone at the replay): all KILLED, each at its own assertion. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 38 +++++++++++----- tests/test_a23_publish_replay.py | 74 +++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 7a6f7ea0..42260714 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2963,7 +2963,7 @@ EOF 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" "while its push was in flight" \ + 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)" 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 origin/$LANES_BRANCH\` run." @@ -3016,12 +3016,22 @@ cp_untold() { # # 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) origin/$LANES_BRANCH does not have, which this write did not make: another writer in this checkout committed while this write was being made (an unlocked one: lane-start's stamp, lane-handoff), and dropping this write's commit to origin's tip would drop them too." - note "look at them: git -C $LANES_REPO log --oneline $3..$1" - 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 "the push was refused because origin/$LANES_BRANCH moved, and this write's commit sits on $cu_n commit(s) it did not make, so it is not replayed: it and they are kept on HEAD (Amendment 23 S4): $1 $2" 6 + 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 @@ -3134,7 +3144,7 @@ cp_not_confirmed() { # 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" "while its push was in flight" "pushed, not confirmed: $1 — $3 (never reported as success)" "$cnc_rc" + 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). @@ -3211,9 +3221,11 @@ cp_replay() { # # 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. + # 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" "while its push was in flight" + [ "$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 @@ -3347,7 +3359,7 @@ cp_publish_act() { # 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)" "while its push was in flight" \ + 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 origin/$LANES_BRANCH HEAD" 6 } @@ -3362,10 +3374,16 @@ cp_publish_moved() { # in full, since a local branch named `origin/` wins the short name. 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 + 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 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. +cp_archive_steps() { note " git -C $LANES_REPO push origin HEAD:refs/heads/archive/${LANES_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." - 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 } # ====================================================== AMENDMENT 14 ======= diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 0de51c23..b9702f1b 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -364,7 +364,7 @@ def test_a_push_refused_with_origin_unmoved_names_a_commit_on_top_exit_6(box: Bo 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 while its push was in flight." in err, "no drop is offered as the reason" + 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" @@ -561,7 +561,7 @@ def test_an_unconfirmed_push_reads_head_before_it_says_kept_on_head_exit_3(box: 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 while its push was in flight." in 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: @@ -1136,7 +1136,7 @@ def test_a_commit_made_on_top_before_the_replay_is_kept_exit_6(box: Box): 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 while its push was in flight" in result.stderr, "the HEAD check fired, not the drop's" + assert "in this checkout before or while its push was in flight" in result.stderr, "the HEAD check fired, not the drop's" 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" @@ -1812,20 +1812,38 @@ def test_a_commit_landing_just_after_this_writes_commit_is_never_taken_for_it_ex 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 + # 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 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 it did not make, so both are kept on HEAD and - named (6), and nothing is replayed.""" + 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)) @@ -1836,11 +1854,53 @@ def test_a_commit_landing_just_before_this_writes_commit_is_never_dropped_with_i 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) it did not make" 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" 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" + _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) From 173f3c82677aea33f4c6855cbb420b9bc60b863c Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 20:29:02 +0000 Subject: [PATCH 49/59] The refusal with origin unmoved lists the commits under this write's with the STOP line before any reset, and step 5 and exit row 6 say that `commit`'s carry form drops to its own parent, so a commit under it stays and the re-run refuses on it with 2 (#198, slice 1b) 344 of lane openRepoTools-3's re-check of #307 at `fc6f34d`. - Case (e2): an unlocked writer's stamp lands after S3 and just before this write's `git commit`, so it sits 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 `^` is not on origin's history, it says how many commits origin lacks under it and lists `refs/remotes/origin/..HEAD` with the STOP line, before the reset, which now spells `refs/remotes/origin/` in full. - Exit row 6 (the header and the manual) and the manual's step 5 stated the keep form's drop rule as every write's. `commit`'s carry form drops to its own commit's parent by design (B21): a commit under it stays, the hand edit goes back to the working tree with its copy kept, and the re-run's S3 refuses on that commit (2, not 6). Both now say so. New (replay module): `test_a_push_refused_with_origin_unmoved_over_a_stamp_under_it_lists_them_and_stops_first_exit_6` and `test_a_stamp_under_the_carry_forms_commit_stays_and_the_re_run_refuses_on_it_exit_2`, which pins what step 5 and row 6 now say (it passes before and after: no behaviour changes there). - Red at `af29be6`: 1 failed (the list), 1 passed (the carry form's pin). - Green: 4 passed, with the two origin-unmoved cases; hygiene 253 passed; the replay module 76 passed. - Mutant C47 (the list's condition made `false`): KILLED at its assertion. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 11 ++++-- lanes-edit.sh | 17 ++++++++- tests/test_a23_publish_replay.py | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 07c6bf8c..9a09136f 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -224,8 +224,13 @@ creating a second one; two rows differing only by case are a refusal naming both as `reset --keep` moves it) and runs the same write again on origin's new tip, so its own checks re-run on the new content — `claim`'s rescan included, which - ends a lost race as `CLAIM-LOST` (7). After six attempts, or a refusal with - origin unmoved, the commit is kept on HEAD and named (6). + ends a lost race as `CLAIM-LOST` (7). `commit`'s carry form drops to its + own commit's parent instead, by design: the change goes back to the + working tree as the hand edit it was, and the re-run carries it again, + or, where a commit under it is not on origin (an unlocked writer's), + refuses at S3 (2) with the edit in the working tree and its copy kept. + After six attempts, or a refusal with origin unmoved, the commit is kept + on HEAD and named (6). **No write rebases, and no recovery resets with `--hard`.** If a write refuses over commits origin lacks, list them first — `git -C cherry -v @@ -1217,7 +1222,7 @@ else. | 3 | **pushed, not confirmed: ``** (Amendment 23 S4). 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 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 | | 5 | an edit moved more than one line and was refused | -| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): a sixth push refused because origin moved (Amendment 23 S4's bound; 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), 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 (`git cherry -v origin/main HEAD`). 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 until the lane publishes or archives it | +| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): a sixth push refused because origin moved (Amendment 23 S4's bound; 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 (`git cherry -v origin/main HEAD`). 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 until the lane publishes or archives it | | 7 | another act got there first and this one wrote nothing: `claim`'s `CLAIM-LOST` (another lane's claim landed first), and `set-lane-state` / `set-lane-tree`'s lifecycle fence (openRepoTools#91, below) | | 9 | `CLAIM-LOST` — issue #30's own dead-lane verdict could not be reconfirmed before a `--force` takeover's push landed: the source lane resumed, a live session now backs it up, or that could not be read at all (`claim` only). Never 7 — that code is a RIVAL's claim, and this is the same lane the takeover was granted over | | 10 | `lane-state`: the lane's lifecycle snapshot is there and could not be read, or is of this schema and not a whole record, so nothing can be read out of it as a snapshot (openRepoTools#91 and #115, below) — never the 8 that means it has none. It was 9 until #61 spent 9 on `claim`. `lane-trees`: the lane's `trees` directory is there and could not be listed (#157 item 4e) — never the 8 that means it recorded no tree | diff --git a/lanes-edit.sh b/lanes-edit.sh index 42260714..9b2077d7 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -538,7 +538,10 @@ # 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), a commit made in the same moment as this +# 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 @@ -2965,8 +2968,18 @@ EOF [ "$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 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 origin/$LANES_BRANCH\` run." + 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" diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index b9702f1b..2ac77638 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1904,6 +1904,71 @@ def test_history_origin_no_longer_has_under_this_writes_commit_is_never_called_a _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 From 40c57b9bbf34c70a2bb1154abbd5ba5516bcbdbc Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 21:54:41 +0000 Subject: [PATCH 50/59] A writer whose checkout, or the directory its lock lives in, is removed while it waits for that lock refuses at once with 1, naming the checkout as removed, instead of waiting out its minute and saying another run holds the lock (#198, slice 1b) 360 of lane openRepoTools-3's review of #308, for #307. - lane-end's sweep removes a CLOSED lane's checkout, and a person may move one aside, while a writer for that lane waits on the checkout's lock. The waiter's `mkdir` then fails on a missing parent; `mutex_stale` read the missing lock as one naming no holder and over ten minutes old, and `mutex_steal` could not take it, so the writer waited out its 60 s and exited 4: "another lanes-edit run is active (holder pid unrecorded)". - `mutex_try` now answers 2 at once where the directory the lock lives in is gone (it still never exits); `lock_try` passes the 2 on, and `acquire_lock` calls `lock_gone_die`: "the checkout , which this write goes through, was removed while the write waited for its lock", the sweep and a move aside as its causes, "Nothing was written", and the re-run (a routed write makes the checkout again, S1). Where the checkout is still there and only its `lanes/` went, it names that directory and its restore. Exit 1, as for a register that is not there: no other run holds anything. - The creation lock (`-lanes/..lock`) has the same shape: where `-lanes/` itself goes while a writer waits to clone, it says so (1) instead of "another writer is creating" the checkout after the clone's eleven-minute budget. - Exit row 1 in the header names the new cause. `sidecar_try` makes its directory just before it waits and keeps reading any failure as 1. New (a23_lock module): `test_a_writer_waiting_on_a_checkout_that_is_removed_refuses_at_once[removed|moved-aside|its-lanes-directory-removed]` and `test_a_writer_waiting_to_create_a_checkout_whose_directory_is_removed_refuses_at_once`. - Red at `173f3c8`: 4 failed. The three waiters exited 4 after 57-58 s with "holder pid unrecorded"; the creation waiter was still waiting 20 s after `-lanes/` went. - Green: 4 passed; the a23_lock module 28 passed; hygiene 253 passed. - Mutants C48 (the directory test made `false`), C49 (`acquire_lock` ignores the 2), C50 (a checkout still there called removed), C51 (the creation lock ignores the 2) and C52 (`lock_try` turns the 2 into 1): all KILLED at their own assertions. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 49 ++++++++++++++++++++---- tests/test_a23_lock.py | 87 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 9b2077d7..82ce46c2 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -446,7 +446,9 @@ # 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 +# 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 @@ -1411,13 +1413,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)) @@ -1657,8 +1672,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. # @@ -1682,15 +1697,29 @@ lock_adopt() { 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. 1, as for a register that is not there, and never 4: no +# other run holds anything. +lock_gone_die() { + if [ -d "$LANES_REPO" ]; then + die "$LANES_DIR, where this write's lock lives, is gone 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 + 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 @@ -15827,7 +15856,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. diff --git a/tests/test_a23_lock.py b/tests/test_a23_lock.py index 2b31ba7a..68564572 100644 --- a/tests/test_a23_lock.py +++ b/tests/test_a23_lock.py @@ -539,3 +539,90 @@ 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 this write's lock lives, is gone from the checkout {c}" 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" From 126bccddf76c40080664710ee491c1d7c6bc8c5e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 21:59:04 +0000 Subject: [PATCH 51/59] The two routing `exec` lines slice 1b added, `cs_recheck`'s re-run and `cp_replay`'s replay, carry `# shellcheck disable=SC2093` with their reason in 1a's form, so lint.yml's baseline passes on lanes-edit.sh (#198, slice 1b) Lane openRepoTools-3's #198 comment 6341440689: #277's lint job and act 2 collide on SC2093. Slice 1a's line (`lane_checkout_enter`) took the disable in `6ca3158`; these are 1b's own (`10169f3`, `ef1377f`). - Before: shellcheck 0.9.0 with lint.yml's baseline flags from `935eaef` (`--severity=warning --exclude=SC1007,SC2034,SC2069,SC2088,SC2128,SC2141,SC2154,SC2178,SC2209,SC2318`) exits 1 with SC2093 at lines 2333 and 3354. - After: exit 0, no output. Hygiene 253 passed. Comments only. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lanes-edit.sh b/lanes-edit.sh index 82ce46c2..85c44e5a 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2330,6 +2330,9 @@ cs_recheck() { # 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 } From 59551dd97f0d7efed966fa0fd357b65c186ed530 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 02:50:59 +0000 Subject: [PATCH 52/59] A write whose `LANES_REPO` names a directory below its checkout's top level refuses with 2 before any edit, naming the top level and the register, instead of making its edit and dying 6 at `git add` (#198, slice 1b) Found by slice 2a's writer w2a-c10 at `commit_publish`'s `git add`. - Every pathspec the helper 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. With `LANES_REPO=/handoffs` and `LANES_FILE=/lanes/LANES.md`, every writer made its edit ("line 5 rewritten", "1 line appended") and then died 6, "pathspec 'lanes/LANES.md' did not match any files" / "git add failed", leaving the edit uncommitted. `commit`'s pending hand edit went unseen by S3's step 7 (its `git diff HEAD -- lanes/LANES.md` asked about `handoffs/lanes/LANES.md`), and every printed recovery named the wrong path. `874e020`'s `commit_push` makes the same `git add`: not new with this slice. - `checkout_sync` now asks `git rev-parse --show-prefix` right after its not-a-checkout test, under the lock and before the verb reads its file: a prefix refuses (2) "LANES_REPO names inside the checkout , not its top level", with the recovery "name the checkout itself: LANES_REPO= LANES_FILE=", then the re-run. - Chosen over top-level pathspecs (`git -C ` or `:/`): dozens of `git -C "$LANES_REPO" ... -- ` calls and printed commands would each need it, and one missed in step 7 is ratified choice 9's capture again. Not a silent rewrite of `LANES_REPO` to its top level either: without `LANES_FILE`, the register's default comes from `LANES_REPO`, so the recovery names both. `LANES_NO_GIT=1` runs no git and is unchanged. - Exit row 2, in the header and in the manual, names the cause. New (guard module): `test_a_lanes_repo_inside_the_checkout_refuses_before_any_edit` for all nine writing verbs, and `test_the_top_level_the_refusal_names_carries_the_hand_edit`, which follows the printed recovery word for word: the same `commit` carries the hand edit and publishes it. - Red at `126bccd`: 10 failed, every verb exit 6 at "git add failed" after its edit. - Green: 10 passed; the guard module 150 passed; hygiene 253, skill_shell_blocks 6, test_roots 39 passed and 1 skipped. - Mutant C53 (the prefix test made `false`): KILLED. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 2 +- lanes-edit.sh | 19 ++++++++++++ tests/test_a23_guard_refusals.py | 51 ++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 9a09136f..44dd0df2 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1218,7 +1218,7 @@ else. |---|---| | 0 | done — and for a write, **confirmed**: the commit was read back on origin before the writer said `pushed` (Amendment 23 S4) | | 1 | environment — no register, no writer | -| 2 | refusal — bad arguments, the object is held, an unknown alias, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them); and, Amendment 23 S3, every writer with nothing written, where its checkout 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, or a fast-forward git refused; the withdrawn `--sweep`; the publish act over a stack origin has moved past (S4); and `claim` or `add-row` meeting origin moved under its own checks on each of six attempts, before any edit (S3, S4) | +| 2 | refusal — bad arguments, the object is held, an unknown alias, or `commit` refusing to publish local commits that are not register or handoff writes, from a checkout whose HEAD is off the branch, or that it cannot read (#194; it pushes none of them); and, Amendment 23 S3, every writer with nothing written, where its checkout 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, or a fast-forward git refused; a `LANES_REPO` naming a directory below its checkout's top level, from which git would read the write's pathspecs, before any edit; the withdrawn `--sweep`; the publish act over a stack origin has moved past (S4); and `claim` or `add-row` meeting origin moved under its own checks on each of six attempts, before any edit (S3, S4) | | 3 | **pushed, not confirmed: ``** (Amendment 23 S4). 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 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 | | 5 | an edit moved more than one line and was refused | diff --git a/lanes-edit.sh b/lanes-edit.sh index 85c44e5a..b0963346 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -461,6 +461,8 @@ # (`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 @@ -2361,6 +2363,23 @@ checkout_sync() { # [ 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" diff --git a/tests/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py index 3ed6d798..8b007c4a 100644 --- a/tests/test_a23_guard_refusals.py +++ b/tests/test_a23_guard_refusals.py @@ -815,3 +815,54 @@ def test_a_routed_sweep_names_its_runners_lane_in_the_publish_act(box: Box): 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" From 9137569decd6ec8837fc5ada34953ec6fbef42f1 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 20:31:11 +0000 Subject: [PATCH 53/59] A sidecar act whose `-lanes/` folder is moved aside while it waits for the workstation's lock names that folder and its restore, a move back, instead of W's `lanes/` and a `git checkout` that restores nothing (#198, slice 1b) 701 of lane openRepoTools-3's re-check of #307's round 2. - `lock_gone_die` (360) told only "the checkout is gone" from "it is still there": wherever `$LANES_REPO` was still a directory it said `$LANES_DIR` had gone and gave `git -C checkout HEAD -- $LANES_DIR`. The workstation's sidecar lock lives in `-lanes/`, which is no part of any checkout, so where that folder went while a sidecar act waited, W's `lanes/` was still there and the restore did nothing. - Both branches now say what went in `mutex_try`'s words (`MX_WHY`). The git restore is given only where the lock is the checkout's own (`$LANES_DIR/.lanes-edit.lock`); any other lock's folder is said to be no part of the checkout, with its restore, a move back, and that a re-run with it still gone makes it again, empty. Exit 1, unchanged. New (a23_lock module): `test_a_sidecar_act_whose_lock_directory_is_moved_aside_names_it_and_a_restore_that_works` (follows the restore it names: moved back, the act runs, 0). The `[its-lanes-directory-removed]` case of the 360 test now also asserts the `git checkout` restore it prints. - Red at `59551dd`: 1 failed (W's `lanes/` named). - Green: 5 passed with the 360 cases; the a23_lock module 29 passed; hygiene 253 passed. - Mutants C54 (the sidecar's branch made `false`), C55 (the lock test dropped) and C50 re-anchored to the new condition: all KILLED at their own assertions. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 12 +++++++++--- tests/test_a23_lock.py | 44 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index b0963346..6d7d7f6b 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1709,11 +1709,17 @@ lock_try() { # [] — 0 taken, 1 not, 2 its direc # 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. 1, as for a register that is not there, and never 4: no -# other run holds anything. +# 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 "$LANES_DIR, where this write's lock lives, is gone 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 + 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 } diff --git a/tests/test_a23_lock.py b/tests/test_a23_lock.py index 68564572..32ddc793 100644 --- a/tests/test_a23_lock.py +++ b/tests/test_a23_lock.py @@ -589,7 +589,8 @@ def test_a_writer_waiting_on_a_checkout_that_is_removed_refuses_at_once(box: Box 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 this write's lock lives, is gone from the checkout {c}" in err, err + 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 @@ -626,3 +627,44 @@ def test_a_writer_waiting_to_create_a_checkout_whose_directory_is_removed_refuse 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() From 9785ad975e9541e296397ee5d9255176e75ae24e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 20:43:28 +0000 Subject: [PATCH 54/59] S6.3's archive, as `cp_under` and the publish act print it, names the lane the write was given, or none where no lane is named, never a literal ``, so the archive and the drop run as printed (#198, slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, a `set-row-state ` that lost its push race over a commit origin lacks), the branch read `archive/-stranded-…`: git made a branch of that name, and a shell reads `` as a redirection. - `checkout_sync` keeps the lane it was given in `CS_LANE` (set at load to empty, never from the environment). The archive names LANES_LANE, or that lane; where neither names one (`commit` with no LANES_LANE, without the opt-in: under it a write names its lane, S2) it is `archive/stranded-`, with no lane segment. New (replay module): `test_s63s_archive_and_drop_run_as_printed[by-argument|by-nobody]` runs the printed archive and drop with `bash -c`, word for word: the archive branch on origin holds the stack, and HEAD reaches origin's tip. - Red at `9137569`: both cases print `archive/-` ([by-nobody] run on that helper once it went off the opt-in). - Green: 6 passed with the publish act's and `cp_under`'s cases; the replay module 78 passed; hygiene 253 passed. - Mutants C56 (`CS_LANE` not kept), C57 (the no-lane form prints `` again) and C22 re-anchored to the new lane line: all KILLED at their own assertions. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 14 +++++++-- tests/test_a23_publish_replay.py | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 6d7d7f6b..c9c0b311 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1309,6 +1309,9 @@ CS_RECHECK=0 # 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: @@ -2364,6 +2367,7 @@ cs_removed_unique() { # checkout_sync() { # […] 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" @@ -3452,9 +3456,15 @@ cp_publish_moved() { } # 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. +# 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() { - note " git -C $LANES_REPO push origin HEAD:refs/heads/archive/${LANES_LANE:-}-stranded-$(date -u +%Y%m%dT%H%MZ) # the archive, on origin" + 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." } diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 2ac77638..cbcbce00 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -2182,3 +2182,57 @@ def test_a_file_the_write_edits_in_place_deleted_on_origin_keeps_the_commit_exit 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" From 0ddde5e92c115a617fe3a8d4dad5850e29e5a93b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 20:54:19 +0000 Subject: [PATCH 55/59] A claim's re-run posts Rule 1's reads whole, from a file attempt 1 keeps in the checkout's git directory, not the 32,768-byte copy that crosses the `exec`; the environment string keeps its cap (#198, slice 1b) 706 of lane openRepoTools-3's re-check of #307's round 2. - 334 capped the copy of Rule 1's reads that crosses a re-run's `exec` at 32,768 bytes, since Linux caps one environment string at 128 KiB. The re-run's claim comment took its "three reads" block from that copy, so with 1,800 comments the posted comment held about 260 lines and the truncation marker. - Where the reads are over the cap, attempt 1 now also writes them whole to `/lanes-edit/claim-reads--.txt`, beside the carried hand-edit copies, and its name crosses the `exec` with the other claim markers (`LANES__CLAIM_READS_FILE`, read and unset at load). The re-run prints the capped copy as before and posts the file. The file goes in `cleanup`, when the claim's last run ends, however it ends (`exec` runs no EXIT trap). Where it cannot be written, the re-run posts the capped copy, marked, as before. New (replay module): `test_a_claims_re_run_posts_the_whole_reads_not_the_capped_copy`; the `gh` stub of `_network_reads_logged` keeps a posted comment's body when asked (`posted=`). - Red at `9785ad9`: 1 failed (the body held the truncation marker and not `bulk-01800`). - Green: 5 passed with 284's and 334's cases; the replay module 79 passed; hygiene 253 passed. - Mutants C58 (the body cites the capped copy), C59 (the file never read), C60 (its name does not cross the `exec`) and C61 (the file outlives the claim): all KILLED at their own assertions. C16 and C17 (284's markers) re-anchored to the env line, which no longer ends it: KILLED. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 35 +++++++++++++++++++++++++----- tests/test_a23_publish_replay.py | 37 +++++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index c9c0b311..056f1528 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -1167,18 +1167,22 @@ LANES_ROUTED="${LANES__CHECKOUT_LANE:-}" # the lane, in the re-run process onl # #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`). Not `LANES__PID`: an unrouted write has no routing +# 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 +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). 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 @@ -1620,6 +1624,9 @@ 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_publish` recorded a push # that landed - or, opensoft/openRepoTools#161, where an inventory writer @@ -17911,7 +17918,8 @@ EOF 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=$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 @@ -18052,8 +18060,17 @@ EOF 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 @@ -18063,6 +18080,13 @@ EOF 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" @@ -18078,7 +18102,8 @@ EOF CLAIM_OBJ="$obj"; CLAIM_LANE="$lane"; CLAIM_SKIP="$takeover_from"; CLAIM_SKIP_DEAD="$takeover_dead" CLAIM_ABANDON_REVIVED="" 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=$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`). @@ -18102,7 +18127,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" diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index cbcbce00..0ce12688 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -1428,21 +1428,25 @@ def test_an_add_row_whose_retirement_a_reader_fetched_in_after_S3s_compare_refus # 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) -> tuple: +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. - Nothing reaches the network. Returns the log and the env that puts both - first and lets the claim ask GitHub.""" + 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" + @@ -2236,3 +2240,30 @@ def test_s63s_archive_and_drop_run_as_printed(tmp_path: Path, named: str): 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) From 458cdde04c3d4e34bd79ba84ac4f5a58883f5a52 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 21:38:52 +0000 Subject: [PATCH 56/59] The recovery commands S4's refusals print name origin's tracking ref in full, `refs/remotes/origin/`, so a local branch named `origin/` no longer stands in for origin in a `cherry` or a `reset --keep` run as printed (#198, slice 1b) 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. - `git cherry -v origin/ HEAD` in `cp_not_confirmed`'s LOOK line, `cp_rewritten`'s read by content and the publish act's origin-unmoved refusal; `reset --keep origin/` in `cp_replay`'s keep-if-gone and six-attempts notes and in the migration's case-only rename. A local branch named `origin/` wins the short name over the tracking ref, so the LOOK line listed a landed commit as missing, and the reset would have dropped to that branch. - `cp_rewritten`'s `log --oneline -5 origin/`, in the same note block, is spelled the same way. README row 6 says `git cherry -v refs/remotes/origin/main HEAD`. - Left alone on purpose: slice 1a's two (`lane_checkout_verify` and W's ahead note), and README's lines 105, 108 and 242 (not row 6). New (replay module): `test_an_unconfirmed_pushs_look_line_reads_origin_past_a_local_branch_named_origin_main` runs the LOOK line with `bash -c`, word for word, past a shadow `origin/main` branch behind origin. The bound, unconfirmed, publish-act-unmoved [head-is-the-tip], rewritten and A37 [deletes-it] cases, and the guard's migration case, assert the new words. - Red at `0ddde5e`: 7 failed at their 703 assertions (the LOOK case listed the landed commit as `+`), 1 passed (the migration's [untracked], which prints no reset). - Green: 8 passed; the replay module 80 passed, the guard module 150 passed, hygiene 253 passed. - Mutants C62-C68, one per changed line, each back to the short name: all KILLED at their own assertions. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- docs/README-lanes.md | 2 +- lanes-edit.sh | 17 +++++++++-------- tests/test_a23_guard_refusals.py | 2 +- tests/test_a23_publish_replay.py | 31 ++++++++++++++++++++++++++++++- 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/README-lanes.md b/docs/README-lanes.md index 44dd0df2..c5cc0aee 100644 --- a/docs/README-lanes.md +++ b/docs/README-lanes.md @@ -1222,7 +1222,7 @@ else. | 3 | **pushed, not confirmed: ``** (Amendment 23 S4). 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 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 | | 5 | an edit moved more than one line and was refused | -| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): a sixth push refused because origin moved (Amendment 23 S4's bound; 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 (`git cherry -v origin/main HEAD`). 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 until the lane publishes or archives it | +| 6 | `git add` or `commit` failed — or the commit is **kept on HEAD and named** (sha, subject, `git show`): a sixth push refused because origin moved (Amendment 23 S4's bound; 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 (`git cherry -v refs/remotes/origin/main HEAD`). 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 until the lane publishes or archives it | | 7 | another act got there first and this one wrote nothing: `claim`'s `CLAIM-LOST` (another lane's claim landed first), and `set-lane-state` / `set-lane-tree`'s lifecycle fence (openRepoTools#91, below) | | 9 | `CLAIM-LOST` — issue #30's own dead-lane verdict could not be reconfirmed before a `--force` takeover's push landed: the source lane resumed, a live session now backs it up, or that could not be read at all (`claim` only). Never 7 — that code is a RIVAL's claim, and this is the same lane the takeover was granted over | | 10 | `lane-state`: the lane's lifecycle snapshot is there and could not be read, or is of this schema and not a whole record, so nothing can be read out of it as a snapshot (openRepoTools#91 and #115, below) — never the 8 that means it has none. It was 9 until #61 spent 9 on `claim`. `lane-trees`: the lane's `trees` directory is there and could not be listed (#157 item 4e) — never the 8 that means it recorded no tree | diff --git a/lanes-edit.sh b/lanes-edit.sh index 056f1528..a768dab0 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -3208,7 +3208,8 @@ CDC_EOF # S4.3 — A PUSH IT CANNOT CONFIRM IS NEVER SUCCESS. The commit stays on HEAD; # the next write's S3 finds it by content either way: published, it reads as # nothing to do; not published, as an unpushed commit, refused until the lane -# publishes or archives it. +# publishes or archives it. Every command these notes print names the tracking +# ref in full, since a local branch named `origin/` 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 @@ -3219,7 +3220,7 @@ CDC_EOF # 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 origin/$LANES_BRANCH HEAD" + 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 || :)" @@ -3237,8 +3238,8 @@ cp_not_confirmed() { # # 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 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 origin/$LANES_BRANCH" + 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}" } @@ -3319,13 +3320,13 @@ cp_replay() { # ! 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" - 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 origin/$LANES_BRANCH\` run." + 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 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" - 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 origin/$LANES_BRANCH\` run." + 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 "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" @@ -3445,7 +3446,7 @@ cp_publish_act() { [ "$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 origin/$LANES_BRANCH HEAD" 6 + 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 @@ -12763,7 +12764,7 @@ EOF # commit to archive: it is unstaged, and the copy it leaves is # the NOT TRACKED state. if [ "$NO_GIT" != 1 ] && git -C "$LANES_REPO" cat-file -e "HEAD:$msc_rel" 2>/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 origin/$LANES_BRANCH, and re-run; the lane's next write renames the log once, inside its own commit" + 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 diff --git a/tests/test_a23_guard_refusals.py b/tests/test_a23_guard_refusals.py index 8b007c4a..fb2752d9 100644 --- a/tests/test_a23_guard_refusals.py +++ b/tests/test_a23_guard_refusals.py @@ -450,7 +450,7 @@ def test_a_migration_dry_run_names_a_rename_that_never_landed_and_never_says_pul 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 origin/main" 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() diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 0ce12688..92a73079 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -305,6 +305,7 @@ def test_bound_keeps_the_commit_on_head_exit_6_and_next_write_refuses(box: Box): 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 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() @@ -447,6 +448,7 @@ def test_unconfirmed_push_exits_3(box: Box): 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"] @@ -989,6 +991,7 @@ def test_a_publish_act_push_refused_with_origin_unmoved_reads_head_exit_6(box: B 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 @@ -1606,7 +1609,8 @@ def test_a_commit_another_writer_rewrote_is_named_as_rewritten_exit_6(box: Box): 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 origin/main HEAD" in result.stderr + 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"], @@ -2174,6 +2178,7 @@ def test_a_file_the_write_edits_in_place_deleted_on_origin_keeps_the_commit_exit 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 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(), \ @@ -2267,3 +2272,27 @@ def test_a_claims_re_run_posts_the_whole_reads_not_the_capped_copy(box: Box): 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) From f97723cc086e63942e5e40b7d2a215774720de69 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 21:55:44 +0000 Subject: [PATCH 57/59] Where origin moved, the bound's, the keep-if-gone and `cp_not_head`'s notes give S6.3's recovery, the list and the STOP line and then the archive, instead of offering first the publish act, which refuses there (#198, slice 1b) 705 of lane openRepoTools-3's re-check of #307's round 2: the flaw 343 fixed in `cp_under` was left in four sibling notes. `cp_replay`'s six-attempts and keep-if-gone notes, and `cp_not_head`'s HEAD-still and on-top notes, offered `LANES_LANE= lanes-edit.sh commit ""`, the publish act, first. Origin had moved past HEAD in each, so the publish act refused with 2 ("moved past"), a detour. - New `cp_recovery_steps []`: the lead ("every later write ... refuses until it is archived and dropped here: origin has moved past HEAD, so the publish act refuses (2)"), then `cp_publish_moved`'s own RECOVERY line, its list line with the STOP line, and `cp_archive_steps`. `cp_publish_moved` calls it with no lead, so its words are unchanged. - The bound and the keep-if-gone note call it. `cp_not_head` calls it where it is given no fourth argument (origin moved). With a fourth (origin unmoved: `commit_publish`'s refusal, the publish act's, a push not confirmed) the publish act is offered as before. - `cp_rewritten`'s offer is unchanged: a rewritten commit sits on origin's tip, so the publish act is a fast-forward there. Tests (replay module): `_recovery_is_s63s_archive` asserts the lead, RECOVERY, the list, the STOP line, the archive and the drop in that order, and no publish-act offer. It runs in the bound, the A37 [deletes-it] case, the drop refused over a held ref lock (HEAD still this write's commit), and three on-top cases. The origin-unmoved on-top case asserts the publish act is still offered and no archive. - Red at `458cdde`: 6 failed at the order assertion; 2 passed (the origin-unmoved case and the publish act's own refusal, the controls). - Green: 8 passed; the replay module 80 passed, the guard module 150 passed, hygiene 253 passed. - Mutants C69-C75 (each call site, both branches of the fourth-argument test, the lead, and the publish act's call): all KILLED. C21 and C33, whose list line moved into the helper, re-run: KILLED. 703's C65 and C66 are retired: their lines are gone, and C69 and C70 cover the notes that replaced them. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 36 ++++++++++++++++++++++++++------ tests/test_a23_publish_replay.py | 25 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index a768dab0..37a0724f 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -3256,7 +3256,10 @@ cp_rewritten() { # [, as `cp_not_head` tak # 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. +# 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" @@ -3268,7 +3271,12 @@ cp_not_head() { # [ $SELF commit \"\", the publish act — or archived (S6.3)." + 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). @@ -3276,7 +3284,11 @@ cp_not_head() { # [ $SELF commit \"\", the publish act — or archived (S6.3)." + 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 @@ -3320,13 +3332,13 @@ cp_replay() { # ! 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" - 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." + 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" - 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." + 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" @@ -3457,10 +3469,22 @@ cp_publish_moved() { # 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 - 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 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 diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 92a73079..aed7145d 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -306,6 +306,7 @@ def test_bound_keeps_the_commit_on_head_exit_6_and_next_write_refuses(box: Box): 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() @@ -375,6 +376,8 @@ def test_a_push_refused_with_origin_unmoved_names_a_commit_on_top_exit_6(box: Bo 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) @@ -1140,6 +1143,7 @@ def test_a_commit_made_on_top_before_the_replay_is_kept_exit_6(box: Box): 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" @@ -1700,6 +1704,7 @@ def test_a_commit_landing_before_the_replays_drop_is_never_dropped_exit_6(box: B 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" @@ -1736,6 +1741,7 @@ def test_a_drop_refused_for_another_reason_names_git_and_no_commit_on_top_exit_6 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" @@ -1820,6 +1826,7 @@ def test_a_commit_landing_just_after_this_writes_commit_is_never_taken_for_it_ex 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 @@ -1843,6 +1850,23 @@ def _archive_comes_after_the_list_and_the_stop_line(err: str, base: str, mine: s 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 @@ -2179,6 +2203,7 @@ def test_a_file_the_write_edits_in_place_deleted_on_origin_keeps_the_commit_exit 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(), \ From 80ae89e731e8a70d73f8ba90a64f7a1821b8d45d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 22:07:20 +0000 Subject: [PATCH 58/59] After a lost race, a `commit` whose re-run carry no longer applies names every copy of the hand edit it keeps, attempt 1's as well as the re-run's, instead of the last one alone (#198, slice 1b) 707 of lane openRepoTools-3's re-check of #307's round 2, the small follow-up the body disclosed with 393. - A `commit` that loses a push race replays its carry, which saves the hand edit a second time. Where the peer that won changed the same row, the re-run's carry no longer applies, and the refusal (2) kept both copies but named only `CS_CARRY_DIFF`, the last. Lane-end's sweep holds the checkout on either one. - New `cs_earlier_copies` reads 393's record (`CP_CARRY_COPIES`, which crosses the replay's `exec`) and prints every copy but the last. The refusal says "KEPT in (and, as this write saved it before its push lost the race, in )". A replay in the same second reuses the name (704), so that copy is the last one and is named once. New (replay module): `test_a_lost_races_refused_re_run_carry_names_every_copy_it_keeps`. The refusal line names each copy once, by the names the carry notes printed and the files kept, so it holds for one name or two. - Red at `f97723c`: 1 failed (attempt 1's copy not named), and the single-copy refusal case passed. - Green: 7 passed with the refusal cases; the replay module 81 passed, the guard module 150 passed, hygiene 253 passed. - Mutants C76 (the earlier copies are not asked for) and C77 (the last copy named twice): KILLED at the naming assertion. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- lanes-edit.sh | 19 ++++++++++++++++++- tests/test_a23_publish_replay.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lanes-edit.sh b/lanes-edit.sh index 37a0724f..86804b5a 100755 --- a/lanes-edit.sh +++ b/lanes-edit.sh @@ -2371,6 +2371,22 @@ cs_removed_unique() { # 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="" @@ -2600,7 +2616,8 @@ CS_EOF 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_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 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' ' ')" \ + 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 diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index aed7145d..8ca93f40 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -780,6 +780,34 @@ def test_a_lost_races_two_copies_of_the_hand_edit_are_both_removed_once_origin_h _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 From 4b79d6718f19973e3266cac4e3988de23e51a72e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 24 Oct 2026 14:40:58 +0000 Subject: [PATCH 59/59] The two-copies test asserts one removal note per copy the run recorded, not two distinct names, and a `[same-second]` case gives attempt 1 and its replay one second, so the test no longer depends on the clock (#198, slice 1b) 704 of lane openRepoTools-3's re-check of #307's round 2. - A copy's name carries the time to the second. 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, so it would have failed there. The replay's three-second pause hides it unforced. - `test_a_lost_races_two_copies_of_the_hand_edit_are_both_removed_once_origin_has_it` is now `[unforced|same-second]`. `[same-second]` puts a `date` first on PATH that answers the copies' own format with one fixed second and hands every other call to the real `date`. Both cases assert two recorded copies, one removal note per recorded copy, and no copy left; `[same-second]` also asserts the two names are one. Test-only: `lanes-edit.sh` is not changed. - Red at `80ae89e`, with the shim and the old assertion: `[same-second]` failed at the distinct-names assertion (1 == 2), `[unforced]` passed. - Green: 3 passed (both cases and 707's refusal case); the replay module 82 passed, hygiene 253 passed. - Mutants C37, C38 and C39 (393's record, its export across the replay's `exec`, and its read at load), run on the whole test and on `[same-second]` alone: all KILLED at the removal-note assertion. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 --- tests/test_a23_publish_replay.py | 39 +++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/tests/test_a23_publish_replay.py b/tests/test_a23_publish_replay.py index 8ca93f40..1115ed3e 100644 --- a/tests/test_a23_publish_replay.py +++ b/tests/test_a23_publish_replay.py @@ -753,14 +753,35 @@ def test_commit_replay_keeps_the_hand_edit(box: Box): _settled(box, c) -def test_a_lost_races_two_copies_of_the_hand_edit_are_both_removed_once_origin_has_it(box: Box): +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 under a new name, 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.""" + 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") @@ -770,10 +791,12 @@ def test_a_lost_races_two_copies_of_the_hand_edit_are_both_removed_once_origin_h 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, result.stderr - assert len(set(copies)) == 2, "attempt 1 and its replay each saved a copy of its own" + 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 f"its copy is removed: {copy}" in result.stderr, copy 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()