Imported from kunchenguid/no-mistakes (
.agents/skills/branch-sync-and-push-safety/SKILL.md). Install upstream withnpx skills add kunchenguid/no-mistakes --skill branch-sync-and-push-safety. Copyright stays with the author.
Guarded Local Branch Synchronization (internal/branchsync)
sync,axi sync, and the TUIuaction share one service whose only ordinary worktree mutation is a clean guarded move to an exact freshly verified pipeline push binding: strict fast-forward for behind branches, or an anchored reset to an equivalent diverged pipeline head when local unique work is already represented there. Under--recover, the worktree can only strict-fast-forward to the gate-preserved head, or adopt a diverged preserved head thatpreservedContainsLocalWorkproves carries every local change. Passive status never fetches, and blocked states never reset, stash, merge, rebase, force, switch, delete, or update an external remote.- Give each network remote operation its own bounded child context derived from the caller:
Refreshmust not share one deadline across sequentialgit.LsRemoteandgit.FetchRemoteBranchToPrivateRefcalls, andApplyuses the same per-operation budget for its final live check. The per-operation budget isService.RemoteTimeout, sourced only from the operator's globalbranch_sync_remote_timeoutsetting (defaultconfig.DefaultBranchSyncRemoteTimeout, 60s);RepoConfigdeliberately has no matching field.Recover's local-gate fetch is outside this network deadline contract. Regressions:TestRefreshSlowSuccessfulLsRemoteDoesNotStealFetchBudget,TestRefreshSlowButSuccessfulLsRemoteAloneExceedsItsOwnBudgetReportsOffline,TestRefreshRaisedRemoteTimeoutAcceptsTheSameLegitimateSlowLsRemote,TestRefreshParentCancellationStopsFetchAfterLsRemoteSucceeds,TestServiceRemoteTimeoutDefaultsToConfigDefault,TestLoadGlobal_InvalidBranchSyncRemoteTimeout,TestLoadRepo_BranchSyncRemoteTimeoutIsNotARepoSetting. - Successful pipeline pushes persist the exact SHA, credential-free target fingerprint/ref, and generation; legacy rows remain nullable and must never infer provenance from mutable
head_sha. Structured PR lifecycle retires merged/closed branches. The service rechecks the invoking worktree, target, live remote equality, ancestry or equivalent-divergence proof, generation, and all mutable assumptions immediately before apply. - A TERMINAL run with unpublished pipeline commits (moved head) is recoverable only from verified, non-conflicting evidence: inspection and
Recovershare one eligibility model. Equal/ahead local ancestry can create the local anchor without requiring gate access, but available gate evidence must agree; importing a locally missing preserved head requires exact or safely anchorable gate evidence, a clean worktree, and either ancestry or the content-preservation proof below. Only then does inspection reportblocked_pipeline_owned_recoverable+next_action recover_custodywith the exact submitted/current-head and relation facts (active runs keep the plain block). Non-commit, symbolic, or conflicting evidence, and import cases that are dirty or genuinely divergent, fail closed with manual reconciliation. A verified head absent from both the worktree and an accessible gate instead offers the explicit--recover --keep-localdiscard path described below. Plainsync --recoveranchors the preserved head atrefs/no-mistakes/recover/<run>before stampingruns.custody_returned_at. Cancellation RELEASES a terminal run that never changed the submitted head (head_sha == submitted_head_sha, no push, no custody stamp): selection keeps it visible so it never misreports asblocked_wrong_branch, and it classifiesuser_owned- nonext_action, non-blocking exit, never represented as recoverable custody,--recoverthere is an idempotent no-op that mutates nothing, and a freshaxi runor separately authorized direct push is never blocked. Equal/ahead worktrees anchor locally without requiring gate access, but an available gate's existing recovery ref must agree with the recorded head; behind/diverged worktrees verify and fetch the preserved head from the run-specific recovery ref, fast-forwarding only a clean behind worktree. A cancelled validation routinely leaves a preserved head that is a REBASE of the local branch, which equality and ancestry read as plain divergence, so a clean diverged worktree is adopted whenpreservedContainsLocalWorkproves containment. That proof is an executablemerge-treethree-way merge whose result must equal the preserved head's tree, anchored on the merge-base - neverruns.base_sha, the previous gate head. It deliberately does NOT use patch identity: patch IDs discard hunk locations and whitespace, so they cannot tell a genuine replay from a same-shaped edit to another identical block, and a containment claim built on them is not a proof. Everything undecidable escalates, including a rebase whose fix rounds also rewrote operator lines, where nothing separates a deliberate fix from a dropped change. Adoption anchors the pre-recovery local head atrefs/no-mistakes/recover-local/<run>, then moves the branch with Git operations that fail closed on their own rather than after an observation - an atomicupdate-refCAS plusread-tree -m -u, never check-then-act followed byreset --hard, which destroys anything landing in the gap.recoverAdoptPreservedowns the reasoning. Terminalization pins every verified unpublished head atrefs/no-mistakes/recover/<run>before the managed worktree can be removed. Recovery reads that run-specific ref rather than requiring the gate branch to match, so aborts, rebases, and pre-push failures remain recoverable while an independently moved gate branch is preserved. Legacy recorded heads that still exist as dangling gate objects are anchored on recovery. A recorded head the run never verified (terminal_head_verified_atNULL) is verified byRecoveritself in exactly two shapes, both read from fresh gate state: the live gate branch is an equal-tree rewrite of the exact reviewed result, or (recordedHeadExtendsLiveGateBranch, issue #1421) the recorded head EXTENDS the live gate branch - the clean worktree sits exactly at it, the gate still holds the object, the live gate branch is its ancestor, andsubmitted <= review_approved <= recordedis one chain, which is what a Review fix, an approved rereview, a Test fix, and a later non-code failure (Document's base fetch) leave behind. Both shapes end inanchorUnverifiedTerminalHead, the one owner of the worktree re-read and the create-onlyupdate-reftransaction that also verifies the gate branch; each then stamps with its own compare-and-swap (db.VerifyTerminalRunHeadRewrite,db.VerifyTerminalRunRecordedHeadon status, head, review authority, no earlier stamp and no custody return). Recovery never moves a worktree ONTO an unverified head: a behind or dirty worktree, a rebased head, a missing review authority, a moved gate branch, or a pruned object all keep refusingblocked_recover_unverified_head. Regressions:TestRecoverReturnsCustodyAtTheRecordedHeadThatExtendsTheGateBranch,TestRecoverRecordedHeadExtendingTheGateBranchFailsClosed,TestRecoverTerminalUnverifiedEqualTreeRewriteAdoptsLiveGateHead,TestRecoverTerminalUnverifiedRewriteNegativeControls. When an accessible gate confirms that a verified recorded head is truly missing and both recovery refs are compatible, status offersrecover_custodywithno-mistakes axi sync --recover --keep-local: that flag is the operator's explicit discard of unpublished pipeline commits. Unverified heads, inaccessible gates, and conflicting refs retain manual reconciliation, and plain--recoverstill refuses. A divergent later head can also prove preserved through exactly one append-onlyrecovery_archivesbinding, but only while its repository, run, branch, required/preserved heads, raw archive ref, and gate recovery ref all revalidate.recoverySourceAvailableremains the one discovery/classification owner and offers onlyrecover_custodywith--recover --keep-local; the archived head is never selected. Similar unbound refs and every stale, moved, symbolic, malformed, cross-bound, or ambiguous record fail closed. When the operator keeps a behind or diverged local head instead of taking the preserved head,--keep-localnever touches the worktree and CAS-moves the gate branch to the kept head, staging objects via gate-side fetch - never a push, which would fire the receive hook and start a run. The full relation matrix and fail-safe rules live in theRecoverdoc comment ininternal/branchsync/sync.go. - Public guidance is owned by
internal/skill/skill.goplus live AXI strings, then regenerated withmake skill. Core regressions live ininternal/branchsync(incl.recover_test.go),internal/cli/sync_test.go,internal/tui/branch_sync_test.go, and e2eTestAxiBranchSyncJourney/TestAxiCustodyRecoveryJourney/TestAxiCustodyRecoveryAfterRebaseJourney/TestAxiPrePushAbortUnmovedHeadCustodyJourney.
Post-Review Head Continuity and Push Binding
- Every step after Review in the fixed pipeline order (Test, Document, Lint, Push, PR, CI) calls
assertPipelineHeadContinuityat entry. The helper is the single semantic owner: equal or descendant live heads continue; backward, sibling, and unverifiable heads fail before the step performs work. Regression:TestPostReviewStepsRefuseHeadClobberAtEntry. - A successfully completed full review atomically records
runs.review_approved_head_sha; parked, failed, skipped, and legacy reviews carry no inferred authority. Push reads that durable binding, permits only the exact commit or a descendant, and pushes the verified immutable SHA rather than mutableHEAD. Never infer approval fromruns.head_sha, a worktree, gate ref, or remote branch. Regressions:TestPushStep_RefusesPostReviewClobberWithoutLaterPipelineCommit,TestPushStep_BindsRemoteAndDatabaseToVerifiedCommitWhenHEADMovesDuringPush,TestExecutor_FullRereviewReplacesApprovalWithoutAuthorizingParkedRound.
Rebase Base & Force-Push Safety (data-loss prevention)
- The whole job of this tool is to not lose people's code; favor refusing the push and surfacing a finding over any clever recovery. The comments in
internal/pipeline/steps/forcepush.goown the full reasoning; the invariants are the next three bullets. - Rebase bases come from the freshly fetched authoritative remote refs, never local or stale state; and a branch built on unpushed local-default-branch commits parks with
NeedsApproval+AutoFixable=falseinstead of silently widening the PR (detectBundledLocalDefaultCommits, #283). - Every force-push routes through
resolveForcePushDecision, which re-reads the live remote head and allows the push only for a new branch, an already-equal remote, an unchangedlastSeenSHA, or remote commits already incorporated by patch-id (excluding^baseSHAhistory the run knowingly rewrites). Anything else refuses, and a failed ls-remote/fetch fails closed; never degrade to a bare--force/--force-with-leasewithout an explicit anchor. lastSeenSHAmust stay the head the run last observed (from run/prior-run push provenance or the remote-tracking ref), never the live remote tip: the rebase step refreshesorigin/<branch>only on a normal push, NOT on a force push. CI repairs commit locally and restart validation at Review; the later Push step owns their remote update and force-push safety. Anchoring a lease to a SHA read immediately before pushing is the original #281 bug (it always passes and protects nothing); always-fetching the branch on force push recreates it. Never reintroduce either.- Regressions:
TestPushStep_RefusesToClobberAdvancedUpstreamBranch(#305),TestForcePushRun_RefusesToClobberOutOfBandBranchCommit,TestRebaseStep_DetectsUnpushedLocalDefaultBranchCommits(#283),TestResolveForcePushDecision_*,TestExecutor_CIRestartRevalidatesBeforePush,TestPushStep_AllowsForcePushAfterMidRunRebaseOverPriorPushedGeneration(#837),TestPushStep_AllowsForcePushOnRerunOverPriorRunPushedGeneration(#837).
Rebase Step Integration Shape (rebase.strategy)
rebase.strategy(rebasedefault, ormerge) selects how the rebase step integrates a moved base. Undermergeeach target is merged withgit merge --no-ffinstead of rebased onto, so the reviewed head stays on the branch as the merge commit's FIRST parent. Three things follow and are what the option exists for:ciRepairContinuityGap's ancestry test then passes natively (no patch-id or content guess), publication is a fast-forward, and the merge commit's two parents keep a conflict resolution auditable from outside the pipeline afterwards. To match,mergeWithAgent's resolver prompt requires an ADDITIVE resolution, whichrebaseWithAgent's "minimal necessary changes" prompt deliberately does not; do not unify the two prompts.mergeWithAgentPROVES the merge instead of trusting the agent's conclusion, and target ancestry alone is not that proof (a rebase satisfies it too). It snapshots the pre-merge head and resolves the target to a SHA BEFOREgit mergeruns - resolving by refname afterwards lets an agent that fetches mid-resolution fail a merge that actually landed - and afterwards requires HEAD to have moved off the pre-merge head with BOTH snapshots ancestors of it.shouldSkipRebasehas already consumed every non-divergent case, so two divergent commits can only both be ancestors of HEAD through a merge commit; that admits a follow-up commit on top of the merge while still rejectingmerge --abort, a rebase, andreset --hardonto the target, and an unrelated commit made after abandoning it. A conflicted rebase is the other mid-operation state the worktree can be left in and git sets noMERGE_HEADfor it, sorebaseInProgressis checked alongsidemergeInProgressand a leftover rebase is aborted and rejected too. Every rejection routes throughrestorePreMergeHead, which resets the worktree back to the head that merge started from before returning so a rejected attempt is never left checked out as the branch's state, and is fail-closed: a restore that does not land exactly there, or that leaves a rebase in progress underneath it, is reported inside the returned error. Regressions:TestRebaseStep_MergeStrategyUnconcludedMergeIsAbortedAndFails,TestRebaseStep_MergeStrategyConflictedRebaseLeftInProgressIsAbortedAndFails,TestRebaseStep_MergeStrategyAbortedMergeFails,TestRebaseStep_MergeStrategyRebasedInsteadOfMergedFails,TestRebaseStep_MergeStrategyUnrelatedCommitInsteadOfMergeFails,TestRebaseStep_MergeStrategyResetOntoTargetFails,TestRebaseStep_MergeStrategyFollowUpCommitAfterTheMergeIsAccepted.- The flag is trusted-default-branch-only regardless of
allow_repo_commands, for the same reasonno_ciis: a pushed branch must not opt its own integration out of the evidence shape the maintainer chose, in either direction. An unrecognized value fails the config closed rather than falling back. forcePushDecision.fastForwardis a separate, unconditional path: an append-only update publishes with a plain push, no--force, so the remote rather than our lease bookkeeping enforces the no-rewrite property. It applies under either strategy.test/ciscope is deliberately untouched: both still run in full on the integrated head, andci_fix.go's merge-conflict repair still rebases and therefore still always revalidates.- User-facing semantics are owned by
docs/src/content/docs/reference/repo-config.md(rebase.strategy). Regressions:internal/pipeline/steps/rebase_merge_test.go,internal/config/rebase_strategy_test.go,TestResolveForcePushDecision_FastForwardNeedsNoForce,TestResolveForcePushDecision_RewriteIsNotAFastForward.
Private Mirror and Empty-Index Handoffs
internal/gateowns private-mirror reconciliation. The accepted Decision 41-A exception, containment and final-tree survival requirements, exact archive-before-delete contract, and descendant preservation are owned bydocs/src/content/docs/concepts/gate-model.md(Private mirror reconciliation). Decision 41-A permits only a mirror head exactly equal to the publishing run'sRun.SubmittedHeadSHAor its durableRun.LastPushedSHA(read from the database, because publication does not update the executor's in-memory run) to bypass patch proof; it is an explicit exception, not a claim that ownership proves containment.push.gomust settle shared gate/worktree refs only once. Separately, and not a head exception (#1233): a private-only commit reachable from arefs/no-mistakes/recover/<run>anchor is preserved and is re-derived out of the at-risk set, so the sanctionedsync --recover-> rerun -> push loop cannot deadlock against the anchor that loop wrote. That is preservation evidence only - containment is still proven by ancestry or tree survival, Decision 41-A is still the only head exception, archive-before-delete is unchanged, and an anchor that is symbolic or non-commit credits nothing. A refusal names every satisfier. The credit scan's membership walk excludes the live head and every anchor, so it is bounded by the private-only range; with no anchors it credits nothing and the ordinary refusal stands, otherwise candidates are scanned in batches ofmaxRecoveryCandidates, never truncated or refused by size. A clean merge leaving the live tree unchanged proves every private commit contained, even a rebased copy whose patch ID drifted with context. Survival of rebased equivalents that later live commits edited falls back to a first-parent survival point plus a live-favoring merge (privateTreeSurvives), which clears every private commit whatever its patch ID; a hunk that merges cleanly is never excused. Behavioral coverage lives ininternal/gate/reconcile_test.go,internal/pipeline/steps/push_test.go,internal/pipeline/steps/push_published_rewrite_test.go, andinternal/pipeline/steps/push_rebased_equivalent_test.go.- The correction and CI repair handoff contract is owned by
docs/src/content/docs/reference/pipeline-steps.md;stagedChangesPresentimplements the index check ininternal/pipeline/steps/common_fix.go.
Custody Recovery (internal/branchsync)
- Missing-head keep-local (#958):
classifyPipelineOwnedchecksmissingHeadKeepLocalRunsfirst. When a verified recorded head is absent from both the worktree and an accessible gate and recovery refs are compatible, status offersrecover_custodywith--recover --keep-localas the explicit discard of those unpublished commits.Recovertakes that early keep-local path, then ordinary keep-local usesrecoverKeepLocalAtCurrentHead/finishKeepLocalRecoverso a stranded stack is preflighted, available heads are anchored, and custody stamps atomically. Unverified or conflicting evidence stays manual reconciliation; plain--recoverstill refuses. - Bound-archive keep-local (#954): after the missing-head check,
recoverySourceAvailableremains the discovery/classification owner and returns arecoverySourceProof(not a bool). A divergent later head affects status only through exactly one append-onlyrecovery_archivesbinding whose repository, run, branch, required head, preserved head, and raw non-symbolicrefs/heads/archive/*target all revalidate, alongside the exact gate recovery ref. The only offered action isrecover_custodywith--recover --keep-local; it never selects the archive.recoverKeepLocalFromArchiverevalidates the bound archive at the recovery boundary and rolls the gate branch back if custody stamping fails. Any missing, moved, replaced, malformed, stale, cross-bound, or ambiguous evidence fails closed without Git mutation. Regressions:TestBoundArchive*,TestBoundArchiveProofMatrixFailsClosedWithoutGitMutation,TestAxiArchiveBackedRecoveryKeepsExactRequiredHeadAndBothHistories, missing-head tests inrecover_test.go/sync_test.go.
