# Branch Sync And Push Safety

> Use when changing local branch synchronization, custody recovery, post-review head binding, rebasing, or force-push safety.

- Skill: `mvanhorn/branch-sync-and-push-safety` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mvanhorn/branch-sync-and-push-safety`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mvanhorn/branch-sync-and-push-safety/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: mvanhorn (https://skillmd.com/u/mvanhorn)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mvanhorn/branch-sync-and-push-safety

---


**Guarded Local Branch Synchronization (`internal/branchsync`)**

- `sync`, `axi sync`, and the TUI `u` action 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 that `preservedContainsLocalWork` proves 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: `Refresh` must not share one deadline across sequential `git.LsRemote` and `git.FetchRemoteBranchToPrivateRef` calls, and `Apply` uses the same per-operation budget for its final live check. The per-operation budget is `Service.RemoteTimeout`, sourced only from the operator's global `branch_sync_remote_timeout` setting (default `config.DefaultBranchSyncRemoteTimeout`, 60s); `RepoConfig` deliberately 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 `Recover` share one eligibility model. Equal/ahead local ancestry can create the local anchor without requiring gate access, but available gate evidence must agree; importing a 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 report `blocked_pipeline_owned_recoverable` + `next_action recover_custody` with the exact submitted/current-head and relation facts (active runs keep the plain block). Missing, non-commit, symbolic, or conflicting evidence, and import cases that are dirty or genuinely divergent, fail closed with manual reconciliation instead. `sync --recover` anchors the preserved head at `refs/no-mistakes/recover/<run>` before stamping `runs.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 as `blocked_wrong_branch`, and it classifies `user_owned` - no `next_action`, non-blocking exit, never represented as recoverable custody, `--recover` there is an idempotent no-op that mutates nothing, and a fresh `axi run` or 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 when `preservedContainsLocalWork` proves containment. That proof is an executable `merge-tree` three-way merge whose result must equal the preserved head's tree, anchored on the merge-base - never `runs.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 at `refs/no-mistakes/recover-local/<run>`, then moves the branch with Git operations that fail closed on their own rather than after an observation - an atomic `update-ref` CAS plus `read-tree -m -u`, never check-then-act followed by `reset --hard`, which destroys anything landing in the gap. `recoverAdoptPreserved` owns the reasoning.
  Terminalization pins every verified unpublished head at `refs/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 truly missing recorded head reports a distinct manual-reconciliation action instead of advertising an impossible `recover_custody` command.
  When the operator keeps a behind or diverged local head instead of taking the preserved head, `--keep-local` never 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 the `Recover` doc comment in `internal/branchsync/sync.go`.
- Public guidance is owned by `internal/skill/skill.go` plus live AXI strings, then regenerated with `make skill`. Core regressions live in `internal/branchsync` (incl. `recover_test.go`), `internal/cli/sync_test.go`, `internal/tui/branch_sync_test.go`, and e2e `TestAxiBranchSyncJourney` / `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 `assertPipelineHeadContinuity` at 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 mutable `HEAD`. Never infer approval from `runs.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.go` own 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=false` instead 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 unchanged `lastSeenSHA`, or remote commits already incorporated by patch-id (excluding `^baseSHA` history the run knowingly rewrites). Anything else refuses, and a failed ls-remote/fetch fails closed; never degrade to a bare `--force`/`--force-with-lease` without an explicit anchor.
- `lastSeenSHA` must 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 refreshes `origin/<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).

