diff --git a/docs/PICKUP_RECOVERY_PROTOCOL_DESIGN_20261020.md b/docs/PICKUP_RECOVERY_PROTOCOL_DESIGN_20261020.md new file mode 100644 index 000000000..b43323271 --- /dev/null +++ b/docs/PICKUP_RECOVERY_PROTOCOL_DESIGN_20261020.md @@ -0,0 +1,375 @@ +# Pickup recovery: a protocol design and a fence test (20 October 2026) + +Status: a design note and one test, written as the isolated design stage that the +coordinator's disposition +[#275/6281287486](https://github.com/d6g8k5htny-coder/main/issues/275#issuecomment-6281287486) +named for proposal +[#275/6276723458](https://github.com/d6g8k5htny-coder/main/issues/275#issuecomment-6276723458). +It changes no rule, activates nothing and releases nobody's claim. The +[current workflow](../governance/OP-WORKFLOW-20260930.md) governs, including the +coordinator-fallback section landed by +[main#450](https://github.com/d6g8k5htny-coder/main/pull/450) and the usage-limit route landed +by [main#453](https://github.com/d6g8k5htny-coder/main/pull/453); this note changes neither. +Author: Anthropic Claude, session `session_01NMeKEismAyeqgdB4sy2NJU` (Claude01N), as delegated +AI work for Dylan Roy. Scientific effect NONE. +Amendment author: OpenAI/Codex `/root/main456_successor_integrator_1020`, under the +separate source stage [6293498638](https://github.com/d6g8k5htny-coder/main/pull/456#issuecomment-6293498638), +addressing five Ready-review findings on head `4c5ab1f7`. Original source authorship above +is retained. This amendment is still a design record, not operating-rule adoption. +Further documentary amendment by the same actual OpenAI/Codex invocation under new stage +[6294231132](https://github.com/d6g8k5htny-coder/main/pull/456#issuecomment-6294231132) +addresses four Ready-review findings on head `43b5337a`; the prior source stage is closed. + +## 1. The problem, with its evidence + +A pickup is a comment that names a performer, an exact source and a scope. Some performers +are stateless hourly invocations under a shared label (for example the xAI workers +GROK-H01 to H04). When such an invocation ends, its pickup keeps its claim, but no execution +holds it any longer, and the next invocation under the same label states, correctly, that it +holds no prior assignment. Five cases between 15 and 19 October: + +| Pickup | Scope | Claimed | Outcome | +|---|---|---|---| +| [389/6188676525](https://github.com/d6g8k5htny-coder/main/pull/389#issuecomment-6188676525) | main#389 main text, slice M1 | 15 Oct 09:15Z | no delivery; ended by owner authority on 19 Oct 21:53Z | +| [389/6191845893](https://github.com/d6g8k5htny-coder/main/pull/389#issuecomment-6191845893) | main#389 main text, slice M2 | 15 Oct 12:17Z | same | +| [389/6182815146](https://github.com/d6g8k5htny-coder/main/pull/389#issuecomment-6182815146) | main#389 supplement draft 9 | 15 Oct 02:11Z | same | +| [389/6184901584](https://github.com/d6g8k5htny-coder/main/pull/389#issuecomment-6184901584) | main#389 supplement draft 9 | 15 Oct 05:08Z | same | +| [419/6248600576](https://github.com/d6g8k5htny-coder/main/issues/419#issuecomment-6248600576) | main#447 source review | 18 Oct 10:09Z | open as of 20 Oct 02:00Z | + +The cost: the main#389 head was frozen for four days under the practice that an open read +freezes the head, and main#447's mathematical review could not start because other lanes +deferred to the pickup. The resolution of the first four needed a direct question to the +owner ([389/6278216270](https://github.com/d6g8k5htny-coder/main/pull/389#issuecomment-6278216270)). + +## 2. What the coordinator corrected + +The proposal's four clauses are withdrawn as written. The design below keeps these +constraints from the disposition: + +1. A later invocation under a shared label is not the earlier performer. Its statement that + it holds nothing neither releases the earlier claim nor proves that the earlier execution + ended. Historical reassignment is recorded as authorized supersession or recovery, with + the predecessor's closeout preserved as UNKNOWN; it is never described as the predecessor's + own release. +2. A release-on-exit term is usable only when the exact invocation and a trustworthy + termination event are observable; otherwise it is an unfulfilled condition, not a + timeout. An unavailable executor is not known to be dead. +3. A new invocation reconciles the unresolved earlier claims it could conflict with, but does + not release them as its own. A lookback window is a convenience, not a cutoff. +4. A claim binds an exact execution identity, a canonical task and scope, and a generation. + Recovery uses the ordered separate authority, personal acknowledgement and fresh + post-acknowledgement activation records in section 5 point 4. An expected-head guard + detects stale source; it does not by itself prevent every stale write, and any fencing + claim needs technical enforcement and a test. +5. Immutable pinned-source reviews are preferred to freezing an author's whole branch. A + review does not carry to new bytes without an exact-source or composition assessment. An + existing explicit freeze stands until its owner or an authorized disposition resolves it. + +## 3. What is technically prevented, and what is only discouraged + +`tests/test_pickup_fence.py` builds a bare repository, lets a second worker (B) move a +branch, and records which pushes by the first worker (A) succeed, from a stale view and from +a fresh one. The three lease cases of the nonauthor read +[456/6281763561](https://github.com/d6g8k5htny-coder/main/pull/456#issuecomment-6281763561) +are included, along with a ninth deletion/recreation case. That case first verifies that an +ordinary divergent push is rejected, then shows deletion plus ordinary recreation succeeds. +It exercises local Git only, not GitHub's event stream or protection settings. Standard +library and `git` only; the full focused test runs in a few seconds. + +| Write by A | Result | Enforced by | +|---|---|---| +| stale view: `git push origin work` after the branch moved | rejected (`[rejected] ... fetch first`) | the transport's fast-forward rule | +| stale view: `git push --force-with-lease=refs/heads/work:` after the branch moved | rejected (`stale info`) | the explicit lease: the remote ref must equal the stated old value | +| stale view: `git push --force-with-lease origin work`, no expected value, after a fetch that A never integrated | **accepted**, B's commit is overwritten | nothing: the implicit lease checks the remote-tracking ref, which the fetch already moved | +| the same push with `--force-if-includes` | rejected | the implicit lease with `--force-if-includes`: A's view must contain the remote ref | +| fresh view: `git push --force-with-lease=refs/heads/work:`, a fast-forward | accepted | the lease matches; the push would also pass without it, being a fast-forward | +| fresh view: the same explicit current lease, a deliberate non-fast-forward rewrite of B's commit | **accepted**, B's commit is replaced | nothing: the lease fences stale writers, not deliberate conflicting writes | +| `git push --force origin work` | **accepted**, B's commit is overwritten | nothing | +| A deletes the branch, then recreates it at its divergent candidate with an ordinary push | **accepted**, B's head is discarded | nothing in the unprotected bare-repository setup; deletion/creation are separate operations | +| B records a claim on the branch; A pushes anyway | prevents nothing | nothing | + +The tests separate the normal fast-forward restriction from a lease's forcing behavior. +Ordinary branch updates use a non-forcing `git push`; do not add `--force-with-lease` to +normal pushes. Check the current remote head and preserve its ancestry in the candidate. +For API updates, retain a supported expected-old-head guard together with `force=false`; +the guard does not require permission to discard history. A separately authorized intentional +history rewrite on an unprotected branch is a different action: an explicit +`--force-with-lease=:` can reject a stale expectation, but a matching +lease still permits a non-fast-forward rewrite, whether intended or accidental. It is not a +custody handoff or a safe default. The implicit lease after an unintegrated fetch has the gap +shown in the table; `--force-if-includes` addresses that gap without making a rewrite ordinary. +These distinctions follow the [Git push documentation](https://git-scm.com/docs/git-push). + +A claim record prevents no push. On GitHub, protection that actually binds the acting user +can reject force pushes; branch deletion and recreation are separate replacement paths and +need the controls and audit coverage of section 6. The merge endpoint's `sha` and the +update-branch endpoint's `expected_head_sha` reject a moved expected head. None of these +GitHub guards is exercised by this local bare-repository test; they are documented separately. + +What no transport guard prevents: two performers reviewing the same pinned source (duplicate +work, not a conflicting write), and a successor acting on a scope whose predecessor is not +known to have stopped (a custody conflict, not a byte conflict). Those are protocol matters. + +## 4. The six scenarios + +In each scenario, A is the pickup's performer, B is a later actor, and the source is pinned by +an exact commit in the pickup. + +**S1. Simultaneous claimants.** A and B post pickups for the same scope within minutes, each +after a contender check that did not see the other. Records: two pickups, both naming the +head. Who acts: by the workflow, the earlier pickup holds the scope; the later one withdraws +in the task thread, or the two split the scope explicitly there. Prevented: nothing at the +transport level, since neither has written. Discouraged: continuing both. Design: the pickup +states its contender-check time; the later claimant, on seeing the earlier record, posts a +withdrawal or a split before any write. A reviewer's delivery that overlaps an earlier +pickup is still a delivery; the coordinator decides which review binds. + +**S2. The original worker returns after supersession.** A's pickup was superseded by an +authorized recovery record (as the four main#389 reservations were), and a later execution +under A's label returns with partial work. Records: the pickup, the supersession, the return. +Who acts: the returning execution is not known to be A (correction 1), so it may post its +partial work as data and may post a fresh pickup, but it does not resume A's claim. Prevented: +a push to a branch whose head moved, if guarded. Discouraged: treating the return as A's +continuation. Design: a returning execution states that its continuity is unverified, links +A's record, and takes a fresh pickup only if the scope is still open. + +**S3. A delayed callback across expiry.** A's delivery arrives after a recorded supersession, +for the superseded head. Records: supersession, then A's delivery. Who acts: the delivery +stands as a review of the head it names (correction 5: a review binds pinned bytes); if the +successor's work is on the same head, the coordinator records both and decides which binds; +if the head moved, A's delivery is a review of old bytes and carries forward only with an +exact-source or composition assessment. Prevented: nothing (a comment). Design: every +delivery names the exact head it checked; a delivery for a superseded head is not rejected, +and is not promoted either. + +**S4. A crash before release.** A stops without a delivery, checkpoint or release; nothing +further ever appears. Records initially: the pickup only. No one may invent A's own release +(corrections 1–2). Applicable authority first offers a prospective supersession for a named +actual receiver and exact future scope. The receiver subsequently posts its personal ACK; +only AFTER that ACK does the coordinator freshly reconcile current refs, claims, findings +and reported in-flight work and post a separate explicit activation. The three records +cross-link in that order as specified in section 5 point 4; the limited peer fallback there +does not omit the receiver's ACK or the fresh activation. Until actual activation, the +successor does not mutate the held scope. A guarded push can reject a stale expected head, +but cannot provide this custody handoff. A missed checkpoint triggers reconciliation, not +release. "Release-on-exit" requires an observable trustworthy termination of the exact +invocation; absent that evidence, A's own closeout remains UNKNOWN. + +**S5. Ambiguous claim order.** Two pickups, each citing a contender check that predates the +other's post, with timestamps seconds apart or under clock skew. Records: the two posts with +server timestamps, which have one-second resolution, so a tie is possible. Who acts: when +the server timestamps order the posts, the later poster withdraws, as in S1; when which +record is canonical, or their order, is ambiguous or tied, both actors stop the affected +mutation and reconcile in the existing thread, and the reconciliation does not reassign +claims already released (the [current workflow](../governance/OP-WORKFLOW-20260930.md), +"Four checks bracket every claim", item 2). Design: as S1, with the tie rule; the contender check is re-run +immediately after posting, which detects the overlap. It does not close the window in which +both were posted, and nothing does; the tie rule is what handles it. + +**S6. Stale-head mutation.** B refreshes or amends a branch whose pickup names an earlier +head; A, with a stale view, tries to push. Records: the pickup names the head; the branch +moved. Prevented: A's push, if A uses a guarded push in the explicit lease form or if force +pushes are forbidden on the branch (section 3). Discouraged only: A's bare `--force`, and a +deliberate rewrite of B's refresh from a fresh view. Design: guarded pushes for every +shared branch, and branch protection against force pushes where the host allows it; the +pickup's head is what a review binds, so B's refresh does not invalidate A's review of the +old head, it makes it a review of old bytes (correction 5). + +## 5. A protocol consistent with the corrections + +1. **Identity.** A pickup names the execution (session or invocation identifier), not only a + label, and its generation under that label where the platform reuses labels. Two executions + under one label are two performers. +2. **Checkpoint, not lease-expiry.** A pickup states a checkpoint time. A missed checkpoint is + a trigger for the coordinator to reconcile; it is not a release, and no performer other + than the coordinator, the owner or the peer fallback of point 4 records the supersession. +3. **Reconciliation on start.** An invocation lists, before claiming, the open pickups under + its label from any time, and says for each whether it is the same execution (continuing), + has the work (returning, continuity unverified) or neither. It releases none of them. +4. **Separate supersession records, in order.** For an unfinished role, applicable + authority first publishes a prospective recovery offer in the existing task, naming + the actual eligible receiver, exact affected future scope/source, finite checkpoint + and retained stops, with links to the predecessor's canonical claim. The proposal + permits no mutation. The named receiver subsequently reads that exact offer and + current state and posts its own personal acknowledgement, cross-linking the offer; + no coordinator-authored acceptance, alias, silence or earlier pickup substitutes. + AFTER that acknowledgement, the coordinator freshly reconciles current refs, claims, + findings and reported in-flight work and publishes a distinct explicit activation, + linking both the offer and actual receiver acknowledgement. Changed or crossed work + stops the affected action for reconciliation; an earlier state check is not the + post-acknowledgement confirmation. Only then may the receiver take its active pickup, + link those records and recheck source/contention before acting. Preserve the + predecessor's actual work/attribution and its own closeout as UNKNOWN where unresolved; + activation is prospective authority for the named scope, not a foreign release. + Keep these actual separate records and their ordering visible in the task and + cross-linked board mirrors; one summary may index them but cannot replace them. + Applicable authority is the coordinator or owner; the limited peer fallback below + replaces only its permitted unanswered coordinator confirmation and preserves the + receiver's personal acknowledgement, fresh checks and actual separate activation. + As the [current workflow](../governance/OP-WORKFLOW-20260930.md) ("The confirmations + above assume a coordinator whose record reaches the board") provides, a fallback applies + when the coordinator is unavailable, or is answering but a platform failure keeps its + record off GitHub, and only after the specific routine confirmation was requested in + the task thread and has gone unanswered for 24 hours measured from that request. The eligible peer + is a different provider from the acting worker, neither the object's author, + predecessor nor receiver, and has not reviewed it. It independently reads the same + current refs, claims, findings and reported in-flight work, records what it verified + and that it concurs, labels the fallback confirmation and known condition, and + cross-links it on the current board. It covers only the ordinary engineering, + documentation, review and integration stages permitted by that section. It cannot + approve a manual-review route, lift an owner stop, cure or relay a platform/permission + refusal, change protections/private access, activate a security-sensitive stage or + supply scientific acceptance. A later coordinator record supersedes it for anything + not yet landed; it earns no independence credit. No absent or blocked approval is + fabricated and no denied step is routed elsewhere. Nothing here widens or narrows + that governing fallback section. +5. **Reviews bind pinned sources.** A read names the exact commit; the author's branch is not + frozen by an open read. A later revision does not inherit the read; it gets a byte-diff + readback against the read head, or a fresh read, as today. A freeze is explicit, owned and + lifted by its owner. +6. **Guarded writes without forcing normal updates.** Ordinary pushes retain Git's + fast-forward restriction, and a candidate preserves the observed remote head's ancestry. + For a supported API branch update use the expected old head and `force=false` together; + merges use `sha`, and update-branch uses `expected_head_sha`. Reserve an explicit + `--force-with-lease=:` for a separately authorized, intentional + history rewrite on an unprotected branch; a matching lease permits history loss and is + not an ordinary-update safeguard by itself. No protection is weakened and no rewrite is + authorized by this design. Protect both non-fast-forward updates and deletion where + these controls bind every relevant actor, and audit the separate replacement paths in + section 6. The guards constrain their stated transport operations, not custody or + semantic correctness. + +Point 5 is the one that would have cut the head freeze of the main#389 case from four days to +zero: the author's later revisions would have gone out as new pinned heads, each with its own +read, and the open reads would have remained reads of the heads they named. It would not have +shortened the stalled stage of section 6: the four reads would still have had no delivery +until the supersession, about four days. + +## 6. Measurement plan + +All measures use the existing canonical board/PR pickups and native repository activity, +with source IDs, server timestamps and actual observation bounds. Board mirrors are not new +pickups. Before a comparison, declare the same eligibility and terminal-event definitions, +cohort-entry windows, coordinating lanes, and a fixed follow-up duration H for both cohorts. +No value of H or new operating rule is adopted here. Every eligible pickup/scope receives +the full H of event observation from its predeclared entry timestamp, even when a duration +terminal occurs earlier. A terminal stops ONLY that pickup's stalled-duration clock; it does +not stop delivery, split, scope or duplicate-work acquisition. Predeclare the canonical +scope-entry pickup and equivalence rule for the exact pinned source/scope so later equivalent +deliveries do not restart H or become new denominator scopes. Keep observing all equivalent +deliveries through the original scope-entry plus H; a later overlapping delivery after the +first terminal still enters the duplicate endpoint. A cohort not yet fully observed is +incomplete and receives no final before/after verdict. Do not remove its slow open pickups. +Retain missing follow-up, incomplete event acquisition and uncertain event times in the +eligible cohort inventory as UNKNOWN. Do not assign H or completion to an unobserved +interval, exclude those pickups or issue a final comparison with that missingness unresolved. +These observation limits apply to all endpoints below, including deliveries after an early +duration terminal, owner interventions and scope splits. + +Before adoption or outcome inspection, prespecify stage/type strata (for example source +authoring, documentation review, engineering review and mathematical review), relevant +difficulty/dependency factors observable at entry, their classification rules, and a common +target mix with fixed weights. Compare like strata over the same H and report their +eligibility counts, exposures, missingness and outcomes separately before any aggregate. +Do not choose strata, difficulty labels or weights from observed completion times or +intervention outcomes. If the two cohorts lack comparable support in a required stratum, +or entry difficulty is missing or not comparable, retain that limitation as UNKNOWN and +withhold the target-mix aggregate; do not silently drop hard work, pool unlike stages or +substitute the raw changing mix. Fixed common weights can prevent a changed measured mix +from driving the aggregate, but do not remove unmeasured difficulty, selection, secular +changes or other confounding. Even a fully observed balanced before/after comparison is +an association, not identification of a causal protocol benefit. + +Predeclare an adoption boundary T and the cohort-entry windows before outcomes. For the +untreated baseline, require each enrolled pickup/scope's entire scheduled H to end before T, +including duplicate observation after any early duration terminal. Start the post-adoption +entry window at or after T and wait for its full H. Keep pickups whose follow-up crosses T +in an explicit crossover inventory, outside the untreated baseline, with actual exposure +and outcome uncertainty recorded; do not relabel them untreated, silently discard them +from the eligible inventory or select a convenient boundary after seeing results. +This measurement classification never withholds or delays otherwise authorized recovery. +If the prespecified isolated baseline cannot be obtained, report that the intended +comparison is unavailable rather than claim an untreated baseline or a protocol effect. + +- **Stalled-stage duration with fixed follow-up:** a terminal is the first delivery, + completed supersession activation (not its prospective offer or receiver ACK), or the + performer's own release/withdrawal (including S1/S5), with the reason retained. For every + eligible pickup record the waiting time capped at H: the elapsed + interval from its pickup post to a known terminal within H, otherwise H and an explicit + right-censored/open flag. Compare these capped waiting times over ALL eligible pickups + together with the proportion reaching + a terminal by H. Assigning H to a still-open pickup records its observed capped wait, not + a fictitious completion. It does not estimate the unbounded completion-time distribution. + A distribution over completed pickups alone is only conditional/descriptive and cannot + establish improvement. Report the open ages and the count beyond 24 hours as well. The + five historical cases above are descriptive: four terminal events at about 4 days and one + right-censored beyond 40 hours, not a comparative effectiveness estimate. +- **Duplicate work:** report the raw number of additional equivalent deliveries for one + pinned source/scope without a recorded split, its count of affected eligible pickup scopes, + and the total number of eligible pickup scopes. Continue delivery/scope observation to + that scope's original entry plus H even after the first delivery, withdrawal or + supersession ends a pickup's duration clock. Thus a first delivery followed by a later + overlapping delivery within H counts as duplicate work; it is not erased by the first + terminal. Compare affected-scope rates using the declared denominator and prespecified + comparable mix. Report any known later deliveries separately; zero observed within H + is not a claim of no future duplicates. Differing raw task volumes alone are not an + effect of the protocol. +- **Owner interventions:** report the raw number of owner interventions and the number of + eligible pickups affected, each against the total eligible-pickup denominator. One owner + action covering four pickups is one action and four affected pickups, not four independent + owner decisions. Use the same eligibility and follow-up H before and after; report cohort + sizes and exposure, not only raw counts. The five historical cases needed one intervention + for four of them; they do not establish a normalized before/after improvement. +- **Safety objective and observable replacement audit:** the objective remains no destructive + cross-performer branch replacement without a recorded handoff. The directly observable + metric is the count and details of potentially destructive branch events, not an assumed + count of performer conflicts. Collect native `force_push`, `branch_deletion` and + `branch_creation` activities for every relevant branch, protected or not, including deleted + branches. Preserve event ID, repository/ref, server time, recorded principal and available + before/after commits; mark absent fields as unavailable. Correlate deletion followed by + creation of the SAME repository/ref and reconcile the old/new head identities, even when + the pair crosses a reporting boundary. An unrelated recreated head can remove another + head without a `force_push` event. Include unmatched deletions and unresolved correlations + explicitly; do not report them as safe or silently drop them. The + [repository activity documentation](https://docs.github.com/en/rest/repos/repos#list-repository-activities) + lists these separate event types. The earlier + [456/6281763561](https://github.com/d6g8k5htny-coder/main/pull/456#issuecomment-6281763561) + verified force-push examples only; it did not establish complete replacement coverage. + + The shared GitHub principal does not identify an execution. An event's SHAs and account + alone cannot decide which performer acted, whether it was an own rewrite, or whether an + applicable handoff existed. Qualify those claims only from actual execution-specific + evidence matched to the existing canonical pickup/handoff and exact event/source identity; + retain the evidence and its limits. Otherwise label performer/handoff attribution UNKNOWN. + Report observable event counts, confirmed dispositions and unresolved/unknown cases + separately. Zero confirmed cross-performer conflicts with unknown cases is not evidence + of zero conflicts. This design installs no attribution service and invents no identity or + immutable correlation that the current records do not supply. + + On protected branches the two history-discard paths are blocked only where BOTH + non-fast-forward updates and branch deletion are prohibited for every relevant actor, + including administrators and all bypass actors; exempt actors remain subject to audit, + not an enforced-zero claim. A force-push restriction alone does not block deletion and + recreation. These protections do not prevent semantic conflicts in fast-forward changes. + The design reads or changes no settings and attests to no current protection coverage. + Activity retention and missing boundary events must be qualified during each window; + incomplete coverage is UNKNOWN, not a zero. Record the initial/final ref observations and + collect activities during the window, not only at its end. Report counts with their branch + observation exposure; compare rates only under matched coverage or a declared denominator. + +The proposed comparison therefore needs two fully observed, prespecified comparable +cohorts, an untreated baseline whose entire H precedes separately approved adoption, and +these exposure/uncertainty limits. Its reported differences remain observational +associations; balancing recorded factors alone does not establish a causal protocol effect. +A causal claim would need its own justified identification design and substantive review. +No adoption boundary, H, strata/weights or measured benefit is selected here. No live +lease-expiry experiment, measurement execution or operating-rule adoption is part of this +design. + +## 7. Reproduce + + python3 -m unittest tests.test_pickup_fence -v + python3 -O -m unittest tests.test_pickup_fence + +Both runs exercise nine tests on a checkout with `git` on the path; the test writes only inside +a temporary directory. diff --git a/tests/test_pickup_fence.py b/tests/test_pickup_fence.py new file mode 100644 index 000000000..39d075410 --- /dev/null +++ b/tests/test_pickup_fence.py @@ -0,0 +1,175 @@ +"""Which writes a stale worker can and cannot make to a shared branch. + +Standard library only. The test builds a bare repository in a temporary directory, clones it +twice (worker A and worker B), lets B move the branch, and then records which of A's pushes +succeed. It is a demonstration of what the transport actually prevents, as opposed to what a +claim record only discourages; it is not a model of GitHub's guards, which are documented +separately (the merge endpoint's ``sha`` parameter, the update-branch endpoint's +``expected_head_sha``, and branch protection against force pushes). + +The three lease cases of the nonauthor read of main#456 (comment 6281763561) are included: +the implicit lease after an unintegrated fetch, ``--force-if-includes``, and a deliberate +rewrite under a current lease. +A ninth case contrasts ordinary divergent-push rejection with deletion and unforced recreation; +it does not exercise GitHub activity events or branch protections. + +Run from the repository root: python3 -m unittest tests.test_pickup_fence +""" +import os +import subprocess +import tempfile +import unittest + + +def git(*args, cwd, check=True): + env = dict(os.environ) + env.update({ + 'GIT_AUTHOR_NAME': 'test', 'GIT_AUTHOR_EMAIL': 'test@example.invalid', + 'GIT_COMMITTER_NAME': 'test', 'GIT_COMMITTER_EMAIL': 'test@example.invalid', + 'GIT_CONFIG_NOSYSTEM': '1', 'HOME': cwd, + }) + r = subprocess.run(['git', *args], cwd=cwd, env=env, capture_output=True, text=True) + if check and r.returncode != 0: + raise AssertionError('git %s failed:\n%s%s' % (' '.join(args), r.stdout, r.stderr)) + return r + + +class PickupFenceTest(unittest.TestCase): + """Worker A holds a stale view of ``work``; worker B has pushed a newer commit.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + root = self.tmp.name + self.remote = os.path.join(root, 'remote.git') + git('init', '--bare', '-q', '-b', 'main', self.remote, cwd=root) + seed = os.path.join(root, 'seed') + git('clone', '-q', self.remote, seed, cwd=root) + with open(os.path.join(seed, 'f.txt'), 'w') as f: + f.write('base\n') + git('add', 'f.txt', cwd=seed) + git('commit', '-q', '-m', 'base', cwd=seed) + git('push', '-q', 'origin', 'HEAD:refs/heads/work', cwd=seed) + self.a = os.path.join(root, 'a') + self.b = os.path.join(root, 'b') + for d in (self.a, self.b): + git('clone', '-q', '-b', 'work', self.remote, d, cwd=root) + self.old_head = git('rev-parse', 'HEAD', cwd=self.a).stdout.strip() + # B advances the branch; A does not fetch. + with open(os.path.join(self.b, 'f.txt'), 'a') as f: + f.write('b\n') + git('commit', '-q', '-am', 'b moves work', cwd=self.b) + git('push', '-q', 'origin', 'work', cwd=self.b) + self.new_head = git('rev-parse', 'HEAD', cwd=self.b).stdout.strip() + # A commits on its stale view. + with open(os.path.join(self.a, 'f.txt'), 'a') as f: + f.write('a\n') + git('commit', '-q', '-am', 'a, stale', cwd=self.a) + + def tearDown(self): + self.tmp.cleanup() + + def remote_head(self): + return git('rev-parse', 'refs/heads/work', cwd=self.remote).stdout.strip() + + def test_plain_push_from_stale_view_is_prevented(self): + r = git('push', 'origin', 'work', cwd=self.a, check=False) + self.assertNotEqual(r.returncode, 0) + self.assertIn('[rejected]', r.stderr + r.stdout) + self.assertEqual(self.remote_head(), self.new_head) + + def test_force_with_lease_on_stale_expected_head_is_prevented(self): + r = git('push', '--force-with-lease=refs/heads/work:' + self.old_head, 'origin', 'work', cwd=self.a, check=False) + self.assertNotEqual(r.returncode, 0) + self.assertIn('stale info', r.stderr + r.stdout) + self.assertEqual(self.remote_head(), self.new_head) + + def test_force_with_lease_on_current_expected_head_is_allowed(self): + """A fresh view with a current lease pushes; this push is also a fast-forward.""" + git('fetch', '-q', 'origin', cwd=self.a) + git('reset', '-q', '--hard', 'origin/work', cwd=self.a) + with open(os.path.join(self.a, 'f.txt'), 'a') as f: + f.write('a, fresh\n') + git('commit', '-q', '-am', 'a, fresh', cwd=self.a) + mine = git('rev-parse', 'HEAD', cwd=self.a).stdout.strip() + git('push', '--force-with-lease=refs/heads/work:' + self.new_head, 'origin', 'work', cwd=self.a) + self.assertEqual(self.remote_head(), mine) + + def test_implicit_lease_after_unintegrated_fetch_is_not_prevented(self): + """``--force-with-lease`` with no expected value trusts the remote-tracking ref, which a fetch + that A never integrated has already moved: B's commit is overwritten.""" + git('fetch', '-q', 'origin', cwd=self.a) + r = git('push', '--force-with-lease', 'origin', 'work', cwd=self.a, check=False) + self.assertEqual(r.returncode, 0) + self.assertNotEqual(self.remote_head(), self.new_head) + + def test_implicit_lease_with_force_if_includes_is_prevented(self): + """``--force-if-includes`` closes the gap of the previous case. The option exists since git 2.30; + where it is unknown the push fails for that reason and the case is skipped, not passed.""" + git('fetch', '-q', 'origin', cwd=self.a) + r = git('push', '--force-with-lease', '--force-if-includes', 'origin', 'work', cwd=self.a, check=False) + if 'unknown option' in r.stderr or 'force-if-includes' in r.stderr and 'usage:' in r.stderr: + self.skipTest('git lacks --force-if-includes: ' + r.stderr.strip().splitlines()[0]) + self.assertNotEqual(r.returncode, 0) + self.assertIn('[rejected]', r.stderr) + self.assertEqual(self.remote_head(), self.new_head) + + def test_deliberate_rewrite_under_a_current_lease_is_not_prevented(self): + """A fresh view that rewrites B's commit, with the explicit current lease, pushes: the lease + fences stale writers, not deliberate conflicting writes.""" + git('fetch', '-q', 'origin', cwd=self.a) + git('reset', '-q', '--hard', 'origin/work', cwd=self.a) + git('commit', '-q', '--amend', '-m', 'a rewrites b', cwd=self.a) + is_ff = git('merge-base', '--is-ancestor', self.new_head, 'HEAD', cwd=self.a, check=False).returncode == 0 + self.assertFalse(is_ff) + r = git('push', '--force-with-lease=refs/heads/work:' + self.new_head, 'origin', 'work', cwd=self.a, check=False) + self.assertEqual(r.returncode, 0) + self.assertNotEqual(self.remote_head(), self.new_head) + + def test_deletion_and_ordinary_recreation_is_not_prevented(self): + """An ordinary divergent push rejects, but delete plus unforced recreation discards B. + This is local Git behavior, not a test of GitHub activity events or protections. + """ + git('fetch', '-q', 'origin', cwd=self.a) + # Fetch makes B's object available without integrating it into A's candidate. + is_ff = git('merge-base', '--is-ancestor', self.new_head, 'HEAD', cwd=self.a, check=False) + self.assertEqual(is_ff.returncode, 1) # non-ancestry, not an unknown-object error + mine = git('rev-parse', 'HEAD', cwd=self.a).stdout.strip() + self.assertNotEqual(mine, self.new_head) + rejected = git('push', 'origin', 'work', cwd=self.a, check=False) + self.assertNotEqual(rejected.returncode, 0) + self.assertIn('[rejected]', rejected.stderr + rejected.stdout) + self.assertEqual(self.remote_head(), self.new_head) + + git('push', '-q', 'origin', '--delete', 'work', cwd=self.a) + missing = git('show-ref', '--verify', '--quiet', 'refs/heads/work', cwd=self.remote, check=False) + self.assertEqual(missing.returncode, 1) + # No force option: creation is a separate operation from a fast-forward update. + git('push', '-q', 'origin', 'HEAD:refs/heads/work', cwd=self.a) + self.assertEqual(self.remote_head(), mine) + self.assertNotEqual(self.remote_head(), self.new_head) + + def test_plain_force_is_not_prevented(self): + """A bare ``--force`` overwrites B's commit: this write is only discouraged by the workflow.""" + git('push', '--force', '-q', 'origin', 'work', cwd=self.a) + self.assertEqual(self.remote_head(), git('rev-parse', 'HEAD', cwd=self.a).stdout.strip()) + self.assertNotEqual(self.remote_head(), self.new_head) + + def test_a_claim_record_prevents_nothing_by_itself(self): + """B records a claim on the branch; A sees it and pushes anyway. Nothing stops the push.""" + with open(os.path.join(self.b, 'CLAIM.txt'), 'w') as f: + f.write('claimed by B until 23:59Z\n') + git('add', 'CLAIM.txt', cwd=self.b) + git('commit', '-q', '-m', 'b records a claim', cwd=self.b) + git('push', '-q', 'origin', 'work', cwd=self.b) + git('fetch', '-q', 'origin', cwd=self.a) + git('reset', '-q', '--hard', 'origin/work', cwd=self.a) + self.assertTrue(os.path.exists(os.path.join(self.a, 'CLAIM.txt'))) + with open(os.path.join(self.a, 'f.txt'), 'a') as f: + f.write('a ignores the claim\n') + git('commit', '-q', '-am', 'a ignores the claim', cwd=self.a) + git('push', '-q', 'origin', 'work', cwd=self.a) + self.assertEqual(self.remote_head(), git('rev-parse', 'HEAD', cwd=self.a).stdout.strip()) + + +if __name__ == '__main__': + unittest.main()