fix(branchsync): recover custody from contained rebased heads (#649)
* fix(branchsync): auto-recover custody for a rebase-superset preserved head A cancelled validation whose default branch advanced leaves the preserved pipeline head as a rebase of the operator's branch: the same logical commits under different SHAs. Recovery's decision matrix tested only equality and ancestry, so that shape fell through to the diverged refusal and escalated a custody return where nothing could be lost. A clean diverged worktree is now adopted when preservedContainsLocalWork proves the preserved head carries every local change, by either sufficient proof: each local commit replayed patch-identically (git rev-list --cherry-mark, which survives the fix rounds that supersede operator lines), or the preserved head already holding the local branch's exact content (merge-tree, anchored on the merge-base rather than runs.base_sha, which is the previous gate head). Adoption anchors the pre-recovery local head at refs/no-mistakes/recover-local/<run> before resetting. The unlanded-work protection is unchanged and deliberately fail-closed: unique local commits, a conflict-resolved replay, a squash that also rewrote operator lines, and a dirty worktree all still escalate, because only escalation can tell a deliberate pipeline fix apart from a dropped change. Reproduced end to end through the real binary in TestAxiCustodyRecoveryAfterRebaseJourney, with both directions pinned in internal/branchsync/recover_test.go. * no-mistakes(review): Harden custody recovery against duplicate patches and anchor races * no-mistakes(review): Correct recovery guidance and failed-reset reporting * no-mistakes(review): Qualify conflict-resolved custody recovery guidance * no-mistakes(review): Reject ambiguous recovery patches and close reset race * fix(branchsync): narrow custody adoption to a provable containment check Reshapes the rebase custody recovery to the narrowest contract that still fixes the reported bug, after three review rounds showed the previous containment proof was not one. Containment is now proven only by an executable three-way merge whose result is exactly the preserved head's tree. The patch-identity arm is gone: 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. An ordinary rebase that carries the operator's content forward intact recovers automatically; a rebase whose fix rounds also rewrote the operator's own lines now escalates, because nothing available to recovery separates a deliberate pipeline fix from a dropped change. No-data-loss outranks convenience. The branch move no longer observes branch, HEAD, and cleanliness and then runs an unconditional reset --hard. That is check-then-act, and anything landing in the gap is destroyed no matter how often it is re-observed. The two Git operations now carry the guard themselves: an atomic update-ref compare-and-swap against the observed head, so a concurrent commit refuses with nothing touched, and read-tree -m -u, which aborts rather than overwriting a modified or untracked file and is rolled back by the same compare-and-swap in reverse. Also reverts the internal/git/git.go patch-identity helper, which was scope drift outside the custody-return decision. Regression coverage: rebased auto-recover, unique local work escalates, rewritten operator lines escalate, squash-equivalent adopts, squash-drop escalates, concurrent commit refuses without losing the commit, concurrent worktree edit aborts the move and rolls the branch back, dirty refuses, keep-local unchanged. TestAxiCustodyRecoveryAfterRebaseJourney drives the whole thing through the real binary and fails before this change. * no-mistakes(review): Harden cancelled custody adoption recovery * no-mistakes(document): Document custody recovery reach limit * no-mistakes(document): Clarify diverged custody recovery guidance
This commit is contained in:
@@ -191,14 +191,17 @@ Safest local verification sequence after non-trivial changes:
|
||||
|
||||
**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. Passive status never fetches, and blocked states never reset, stash, merge, rebase, force, switch, delete, or update an external remote.
|
||||
- `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.
|
||||
- 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, never a dead end: inspection reports `blocked_pipeline_owned_recoverable` + `next_action recover_custody` with the exact submitted/current-head and relation facts (active runs keep the plain block), and `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 gate access; behind/diverged worktrees verify and fetch the preserved head from the gate branch, 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.
|
||||
Reach limit: a rebase-only cancelled run advances `runs.head_sha` but not the gate branch ref (the run worktree is detached), so recovery still refuses it at the pre-existing `gateHead == preserved` check (upstream #551). Adoption is reachable once any pipeline commit advances that ref.
|
||||
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` / `TestAxiPrePushAbortUnmovedHeadCustodyJourney`.
|
||||
- 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**
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ Exit code `0` means an eligible check, applied synchronization or recovery, alre
|
||||
The ordinary worktree mutation is either a strict fast-forward of the invoking clean checked-out branch to the freshly verified pipeline-owned pushed SHA, or an equivalent-diverged advance.
|
||||
When a clean local branch and the pipeline-pushed head are diverged but the local unique work is content-equivalent to work already represented in the live pipeline head, `sync` reports `safety: safe_equivalent_advance`, anchors the pre-sync head under `refs/no-mistakes/sync-anchor/<run>`, and moves to the pipeline head with reset semantics.
|
||||
Genuine divergence still reports `safety: blocked_diverged` and changes nothing.
|
||||
Under `--recover`, the possible worktree mutation is a strict fast-forward to the preserved pipeline head after relation-specific preservation checks.
|
||||
Under `--recover`, the possible worktree mutation is a strict fast-forward to the preserved pipeline head, or an adoption of a preserved head proven to carry every local change, both after relation-specific preservation checks.
|
||||
When the local gate branch is exactly at a newer same-branch pushed binding and Git proves that an older terminal run's unpublished preserved head is its ancestor, branch synchronization selects the newer binding; missing gate evidence, non-ancestor heads, or different or ambiguous target provenance remain blocked.
|
||||
Fork configurations verify the configured fork URL and exact feature ref rather than assuming `origin`.
|
||||
Dirty, in-progress, ahead, genuinely diverged, detached, wrong-branch, offline, changed-target, rewritten, deleted, legacy, or retired states fail closed without destructive recovery.
|
||||
@@ -207,8 +207,15 @@ Without that positive terminal head evidence, custody stays recoverable rather t
|
||||
While a run is still active, it reports `state: pipeline_owned`, the exact submitted/current heads and their relation, and `next_action.code: continue_active_run` with `no-mistakes axi status`, even when its head has not moved yet.
|
||||
`--recover` verifies the run is terminal, anchors the preserved head under `refs/no-mistakes/recover/<run>` in the invoking repository, and stamps custody returned so a fresh run can start.
|
||||
For equal or ahead worktrees where the preserved head is already locally reachable, recovery writes that anchor locally without gate access.
|
||||
For behind or diverged worktrees, recovery verifies the preserved head at the local gate branch and fetches it into the anchor before fast-forwarding only a clean behind worktree or refusing with the anchor named.
|
||||
A dirty or diverged worktree refuses with explicit choices.
|
||||
For behind or diverged worktrees, recovery verifies the preserved head at the local gate branch and fetches it into the anchor before moving or refusing.
|
||||
A clean behind worktree fast-forwards.
|
||||
A diverged worktree is adopted only when the preserved head provably carries every local change, proven by an executable three-way merge whose result is exactly the preserved head's tree.
|
||||
This covers a pipeline rebase onto a newer base once a later pipeline commit has also advanced the gate branch to the preserved head.
|
||||
A rebase-only cancelled run can still refuse recovery because its detached worktree advances the recorded run head without advancing that gate branch; use `no-mistakes rerun` in that case.
|
||||
That adoption anchors the pre-recovery local head under `refs/no-mistakes/recover-local/<run>`, then moves the branch with Git operations that refuse on their own rather than after a preceding check: an atomic compare-and-swap on the branch ref, and a working-tree update that aborts instead of overwriting a modified or untracked file.
|
||||
The proof is deliberately narrow and never uses patch identity, which discards hunk locations and whitespace and so cannot tell a genuine replay from a same-shaped edit elsewhere.
|
||||
Anything it cannot decide - unlanded local commits, or a rebase whose fix rounds also rewrote your own lines - still refuses with the anchor named, because only escalation can tell a deliberate pipeline fix apart from a dropped change.
|
||||
A dirty worktree refuses with explicit choices.
|
||||
When you explicitly keep a behind or diverged local head instead of taking the preserved head, `--keep-local` returns custody at the current head without touching the worktree and atomically points the gate branch at it, so a concurrent gate push wins and the recovery refuses instead.
|
||||
`no-mistakes rerun` is the alternative exit that resumes validating the preserved head instead of taking the branch back.
|
||||
A recovered never-pushed run reports `state: custody_returned`; a recovered pushed run reports its ordinary classification against the last push binding, typically `local_ahead`.
|
||||
|
||||
@@ -278,7 +278,7 @@ func TestRecoverFastForwardRechecksCurrentBranchBeforeMerge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecoverFixture(t, types.RunCancelled)
|
||||
f.service.beforeRecoverFastForward = func() {
|
||||
f.service.beforeRecoverWorktreeMove = func() {
|
||||
mustRun(t, f.local, "checkout", "-b", "other-clean-branch", f.submitted)
|
||||
}
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
@@ -1110,3 +1110,487 @@ func TestRecoverConcurrentGatePushLosesCleanly(t *testing.T) {
|
||||
t.Fatal("racing recover stamped custody")
|
||||
}
|
||||
}
|
||||
|
||||
// newRebasedRecoverFixture reproduces the reported cancelled-validation custody
|
||||
// state: the default branch advanced after the branch was submitted, so the
|
||||
// pipeline's rebase replayed the operator's commits onto the newer base (new
|
||||
// SHAs, identical content) before the run was cancelled. No local commit is an
|
||||
// ancestor of the preserved head and no preserved commit is an ancestor of the
|
||||
// local head, so a plain equality/ancestry test sees only "diverged" even
|
||||
// though the preserved head carries every local change.
|
||||
func newRebasedRecoverFixture(t *testing.T, status types.RunStatus) *recoverFixture {
|
||||
t.Helper()
|
||||
return newRebasedRecoverFixtureWithPipelineWork(t, status, nil)
|
||||
}
|
||||
|
||||
// newRebasedRecoverFixtureWithPipelineWork builds the same rebase state and
|
||||
// then lets pipelineWork commit further pipeline changes on the gate branch,
|
||||
// modelling the fix rounds a cancelled run may have produced.
|
||||
func newRebasedRecoverFixtureWithPipelineWork(t *testing.T, status types.RunStatus, pipelineWork func(t *testing.T, pipelineDir string)) *recoverFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
remote := filepath.Join(root, "upstream.git")
|
||||
mustRun(t, root, "init", "--bare", remote)
|
||||
|
||||
local := filepath.Join(root, "operator")
|
||||
mustRun(t, root, "init", "-b", "main", local)
|
||||
configureIdentity(t, local)
|
||||
mustWrite(t, filepath.Join(local, "file.txt"), "base\n")
|
||||
mustRun(t, local, "add", "file.txt")
|
||||
mustRun(t, local, "commit", "-m", "base")
|
||||
base := mustRun(t, local, "rev-parse", "HEAD")
|
||||
|
||||
mustRun(t, local, "checkout", "-b", "feature/recover")
|
||||
mustWrite(t, filepath.Join(local, "feature.txt"), "feature one\n")
|
||||
mustRun(t, local, "add", "feature.txt")
|
||||
mustRun(t, local, "commit", "-m", "feature one")
|
||||
mustWrite(t, filepath.Join(local, "feature.txt"), "feature one\nfeature two\n")
|
||||
mustRun(t, local, "commit", "-am", "feature two")
|
||||
submitted := mustRun(t, local, "rev-parse", "HEAD")
|
||||
|
||||
// The default branch advances after submission; that is what makes the
|
||||
// pipeline rebase produce new SHAs for the same logical commits.
|
||||
mustRun(t, local, "checkout", "main")
|
||||
mustWrite(t, filepath.Join(local, "upstream.txt"), "upstream advance\n")
|
||||
mustRun(t, local, "add", "upstream.txt")
|
||||
mustRun(t, local, "commit", "-m", "upstream advance")
|
||||
mustRun(t, local, "checkout", "feature/recover")
|
||||
|
||||
gate := filepath.Join(root, "gate.git")
|
||||
mustRun(t, root, "init", "--bare", gate)
|
||||
mustRun(t, local, "push", gate, "refs/heads/main:refs/heads/main", "refs/heads/feature/recover:refs/heads/feature/recover")
|
||||
|
||||
pipeline := filepath.Join(root, "pipeline")
|
||||
mustRun(t, root, "-c", "core.autocrlf=false", "clone", gate, pipeline)
|
||||
configureIdentity(t, pipeline)
|
||||
mustRun(t, pipeline, "checkout", "feature/recover")
|
||||
mustRun(t, pipeline, "rebase", "origin/main")
|
||||
if pipelineWork != nil {
|
||||
pipelineWork(t, pipeline)
|
||||
}
|
||||
preserved := mustRun(t, pipeline, "rev-parse", "HEAD")
|
||||
mustRun(t, pipeline, "push", "--force", "origin", "HEAD:refs/heads/feature/recover")
|
||||
|
||||
database, err := db.Open(filepath.Join(root, "state.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
repo, err := database.InsertRepo(local, remote, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, err := database.InsertRun(repo.ID, "feature/recover", submitted, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateRunHeadSHA(run.ID, preserved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateRunStatusWithVerifiedHead(run.ID, status, preserved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, _ = database.GetRun(run.ID)
|
||||
return &recoverFixture{
|
||||
t: t, ctx: ctx, db: database, repo: repo, run: run,
|
||||
service: &Service{DB: database, Repo: repo, WorkDir: local, GateDir: gate},
|
||||
local: local, gate: gate, remote: remote,
|
||||
base: base, submitted: submitted, preserved: preserved,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *recoverFixture) localAnchorRef() string {
|
||||
return "refs/no-mistakes/recover-local/" + f.run.ID
|
||||
}
|
||||
|
||||
// TestRecoverRebasedPreservedHeadAdoptsWithoutEscalating is the regression for
|
||||
// the over-escalating custody return: a cancelled validation whose preserved
|
||||
// pipeline head is the operator's own work rebased onto a newer base loses
|
||||
// nothing by adopting it, so recovery must succeed instead of refusing as
|
||||
// diverged. The relationship is invisible to equality and ancestry alone, which
|
||||
// is exactly what made the old decision escalate.
|
||||
func TestRecoverRebasedPreservedHeadAdoptsWithoutEscalating(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
if mustRun(t, f.local, "rev-parse", "HEAD") != f.submitted {
|
||||
t.Fatal("fixture did not leave the operator worktree at the submitted head")
|
||||
}
|
||||
// The bug's masking condition: neither head is an ancestor of the other.
|
||||
mustRun(t, f.local, "fetch", "--no-tags", f.gate, "+refs/heads/feature/recover:refs/no-mistakes/test/preserved")
|
||||
if isAncestor(f.ctx, f.local, f.submitted, f.preserved) || isAncestor(f.ctx, f.local, f.preserved, f.submitted) {
|
||||
t.Fatal("fixture is not a rebase divergence: one head is an ancestor of the other")
|
||||
}
|
||||
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if !state.Recovered || !state.Changed {
|
||||
t.Fatalf("rebased recovery escalated instead of returning custody: %#v", state)
|
||||
}
|
||||
if state.State != StateCustodyReturned || state.Safety != "custody_returned" {
|
||||
t.Fatalf("post-recover state = %s/%s", state.State, state.Safety)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != f.preserved {
|
||||
t.Fatalf("HEAD = %s, want preserved %s", got, f.preserved)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "feature.txt")); got != "feature one\nfeature two\n" {
|
||||
t.Fatalf("local work missing after adopting the preserved head: %q", got)
|
||||
}
|
||||
// The rebase carried the branch onto the advanced base, so the worktree
|
||||
// must now hold the newer base's files too.
|
||||
if got := readOptional(t, filepath.Join(f.local, "upstream.txt")); got != "upstream advance\n" {
|
||||
t.Fatalf("adopted head did not bring the advanced base into the worktree: %q", got)
|
||||
}
|
||||
if clean, reason := worktreeClean(f.ctx, f.local); !clean {
|
||||
t.Fatalf("worktree not clean after adoption: %s", reason)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", f.anchorRef()); got != f.preserved {
|
||||
t.Fatalf("preserved anchor = %s, want %s", got, f.preserved)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", f.localAnchorRef()); got != f.submitted {
|
||||
t.Fatalf("pre-recovery local head was not anchored: %s, want %s", got, f.submitted)
|
||||
}
|
||||
if !f.custodyReturned() {
|
||||
t.Fatal("custody not stamped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverRebasedPreservedHeadStillEscalatesForUniqueLocalWork is the
|
||||
// disconfirming counterfactual: one genuinely unique local commit whose content
|
||||
// the preserved head does not carry must keep escalating, because adopting the
|
||||
// preserved head would silently discard unlanded work.
|
||||
func TestRecoverRebasedPreservedHeadStillEscalatesForUniqueLocalWork(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
mustWrite(t, filepath.Join(f.local, "unlanded.txt"), "unlanded work\n")
|
||||
mustRun(t, f.local, "add", "unlanded.txt")
|
||||
mustRun(t, f.local, "commit", "-m", "unlanded local work")
|
||||
uniqueHead := mustRun(t, f.local, "rev-parse", "HEAD")
|
||||
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if state.Recovered || state.Changed {
|
||||
t.Fatalf("unique local work was auto-recovered: %#v", state)
|
||||
}
|
||||
if state.Safety != "blocked_recover_diverged" || state.Relation != RelationDiverged {
|
||||
t.Fatalf("recover with unique local work = %s/%s", state.Safety, state.Relation)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != uniqueHead {
|
||||
t.Fatalf("HEAD moved to %s despite unique local work", got)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "unlanded.txt")); got != "unlanded work\n" {
|
||||
t.Fatalf("unlanded work lost: %q", got)
|
||||
}
|
||||
if f.custodyReturned() {
|
||||
t.Fatal("escalation stamped custody")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverRebasedPreservedHeadEscalatesWhenFixRoundsRewroteOperatorLines
|
||||
// pins the deliberate boundary of the narrowed contract. When the pipeline both
|
||||
// rebased the branch and superseded the operator's own lines, the operator's
|
||||
// content is genuinely absent from the preserved head and nothing available to
|
||||
// recovery distinguishes a deliberate fix from a dropped change. No-data-loss
|
||||
// wins: the ambiguous case escalates for an operator decision rather than being
|
||||
// adopted on a patch-identity guess.
|
||||
func TestRecoverRebasedPreservedHeadEscalatesWhenFixRoundsRewroteOperatorLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixtureWithPipelineWork(t, types.RunCancelled, func(t *testing.T, pipelineDir string) {
|
||||
mustWrite(t, filepath.Join(pipelineDir, "feature.txt"), "feature one\nfeature two guarded\n")
|
||||
mustRun(t, pipelineDir, "commit", "-am", "no-mistakes(review): guard the second line")
|
||||
})
|
||||
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if state.Recovered || state.Changed {
|
||||
t.Fatalf("ambiguous rewritten-lines rebase was auto-recovered: %#v", state)
|
||||
}
|
||||
if state.Safety != "blocked_recover_diverged" {
|
||||
t.Fatalf("recover with rewritten operator lines = %s", state.Safety)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != f.submitted {
|
||||
t.Fatalf("HEAD moved to %s despite an unprovable containment claim", got)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "feature.txt")); got != "feature one\nfeature two\n" {
|
||||
t.Fatalf("escalation touched the worktree: %q", got)
|
||||
}
|
||||
// The preserved commits stay anchored so the operator can still reconcile.
|
||||
if got := mustRun(t, f.local, "rev-parse", f.anchorRef()); got != f.preserved {
|
||||
t.Fatalf("preserved anchor = %s, want %s", got, f.preserved)
|
||||
}
|
||||
if f.custodyReturned() {
|
||||
t.Fatal("escalation stamped custody")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverRebasedPreservedHeadRefusesConcurrentCommitWithoutLosingIt is the
|
||||
// no-data-loss-under-concurrency regression. A commit landing after every
|
||||
// precondition was observed and after containment was proven must not be
|
||||
// destroyed: the branch move is an atomic compare-and-swap against the observed
|
||||
// head, so it refuses and the concurrent commit survives untouched.
|
||||
func TestRecoverRebasedPreservedHeadRefusesConcurrentCommitWithoutLosingIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
var concurrent string
|
||||
f.service.beforeRecoverBranchMove = func() {
|
||||
mustWrite(t, filepath.Join(f.local, "concurrent.txt"), "work committed mid-recovery\n")
|
||||
mustRun(t, f.local, "add", "concurrent.txt")
|
||||
mustRun(t, f.local, "commit", "-m", "concurrent local commit")
|
||||
concurrent = mustRun(t, f.local, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if state.Recovered || state.Changed {
|
||||
t.Fatalf("recovery raced a concurrent commit: %#v", state)
|
||||
}
|
||||
if state.Safety != "blocked_recover_assumptions_changed" {
|
||||
t.Fatalf("concurrent-commit refusal = %s", state.Safety)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != concurrent {
|
||||
t.Fatalf("HEAD = %s, want the concurrent commit %s preserved", got, concurrent)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "concurrent.txt")); got != "work committed mid-recovery\n" {
|
||||
t.Fatalf("concurrent work lost: %q", got)
|
||||
}
|
||||
if f.custodyReturned() {
|
||||
t.Fatal("raced recovery stamped custody")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverRebasedPreservedHeadRollsBackAfterConcurrentCheckout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
mustRun(t, f.local, "branch", "other-clean-branch", f.submitted)
|
||||
f.service.afterRecoverBranchMove = func() {
|
||||
mustRun(t, f.local, "checkout", "other-clean-branch")
|
||||
}
|
||||
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if state.Recovered || state.Changed || state.Safety != "blocked_recover_assumptions_changed" {
|
||||
t.Fatalf("recovery raced a concurrent checkout: %#v", state)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "refs/heads/feature/recover"); got != f.submitted {
|
||||
t.Fatalf("feature branch = %s, want rollback to %s", got, f.submitted)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "refs/heads/other-clean-branch"); got != f.submitted {
|
||||
t.Fatalf("concurrently checked-out branch = %s, want %s", got, f.submitted)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "feature.txt")); got != "feature one\nfeature two\n" {
|
||||
t.Fatalf("concurrent checkout worktree changed: %q", got)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "upstream.txt")); got != "" {
|
||||
t.Fatalf("preserved tree leaked into concurrent checkout: %q", got)
|
||||
}
|
||||
if f.custodyReturned() {
|
||||
t.Fatal("raced recovery stamped custody")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverRebasedPreservedHeadRefusesConcurrentWorktreeEditWithoutLosingIt
|
||||
// is the other concurrency axis: an uncommitted edit to a file the move would
|
||||
// rewrite must abort the move inside Git itself rather than being overwritten,
|
||||
// and the branch must be restored to where it started.
|
||||
func TestRecoverRebasedPreservedHeadRefusesConcurrentWorktreeEditWithoutLosingIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
f.service.beforeRecoverBranchMove = func() {
|
||||
// upstream.txt exists only on the advanced base, so adopting the
|
||||
// preserved head must write it; an untracked copy blocks that write.
|
||||
mustWrite(t, filepath.Join(f.local, "upstream.txt"), "uncommitted local draft\n")
|
||||
}
|
||||
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if state.Recovered || state.Changed {
|
||||
t.Fatalf("recovery overwrote a concurrent worktree edit: %#v", state)
|
||||
}
|
||||
if state.Safety != "blocked_recover_worktree_busy" {
|
||||
t.Fatalf("concurrent-edit refusal = %s", state.Safety)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "upstream.txt")); got != "uncommitted local draft\n" {
|
||||
t.Fatalf("concurrent worktree edit lost: %q", got)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "refs/heads/feature/recover"); got != f.submitted {
|
||||
t.Fatalf("branch = %s, want rollback to %s", got, f.submitted)
|
||||
}
|
||||
if f.custodyReturned() {
|
||||
t.Fatal("aborted move stamped custody")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverRebasedPreservedHeadRefusesDirtyWorktree keeps the ordinary
|
||||
// uncommitted-work protection intact: a worktree that is already dirty when
|
||||
// recovery starts refuses outright, and --keep-local remains the exit that
|
||||
// never touches the worktree.
|
||||
func TestRecoverRebasedPreservedHeadRefusesDirtyWorktree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
mustWrite(t, filepath.Join(f.local, "feature.txt"), "uncommitted edit\n")
|
||||
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if state.Recovered || state.Changed || state.Safety != "blocked_recover_dirty" {
|
||||
t.Fatalf("dirty rebased recover = %#v", state)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != f.submitted {
|
||||
t.Fatalf("dirty refusal moved HEAD to %s", got)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "feature.txt")); got != "uncommitted edit\n" {
|
||||
t.Fatalf("dirty refusal overwrote uncommitted work: %q", got)
|
||||
}
|
||||
if f.custodyReturned() {
|
||||
t.Fatal("dirty refusal stamped custody")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverIncompleteAdoptionDoesNotStampStaleWorktree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
mustRun(t, f.local, "fetch", "--no-tags", f.gate, "+refs/heads/feature/recover:"+f.anchorRef())
|
||||
mustRun(t, f.local, "update-ref", f.localAnchorRef(), f.submitted, "")
|
||||
mustRun(t, f.local, "update-ref", "refs/heads/feature/recover", f.preserved, f.submitted)
|
||||
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if state.Recovered || state.Changed || state.Safety != "blocked_recover_incomplete_adoption" {
|
||||
t.Fatalf("incomplete adoption was stamped: %#v", state)
|
||||
}
|
||||
if !strings.Contains(state.Error, f.localAnchorRef()) {
|
||||
t.Fatalf("incomplete-adoption refusal does not name local anchor: %q", state.Error)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != f.preserved {
|
||||
t.Fatalf("HEAD = %s, want preserved %s", got, f.preserved)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "feature.txt")); got != "feature one\nfeature two\n" {
|
||||
t.Fatalf("pre-recovery worktree changed: %q", got)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "upstream.txt")); got != "" {
|
||||
t.Fatalf("preserved tree was applied unexpectedly: %q", got)
|
||||
}
|
||||
if f.custodyReturned() {
|
||||
t.Fatal("incomplete adoption stamped custody")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverRebasedPreservedHeadKeepLocalStillKeepsTheLocalHead pins that the
|
||||
// explicit keep-local choice still wins over the new adoption path.
|
||||
func TestRecoverRebasedPreservedHeadKeepLocalStillKeepsTheLocalHead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
state := f.service.Recover(f.ctx, true)
|
||||
if !state.Recovered || state.Changed {
|
||||
t.Fatalf("keep-local rebased recover = %#v", state)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != f.submitted {
|
||||
t.Fatalf("keep-local moved HEAD to %s", got)
|
||||
}
|
||||
if got := mustRun(t, f.gate, "rev-parse", "refs/heads/feature/recover"); got != f.submitted {
|
||||
t.Fatalf("gate branch = %s, want kept local head %s", got, f.submitted)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", f.anchorRef()); got != f.preserved {
|
||||
t.Fatal("keep-local lost the preserved anchor")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverRebasedPreservedHeadRechecksAfterAnchoringLocalHead proves the
|
||||
// pre-move guard still catches a branch switch that happens before the move.
|
||||
func TestRecoverRebasedPreservedHeadRechecksAfterAnchoringLocalHead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
f.service.beforeRecoverWorktreeMove = func() {
|
||||
mustRun(t, f.local, "checkout", "-b", "other-clean-branch", f.submitted)
|
||||
}
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if state.Recovered || state.Changed || state.Safety != "blocked_recover_assumptions_changed" {
|
||||
t.Fatalf("recover after branch switch = %#v", state)
|
||||
}
|
||||
if got := strings.TrimSpace(mustRun(t, f.local, "branch", "--show-current")); got != "other-clean-branch" {
|
||||
t.Fatalf("current branch = %q", got)
|
||||
}
|
||||
if f.custodyReturned() {
|
||||
t.Fatal("branch-switch refusal stamped custody")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverSquashedEquivalentPreservedHeadAdopts covers the same containment
|
||||
// proof for a fix round that AMENDS or squashes the operator's commits: no
|
||||
// commit-level correspondence survives, but the preserved head still carries
|
||||
// the operator's exact content, so adopting it loses nothing.
|
||||
func TestRecoverSquashedEquivalentPreservedHeadAdopts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
pipeline := filepath.Join(filepath.Dir(f.local), "squash")
|
||||
mustRun(t, filepath.Dir(f.local), "-c", "core.autocrlf=false", "clone", f.gate, pipeline)
|
||||
configureIdentity(t, pipeline)
|
||||
mustRun(t, pipeline, "checkout", "feature/recover")
|
||||
mustRun(t, pipeline, "reset", "--soft", "origin/main")
|
||||
mustRun(t, pipeline, "commit", "-m", "no-mistakes(rebase): squashed feature")
|
||||
squashed := mustRun(t, pipeline, "rev-parse", "HEAD")
|
||||
mustRun(t, pipeline, "push", "--force", "origin", "HEAD:refs/heads/feature/recover")
|
||||
if err := f.db.UpdateRunHeadSHA(f.run.ID, squashed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.db.UpdateRunStatusWithVerifiedHead(f.run.ID, types.RunCancelled, squashed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if !state.Recovered || !state.Changed || state.State != StateCustodyReturned {
|
||||
t.Fatalf("squashed-equivalent recovery = %#v", state)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != squashed {
|
||||
t.Fatalf("HEAD = %s, want squashed preserved head %s", got, squashed)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "feature.txt")); got != "feature one\nfeature two\n" {
|
||||
t.Fatalf("adopted content = %q", got)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", f.localAnchorRef()); got != f.submitted {
|
||||
t.Fatalf("pre-recovery local head was not anchored: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverSquashedPreservedHeadStillEscalatesForDroppedLocalWork is the
|
||||
// counterfactual for that proof: when the squash DROPS one of the operator's
|
||||
// changes, the preserved head no longer contains it and recovery must escalate
|
||||
// rather than silently discard it.
|
||||
func TestRecoverSquashedPreservedHeadStillEscalatesForDroppedLocalWork(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRebasedRecoverFixture(t, types.RunCancelled)
|
||||
pipeline := filepath.Join(filepath.Dir(f.local), "squash-drop")
|
||||
mustRun(t, filepath.Dir(f.local), "-c", "core.autocrlf=false", "clone", f.gate, pipeline)
|
||||
configureIdentity(t, pipeline)
|
||||
mustRun(t, pipeline, "checkout", "feature/recover")
|
||||
mustRun(t, pipeline, "reset", "--soft", "origin/main")
|
||||
// The second of the operator's two lines never makes it into the squash.
|
||||
mustWrite(t, filepath.Join(pipeline, "feature.txt"), "feature one\n")
|
||||
mustRun(t, pipeline, "add", "feature.txt")
|
||||
mustRun(t, pipeline, "commit", "-m", "no-mistakes(review): squashed without the second line")
|
||||
dropped := mustRun(t, pipeline, "rev-parse", "HEAD")
|
||||
mustRun(t, pipeline, "push", "--force", "origin", "HEAD:refs/heads/feature/recover")
|
||||
if err := f.db.UpdateRunHeadSHA(f.run.ID, dropped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.db.UpdateRunStatusWithVerifiedHead(f.run.ID, types.RunCancelled, dropped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
state := f.service.Recover(f.ctx, false)
|
||||
if state.Recovered || state.Changed || state.Safety != "blocked_recover_diverged" {
|
||||
t.Fatalf("dropped local work was auto-recovered: %#v", state)
|
||||
}
|
||||
if got := mustRun(t, f.local, "rev-parse", "HEAD"); got != f.submitted {
|
||||
t.Fatalf("HEAD moved to %s despite dropped local work", got)
|
||||
}
|
||||
if got := readOptional(t, filepath.Join(f.local, "feature.txt")); got != "feature one\nfeature two\n" {
|
||||
t.Fatalf("dropped-work escalation touched the worktree: %q", got)
|
||||
}
|
||||
if f.custodyReturned() {
|
||||
t.Fatal("dropped-work escalation stamped custody")
|
||||
}
|
||||
}
|
||||
|
||||
+218
-10
@@ -137,9 +137,11 @@ type Service struct {
|
||||
GateDir string
|
||||
Paths *paths.Paths
|
||||
|
||||
beforeApply func()
|
||||
beforeGateReset func()
|
||||
beforeRecoverFastForward func()
|
||||
beforeApply func()
|
||||
beforeGateReset func()
|
||||
beforeRecoverWorktreeMove func()
|
||||
beforeRecoverBranchMove func()
|
||||
afterRecoverBranchMove func()
|
||||
}
|
||||
|
||||
// OpenCurrent opens a service for the invoking registered worktree. The caller
|
||||
@@ -456,10 +458,29 @@ func (s *Service) Apply(ctx context.Context) State {
|
||||
// then return custody gate reset to it (CAS)
|
||||
// behind dirty refuse (commit/stash first) custody at local head;
|
||||
// gate reset to it (CAS)
|
||||
// diverged, clean anchor the pre-recovery local custody at local head;
|
||||
// P contains head, then move to P with gate reset to it (CAS)
|
||||
// all local fail-closed ops; return custody
|
||||
// work
|
||||
// diverged, dirty refuse (commit/stash first) custody at local head;
|
||||
// P contains gate reset to it (CAS)
|
||||
// all local
|
||||
// work
|
||||
// diverged any refuse (anchor named, manual custody at local head;
|
||||
// reconcile / rerun offered) gate reset to it (CAS)
|
||||
// P missing any refuse refuse
|
||||
//
|
||||
// The containment row exists because a cancelled validation routinely leaves P
|
||||
// as a REBASE of the local branch onto a newer base: the same logical commits
|
||||
// with new SHAs, so equality and ancestry alone see only divergence and
|
||||
// escalated a case where nothing could be lost. The row applies only where
|
||||
// preservedContainsLocalWork proves, by executable three-way merge, that P
|
||||
// already carries every local change. That proof is deliberately narrow, and
|
||||
// everything it cannot decide - including a rebase whose fix rounds also
|
||||
// rewrote the operator's lines - falls through to the plain diverged refusal.
|
||||
// No-data-loss outranks convenience here: when nothing can distinguish a
|
||||
// deliberate pipeline fix from a dropped change, the operator decides.
|
||||
//
|
||||
// Fail-safe rules, in the same spirit as Refresh/Apply:
|
||||
// - An active run always refuses: only terminal runs are recoverable.
|
||||
// - The preserved commits must be provably safe before custody moves: when
|
||||
@@ -468,11 +489,13 @@ func (s *Service) Apply(ctx context.Context) State {
|
||||
// access; otherwise the preserved head is verified at the gate branch head
|
||||
// and fetched into that anchor. The anchor keeps them reachable locally no
|
||||
// matter what later happens to the gate.
|
||||
// - The only possible worktree mutation stays a strict fast-forward of a
|
||||
// clean checked-out branch. When the operator explicitly keeps a behind or
|
||||
// diverged local head instead of taking P, --keep-local never touches the
|
||||
// worktree and moves the gate branch to the kept head with an atomic
|
||||
// compare-and-swap, so a concurrent gate push wins and recovery refuses.
|
||||
// - The only possible worktree mutation is a guarded move of a clean checked-out
|
||||
// branch: a strict fast-forward, or an anchored move to a proven-containing
|
||||
// head performed by Git operations that refuse on their own rather than by a
|
||||
// preceding observation (see recoverAdoptPreserved). When the operator explicitly keeps a behind or diverged local
|
||||
// head instead of taking P, --keep-local never touches the worktree and moves
|
||||
// the gate branch to the kept head with an atomic compare-and-swap, so a
|
||||
// concurrent gate push wins and recovery refuses.
|
||||
// - Anything unverifiable (missing gate where required, moved gate branch,
|
||||
// failed anchor write or fetch, changed assumptions) refuses with a reason
|
||||
// and changes nothing.
|
||||
@@ -533,6 +556,13 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State {
|
||||
local := state.Local.Head
|
||||
preserved := run.HeadSHA
|
||||
anchorRef := recoverAnchorRef(run.ID)
|
||||
localAnchor := recoverLocalAnchorRef(run.ID)
|
||||
|
||||
if anchoredLocal, err := git.Run(ctx, wd, "rev-parse", "--verify", localAnchor+"^{commit}"); err == nil && anchoredLocal != preserved && local == preserved && !state.Local.Clean {
|
||||
blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_incomplete_adoption", fmt.Sprintf("the branch reached the preserved pipeline head, but its worktree still differs from that head; the pre-recovery head remains anchored at %s; reconcile the worktree and re-run recovery; custody was not recorded", localAnchor))
|
||||
blocked.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"}
|
||||
return blocked
|
||||
}
|
||||
|
||||
if objectExists(ctx, wd, preserved) && (local == preserved || isAncestor(ctx, wd, preserved, local)) {
|
||||
if blocked, ok := s.anchorReachablePreserved(ctx, state, anchorRef, preserved); !ok {
|
||||
@@ -589,6 +619,15 @@ func (s *Service) Recover(ctx context.Context, keepLocal bool) State {
|
||||
if keepLocal {
|
||||
return s.recoverKeepLocal(ctx, run, state, gateHead)
|
||||
}
|
||||
if preservedContainsLocalWork(ctx, wd, local, preserved) {
|
||||
if !state.Local.Clean {
|
||||
state.Relation = RelationDiverged
|
||||
blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_dirty", fmt.Sprintf("the invoking worktree is not clean (%s); commit or stash first and re-run the recovery, or use --keep-local to return custody at the current head without moving the worktree; no files or refs were changed", state.Local.Reason))
|
||||
blocked.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"}
|
||||
return blocked
|
||||
}
|
||||
return s.recoverAdoptPreserved(ctx, run, state, preserved)
|
||||
}
|
||||
state.Relation = RelationDiverged
|
||||
blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_diverged", fmt.Sprintf("the local branch and the preserved pipeline head have diverged; the preserved commits are anchored at %s - reconcile manually and re-run the recovery, run `no-mistakes rerun` to resume validating the preserved head, or use --keep-local to keep the current head; no files or refs were changed", anchorRef))
|
||||
blocked.NextAction = &NextAction{Code: "inspect_and_reconcile_manually", Command: "git log --oneline --left-right HEAD..." + anchorRef}
|
||||
@@ -639,8 +678,8 @@ func (s *Service) recoverKeepLocal(ctx context.Context, run *db.Run, state State
|
||||
// recoverFastForward advances the clean checked-out branch to the preserved
|
||||
// pipeline head with the same strict fast-forward and honesty rules as Apply.
|
||||
func (s *Service) recoverFastForward(ctx context.Context, run *db.Run, state State, preserved string) State {
|
||||
if s.beforeRecoverFastForward != nil {
|
||||
s.beforeRecoverFastForward()
|
||||
if s.beforeRecoverWorktreeMove != nil {
|
||||
s.beforeRecoverWorktreeMove()
|
||||
}
|
||||
branch, branchErr := git.CurrentBranch(ctx, s.workDir())
|
||||
head, headErr := git.HeadSHA(ctx, s.workDir())
|
||||
@@ -670,6 +709,168 @@ func (s *Service) recoverFastForward(ctx context.Context, run *db.Run, state Sta
|
||||
return s.finishRecover(ctx, run, true)
|
||||
}
|
||||
|
||||
// preservedContainsLocalWork proves the preserved pipeline head already carries
|
||||
// every change the local branch has, so adopting it discards no work.
|
||||
//
|
||||
// The proof is an executable three-way merge, never a patch-identity hash.
|
||||
// Patch IDs discard hunk locations and whitespace, so they cannot tell a
|
||||
// genuine replay from a same-shaped edit to a different identical block; a
|
||||
// containment claim built on them is not a proof, and this path exists only to
|
||||
// protect people's unlanded code. Merging the local branch into the preserved
|
||||
// head and requiring the result to be exactly the preserved head's tree is
|
||||
// decidable and content-exact: if the local branch had anything the preserved
|
||||
// head lacks, the merged tree differs and the answer is no.
|
||||
//
|
||||
// The merge base is the only sound anchor. Only a commit provably reachable
|
||||
// from BOTH heads makes the diff base..local mean exactly "the local branch's
|
||||
// own work"; runs.base_sha cannot be used, because it is the previous gate head
|
||||
// and for a re-pushed branch carries pipeline commits the local branch never
|
||||
// had.
|
||||
//
|
||||
// The predicate is one-directional: it answers only "would adopting the
|
||||
// preserved head lose local work", never "are the two heads interchangeable".
|
||||
// It is deliberately narrow, and every unreadable, conflicting, or ambiguous
|
||||
// case returns false so the plain diverged refusal escalates. That is the
|
||||
// intended trade: an ordinary rebase whose content is carried forward intact
|
||||
// recovers automatically, while a rebase that also rewrote the operator's lines
|
||||
// - where nothing can distinguish a deliberate pipeline fix from a dropped
|
||||
// change - stays a decision for the operator.
|
||||
func preservedContainsLocalWork(ctx context.Context, dir, local, preserved string) bool {
|
||||
if local == "" || preserved == "" || local == preserved {
|
||||
return false
|
||||
}
|
||||
base, err := git.Run(ctx, dir, "merge-base", local, preserved)
|
||||
if err != nil || base == "" {
|
||||
return false
|
||||
}
|
||||
return mergeTreePreservesFinalHead(ctx, dir, base, local, preserved)
|
||||
}
|
||||
|
||||
// recoverAdoptPreserved returns custody for a preserved pipeline head that
|
||||
// already carries every local change. The local commits are represented in the
|
||||
// preserved head, but their exact SHAs are not reachable from it, so the move is
|
||||
// not a fast-forward and the pre-recovery local head is anchored first.
|
||||
//
|
||||
// The move itself must fail closed. An observation of branch, HEAD, and
|
||||
// cleanliness followed by an unconditional `reset --hard` is check-then-act:
|
||||
// anything landing in the gap is destroyed, and no amount of re-observation
|
||||
// closes it, because the check and the mutation are separate commands. So the
|
||||
// two Git operations that perform the move carry the guard in themselves, in
|
||||
// the spirit of `merge --ff-only`:
|
||||
//
|
||||
// - `update-ref <branch> <preserved> <observed>` is an atomic compare-and-swap.
|
||||
// A concurrent commit moved the branch, so the swap refuses and nothing at
|
||||
// all has been touched.
|
||||
// - `read-tree -m -u <observed> <preserved>` refuses to overwrite a modified
|
||||
// or untracked working-tree file. A concurrent edit to a file this move
|
||||
// would rewrite aborts it before any file changes; an edit to a file the
|
||||
// move does not touch is simply carried across. When it refuses, the branch
|
||||
// swap is rolled back by the same compare-and-swap in reverse.
|
||||
//
|
||||
// A crash between the two leaves the branch at the preserved head with the
|
||||
// working tree still holding the pre-recovery content, which reads as ordinary
|
||||
// uncommitted changes and loses nothing: containment was proven before the move
|
||||
// and the pre-recovery head stays anchored. Custody is stamped only after the
|
||||
// whole move is verified.
|
||||
func (s *Service) recoverAdoptPreserved(ctx context.Context, run *db.Run, state State, preserved string) State {
|
||||
if s.beforeRecoverWorktreeMove != nil {
|
||||
s.beforeRecoverWorktreeMove()
|
||||
}
|
||||
wd := s.workDir()
|
||||
branch, branchErr := git.CurrentBranch(ctx, wd)
|
||||
head, headErr := git.HeadSHA(ctx, wd)
|
||||
clean, _ := worktreeClean(ctx, wd)
|
||||
if branchErr != nil || branch != state.Local.Branch || headErr != nil || head != state.Local.Head || !clean {
|
||||
return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the local branch or worktree changed while custody was being returned; no files or refs were changed")
|
||||
}
|
||||
// The containment proof runs before the anchor and the move so that no
|
||||
// slow work sits between the last guard and the mutation.
|
||||
if !preservedContainsLocalWork(ctx, wd, head, preserved) {
|
||||
return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the containment proof changed while custody was being returned; no files or refs were changed")
|
||||
}
|
||||
localAnchor := recoverLocalAnchorRef(run.ID)
|
||||
// Create-only: an empty old value requires the ref not to exist. A resumed
|
||||
// recovery legitimately finds its own anchor already at this head; an anchor
|
||||
// at any other commit is unexplained and refuses.
|
||||
existingAnchor, existingErr := git.Run(ctx, wd, "rev-parse", "--verify", localAnchor+"^{commit}")
|
||||
if existingErr == nil && existingAnchor != head {
|
||||
return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be anchored; no files or refs were changed")
|
||||
}
|
||||
if existingErr != nil {
|
||||
if _, err := git.Run(ctx, wd, "update-ref", localAnchor, head, ""); err != nil {
|
||||
return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be anchored; no files or refs were changed")
|
||||
}
|
||||
}
|
||||
if anchored, err := git.Run(ctx, wd, "rev-parse", localAnchor+"^{commit}"); err != nil || anchored != head {
|
||||
return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the pre-recovery local head could not be verified after anchoring; no files or worktree refs were changed")
|
||||
}
|
||||
|
||||
if s.beforeRecoverBranchMove != nil {
|
||||
s.beforeRecoverBranchMove()
|
||||
}
|
||||
branchRef := "refs/heads/" + state.Local.Branch
|
||||
boundaryBranch, boundaryErr := git.CurrentBranch(ctx, wd)
|
||||
if boundaryErr != nil || boundaryBranch != state.Local.Branch {
|
||||
return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the checked-out branch changed while custody was being returned; no branch or worktree changes were made")
|
||||
}
|
||||
if _, err := git.Run(ctx, wd, "update-ref", branchRef, preserved, head); err != nil {
|
||||
return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", "the local branch moved while custody was being returned; no files or refs were changed")
|
||||
}
|
||||
if s.afterRecoverBranchMove != nil {
|
||||
s.afterRecoverBranchMove()
|
||||
}
|
||||
// KNOWN BOUNDED FUNDAMENTAL-GIT LIMITATION: a concurrent git checkout
|
||||
// landing between this branch-identity verification and the read-tree
|
||||
// working-tree update can apply the preserved tree to another branch's
|
||||
// worktree. This is not data loss: containment is proven before the move,
|
||||
// the pre-recovery head stays anchored at
|
||||
// refs/no-mistakes/recover-local/<run>, custody is never stamped, and the
|
||||
// operation fails closed to a reported failure rather than a false success.
|
||||
// The window is sub-millisecond and inside a worktree the pipeline already
|
||||
// owns. It is irreducible because no single Git operation carries both
|
||||
// guards, and no lock git checkout honors can be held across the two
|
||||
// commands, so a further observation cannot close it. Keep this verification
|
||||
// even though it cannot make the two commands atomic.
|
||||
boundaryBranch, boundaryErr = git.CurrentBranch(ctx, wd)
|
||||
if boundaryErr != nil || boundaryBranch != state.Local.Branch {
|
||||
rollbackDetail := ""
|
||||
if _, rollbackErr := git.Run(ctx, wd, "update-ref", branchRef, head, preserved); rollbackErr != nil {
|
||||
rollbackDetail = fmt.Sprintf("; the branch could not be restored to %s and still requires manual reconciliation", head)
|
||||
}
|
||||
return blockedPlan(state, StatePipelineOwned, "blocked_recover_assumptions_changed", fmt.Sprintf("the checked-out branch changed while custody was being returned%s; custody was not recorded", rollbackDetail))
|
||||
}
|
||||
if _, err := git.Run(ctx, wd, "read-tree", "-m", "-u", head, preserved); err != nil {
|
||||
rolledBack := ""
|
||||
if _, rollbackErr := git.Run(ctx, wd, "update-ref", branchRef, head, preserved); rollbackErr != nil {
|
||||
rolledBack = fmt.Sprintf("; the branch could not be restored to %s and now points at %s, whose content the pre-recovery head is contained in", head, preserved)
|
||||
}
|
||||
blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_worktree_busy", fmt.Sprintf("the working tree changed while custody was being returned, so no file was overwritten%s; re-run the recovery once the working tree is settled", rolledBack))
|
||||
blocked.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"}
|
||||
return blocked
|
||||
}
|
||||
|
||||
finalHead, _ := git.HeadSHA(ctx, wd)
|
||||
finalClean, finalReason := worktreeClean(ctx, wd)
|
||||
state.Local.Head = finalHead
|
||||
state.Local.Clean = finalClean
|
||||
state.Local.Reason = finalReason
|
||||
state.Changed = finalHead == preserved && finalHead != head
|
||||
if finalHead != preserved {
|
||||
blocked := blockedPlan(state, StatePipelineOwned, "blocked_recover_apply_failed", fmt.Sprintf("adopting the preserved pipeline head did not reach it; final HEAD is %s and the pre-recovery head is anchored at %s; inspect the worktree before retrying", finalHead, localAnchor))
|
||||
blocked.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"}
|
||||
return blocked
|
||||
}
|
||||
if !finalClean {
|
||||
state.State = StateDirty
|
||||
state.Relation = RelationEqual
|
||||
state.Safety = "blocked_post_recover_" + finalReason
|
||||
state.Error = fmt.Sprintf("HEAD reached the preserved pipeline head, but the worktree is not clean; nothing was overwritten and the pre-recovery head is anchored at %s; custody was not recorded", localAnchor)
|
||||
state.NextAction = &NextAction{Code: "inspect_worktree", Command: "git status"}
|
||||
return state
|
||||
}
|
||||
return s.finishRecover(ctx, run, true)
|
||||
}
|
||||
|
||||
func (s *Service) anchorReachablePreserved(ctx context.Context, state State, anchorRef, preserved string) (State, bool) {
|
||||
if _, err := git.Run(ctx, s.workDir(), "update-ref", anchorRef, preserved); err != nil {
|
||||
return blockedPlan(state, StatePipelineOwned, "blocked_recover_preserve_failed", "the preserved pipeline commits could not be anchored locally; no files or refs were changed"), false
|
||||
@@ -701,6 +902,13 @@ func recoverAnchorRef(runID string) string {
|
||||
return "refs/no-mistakes/recover/" + runID
|
||||
}
|
||||
|
||||
// recoverLocalAnchorRef keeps the exact pre-recovery local commits reachable
|
||||
// when custody is returned by adopting an equivalent preserved head, which
|
||||
// leaves those SHAs unreferenced by the branch.
|
||||
func recoverLocalAnchorRef(runID string) string {
|
||||
return "refs/no-mistakes/recover-local/" + runID
|
||||
}
|
||||
|
||||
func (s *Service) inspect(ctx context.Context) (State, *db.Run, bool) {
|
||||
state := State{Relation: RelationUnknown, Safety: "blocked_ambiguous_context", Remote: RemoteState{Freshness: "unknown"}}
|
||||
root, err := git.FindGitRoot(s.workDir())
|
||||
|
||||
@@ -28,8 +28,9 @@ func newSyncCmd() *cobra.Command {
|
||||
"merges genuine divergence, rebases, switches branches, or updates a remote.\n" +
|
||||
"--check performs the fresh proof without applying it.\n" +
|
||||
"--recover returns custody of a branch whose run went terminal with unpublished\n" +
|
||||
"pipeline commits: it anchors the preserved head, fast-forwards a clean behind\n" +
|
||||
"worktree to it, and frees the branch for a fresh run. A run cancelled before\n" +
|
||||
"pipeline commits: it anchors the preserved head, then either fast-forwards a\n" +
|
||||
"clean behind worktree or adopts a diverged preserved head only when proven to\n" +
|
||||
"carry every local change. Unproven divergence refuses. A run cancelled before\n" +
|
||||
"the pipeline changed anything releases the branch by itself (user_owned) and\n" +
|
||||
"makes --recover a no-op. --recover --keep-local keeps the current local head\n" +
|
||||
"instead and never touches the worktree.",
|
||||
@@ -208,8 +209,9 @@ func runHumanRecover(cmd *cobra.Command, keepLocal, yes bool) error {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " possible changes are anchoring the preserved pipeline commits and moving the")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " local gate branch to your current head; the worktree is never touched.")
|
||||
} else {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " possible worktree change is a strict fast-forward of this clean branch to the")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " preserved pipeline head; anything else refuses without changes.")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " possible worktree change is a fast-forward of this clean behind branch, or")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " adoption of a diverged preserved head proven to carry every local change;")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " unproven divergence refuses, and --keep-local keeps the current head.")
|
||||
}
|
||||
fmt.Fprint(cmd.OutOrStdout(), " Return custody of this branch? [y/N] ")
|
||||
line, readErr := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
|
||||
|
||||
@@ -469,6 +469,161 @@ func TestAxiCustodyRecoveryJourney(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// rebaseCustodyScenario differs from branchSyncScenario in exactly one way that
|
||||
// matters here: its fix round ADDS a file instead of rewriting the operator's
|
||||
// own line. Both shapes advance the gate branch, but only this one leaves the
|
||||
// operator's content intact in the preserved head, which is the case custody
|
||||
// recovery is allowed to adopt.
|
||||
func rebaseCustodyScenario(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "rebase-custody-scenario.yaml")
|
||||
content := `actions:
|
||||
- match: "Investigate previous review findings"
|
||||
text: "added a guard helper"
|
||||
edits:
|
||||
- path: "guard.txt"
|
||||
new: "guard helper\n"
|
||||
structured:
|
||||
summary: "add a guard helper alongside the feature"
|
||||
- match: "Review the code changes and return structured findings"
|
||||
text: "review found a warning"
|
||||
structured:
|
||||
findings:
|
||||
- id: "rebase-1"
|
||||
severity: warning
|
||||
file: "feature.txt"
|
||||
line: 1
|
||||
description: "the feature needs a guard helper"
|
||||
action: auto-fix
|
||||
summary: "found one issue"
|
||||
risk_level: medium
|
||||
risk_rationale: "the feature needs a guard"
|
||||
- text: "no issues found"
|
||||
structured:
|
||||
findings: []
|
||||
summary: "no issues found"
|
||||
risk_level: low
|
||||
risk_rationale: "no remaining risk"
|
||||
tested: ["fakeagent: focused verification"]
|
||||
testing_summary: "simulated tests passed"
|
||||
title: "feat: rebase custody"
|
||||
body: "rebase custody journey"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write rebase custody scenario: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TestAxiCustodyRecoveryAfterRebaseJourney is the same cancelled-validation
|
||||
// custody return, in the shape that used to over-escalate: the default branch
|
||||
// advanced before the run, so the pipeline's own rebase step replayed the
|
||||
// operator's commits onto the newer base. The preserved gate head then carries
|
||||
// the same logical work under different SHAs, which equality and ancestry alone
|
||||
// read as plain divergence - and recovery refused, stranding a branch that
|
||||
// could lose nothing by adopting the preserved head. The journey proves the
|
||||
// real binary now auto-recovers, keeps the operator's file content, brings the
|
||||
// advanced base into the worktree, and anchors the exact pre-recovery commits.
|
||||
func TestAxiCustodyRecoveryAfterRebaseJourney(t *testing.T) {
|
||||
h := NewHarness(t, SetupOpts{Agent: "claude", Scenario: rebaseCustodyScenario(t)})
|
||||
h.CommitChange("init-rebase-recover", "seed.txt", "seed\n", "seed rebase recover init")
|
||||
initWorktree := h.AddWorktree("init-rebase-recover")
|
||||
if out, err := h.RunInDir(initWorktree, "init"); err != nil {
|
||||
t.Fatalf("init: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
submitted := h.CommitChange("feature/rebase-recover", "feature.txt", "unsafe\n", "add unsafe feature")
|
||||
|
||||
// The default branch advances before the run, which is what makes the
|
||||
// pipeline's rebase step produce new SHAs for the operator's commits.
|
||||
h.CommitChange("main", "upstream-advance.txt", "advance\n", "upstream advance")
|
||||
if out, err := h.runGit(context.Background(), h.WorkDir, "push", "origin", "main"); err != nil {
|
||||
t.Fatalf("advance upstream main: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
operator := h.AddWorktree("feature/rebase-recover")
|
||||
gateOut, err := h.RunInDir(operator, "axi", "run", "--intent", "guard the feature across a rebased base before cancellation")
|
||||
if err != nil || !strings.Contains(gateOut, "rebase-1") {
|
||||
t.Fatalf("initial review gate: %v\n%s", err, gateOut)
|
||||
}
|
||||
// Take the fix round, which adds a file without rewriting the operator's
|
||||
// line, then cancel. The preserved head is now the operator's own commits
|
||||
// replayed onto the advanced base plus one additive pipeline commit, so it
|
||||
// still carries every local change.
|
||||
fixOut, err := h.RunInDir(operator, "axi", "respond", "--action", "fix", "--findings", "rebase-1")
|
||||
if err != nil {
|
||||
t.Fatalf("review fix: %v\n%s", err, fixOut)
|
||||
}
|
||||
abortOut, abortErr := h.RunInDir(operator, "axi", "abort")
|
||||
if abortErr != nil {
|
||||
t.Fatalf("axi abort: %v\n%s", abortErr, abortOut)
|
||||
}
|
||||
run := h.WaitForRun("feature/rebase-recover", 30*time.Second)
|
||||
if run.Status != types.RunCancelled {
|
||||
t.Fatalf("run status after abort = %s", run.Status)
|
||||
}
|
||||
|
||||
gateDir := filepath.Join(h.NMHome, "repos", h.repoID()+".git")
|
||||
preservedBytes, err := h.runGit(context.Background(), gateDir, "rev-parse", "refs/heads/feature/rebase-recover")
|
||||
if err != nil {
|
||||
t.Fatalf("gate preserved head: %v\n%s", err, preservedBytes)
|
||||
}
|
||||
preserved := strings.TrimSpace(string(preservedBytes))
|
||||
if got := strings.TrimSpace(h.WorktreeRefSHA("feature/rebase-recover")); got != submitted {
|
||||
t.Fatalf("operator branch moved without explicit recovery: %s", got)
|
||||
}
|
||||
// The masking condition, asserted against the real gate: the rebase left
|
||||
// neither head an ancestor of the other.
|
||||
if _, ancErr := h.runGit(context.Background(), gateDir, "merge-base", "--is-ancestor", submitted, preserved); ancErr == nil {
|
||||
t.Fatalf("pipeline did not rebase: preserved %s still descends from submitted %s", preserved, submitted)
|
||||
}
|
||||
if _, ancErr := h.runGit(context.Background(), gateDir, "merge-base", "--is-ancestor", preserved, submitted); ancErr == nil {
|
||||
t.Fatalf("preserved head %s is an ancestor of submitted %s", preserved, submitted)
|
||||
}
|
||||
|
||||
recoverOut, err := h.RunInDir(operator, "axi", "sync", "--recover")
|
||||
if err != nil {
|
||||
t.Fatalf("rebase-superset recovery escalated instead of returning custody: %v\n%s", err, recoverOut)
|
||||
}
|
||||
for _, want := range []string{"recovered: true", "state: custody_returned", "changed: true", "no-mistakes axi run --intent"} {
|
||||
if !strings.Contains(recoverOut, want) {
|
||||
t.Errorf("recover output missing %q:\n%s", want, recoverOut)
|
||||
}
|
||||
}
|
||||
if got, gitErr := h.runGit(context.Background(), operator, "rev-parse", "HEAD"); gitErr != nil || strings.TrimSpace(string(got)) != preserved {
|
||||
t.Fatalf("operator HEAD after recovery = %s (err %v), want preserved %s", strings.TrimSpace(string(got)), gitErr, preserved)
|
||||
}
|
||||
// The operator's own work survived the adoption unchanged, the advanced
|
||||
// base arrived with it, and the exact pre-recovery commits stay reachable
|
||||
// through the local anchor.
|
||||
feature, readErr := os.ReadFile(filepath.Join(operator, "feature.txt"))
|
||||
if readErr != nil || strings.TrimSpace(string(feature)) != "unsafe" {
|
||||
t.Fatalf("operator feature content lost after recovery: %q (err %v)", string(feature), readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(operator, "upstream-advance.txt")); statErr != nil {
|
||||
t.Fatalf("adopted head did not bring the advanced base into the worktree: %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(operator, "guard.txt")); statErr != nil {
|
||||
t.Fatalf("adopted head did not bring the pipeline fix into the worktree: %v", statErr)
|
||||
}
|
||||
if out, gitErr := h.runGit(context.Background(), operator, "status", "--porcelain"); gitErr != nil || strings.TrimSpace(string(out)) != "" {
|
||||
t.Fatalf("worktree not clean after adoption: %q (err %v)", string(out), gitErr)
|
||||
}
|
||||
localAnchor := "refs/no-mistakes/recover-local/" + run.ID
|
||||
if got, gitErr := h.runGit(context.Background(), operator, "rev-parse", localAnchor); gitErr != nil || strings.TrimSpace(string(got)) != submitted {
|
||||
t.Fatalf("pre-recovery anchor %s = %s (err %v), want submitted %s", localAnchor, strings.TrimSpace(string(got)), gitErr, submitted)
|
||||
}
|
||||
|
||||
// Custody is back: a fresh run starts cleanly on the adopted head.
|
||||
freshOut, err := h.RunInDir(operator, "axi", "run", "--intent", "validate on top of the adopted rebased head")
|
||||
if err != nil {
|
||||
t.Fatalf("fresh pipeline start after rebase recovery: %v\n%s", err, freshOut)
|
||||
}
|
||||
if !strings.Contains(freshOut, "gate:") {
|
||||
t.Fatalf("fresh pipeline did not start cleanly after rebase recovery:\n%s", freshOut)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAxiPrePushAbortUnmovedHeadCustodyJourney reproduces the ownership gap
|
||||
// hit when delivery switches to a direct PR mid-validation: the worker aborts
|
||||
// the run at the review gate BEFORE the pipeline changes anything, so the
|
||||
|
||||
@@ -237,9 +237,11 @@ Before any post-pipeline local commit or fresh run, read the structured ` + "`br
|
||||
Only when its ` + "`next_action.code`" + ` is ` + "`sync`" + `, run ` + "`no-mistakes axi sync`" + ` first.
|
||||
That guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics; genuine divergence stays blocked.
|
||||
If it reports ` + "`next_action.code`" + ` is ` + "`continue_active_run`" + `, the pipeline still owns the branch: run the reported command, keep driving the active run, and do not make local follow-up commits.
|
||||
When ` + "`next_action.code`" + ` is ` + "`recover_custody`" + `, a terminal run left unpublished pipeline commits preserved in the local gate: run ` + "`no-mistakes axi sync --recover`" + ` to return custody and fast-forward to the preserved head, or ` + "`no-mistakes rerun`" + ` to resume validating it instead.
|
||||
When ` + "`next_action.code`" + ` is ` + "`recover_custody`" + `, a terminal run left unpublished pipeline commits preserved in the local gate: run ` + "`no-mistakes axi sync --recover`" + ` to return custody and take the preserved head, or ` + "`no-mistakes rerun`" + ` to resume validating it instead.
|
||||
Recovery takes that head by fast-forward, or by adopting a diverged preserved head proven to carry every local change - the ordinary result of the pipeline rebasing your commits onto a newer base - after anchoring your pre-recovery head under ` + "`refs/no-mistakes/recover-local/<run>`" + `.
|
||||
That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours.
|
||||
A ` + "`branch_sync.state`" + ` of ` + "`user_owned`" + ` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated ` + "`--recover`" + ` there is a harmless no-op.
|
||||
A dirty or diverged worktree makes the recovery refuse with explicit choices; ` + "`--keep-local`" + ` keeps your current head while the preserved commits stay anchored under ` + "`refs/no-mistakes/recover/<run>`" + `.
|
||||
A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; ` + "`--keep-local`" + ` keeps your current head while the preserved commits stay anchored under ` + "`refs/no-mistakes/recover/<run>`" + `.
|
||||
If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement.
|
||||
After synchronization, commit the follow-up on top and re-run ` + "`no-mistakes axi run --intent \"...\"`" + ` with the original user intent.
|
||||
This preserves every prior gate-fix commit regardless of its configured subject.
|
||||
|
||||
@@ -112,11 +112,12 @@ func renderRecoverConfirmation(state branchsync.State, width int) string {
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "The run ended %s without publishing its pipeline commits. Recovery returns\n", state.Pipeline.Status)
|
||||
fmt.Fprintf(&b, "custody of this branch and fast-forwards only a clean behind worktree.\n\n")
|
||||
fmt.Fprintf(&b, "custody by fast-forwarding a clean behind worktree, or by adopting a diverged\n")
|
||||
fmt.Fprintf(&b, "preserved head only when it is proven to carry every local change.\n\n")
|
||||
fmt.Fprintf(&b, "Local branch: %s\n", state.Local.Branch)
|
||||
fmt.Fprintf(&b, "Local HEAD: %s\n", state.Local.Head)
|
||||
fmt.Fprintf(&b, "Preserved HEAD: %s\n\n", state.Pipeline.CurrentHead)
|
||||
b.WriteString("Dirty or diverged worktrees refuse without changes; `no-mistakes sync --recover\n--keep-local` keeps the current head instead. `no-mistakes rerun` resumes validation.")
|
||||
b.WriteString("Dirty worktrees and divergence that cannot be proven contained refuse without changes; `no-mistakes sync --recover --keep-local` keeps the current head instead. `no-mistakes rerun` resumes validation.")
|
||||
return renderBoxWithFooter("Confirm custody recovery", b.String(), width, "u/enter recover · esc cancel")
|
||||
}
|
||||
|
||||
|
||||
@@ -237,9 +237,11 @@ Before any post-pipeline local commit or fresh run, read the structured `branch_
|
||||
Only when its `next_action.code` is `sync`, run `no-mistakes axi sync` first.
|
||||
That guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics; genuine divergence stays blocked.
|
||||
If it reports `next_action.code` is `continue_active_run`, the pipeline still owns the branch: run the reported command, keep driving the active run, and do not make local follow-up commits.
|
||||
When `next_action.code` is `recover_custody`, a terminal run left unpublished pipeline commits preserved in the local gate: run `no-mistakes axi sync --recover` to return custody and fast-forward to the preserved head, or `no-mistakes rerun` to resume validating it instead.
|
||||
When `next_action.code` is `recover_custody`, a terminal run left unpublished pipeline commits preserved in the local gate: run `no-mistakes axi sync --recover` to return custody and take the preserved head, or `no-mistakes rerun` to resume validating it instead.
|
||||
Recovery takes that head by fast-forward, or by adopting a diverged preserved head proven to carry every local change - the ordinary result of the pipeline rebasing your commits onto a newer base - after anchoring your pre-recovery head under `refs/no-mistakes/recover-local/<run>`.
|
||||
That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours.
|
||||
A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated `--recover` there is a harmless no-op.
|
||||
A dirty or diverged worktree makes the recovery refuse with explicit choices; `--keep-local` keeps your current head while the preserved commits stay anchored under `refs/no-mistakes/recover/<run>`.
|
||||
A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; `--keep-local` keeps your current head while the preserved commits stay anchored under `refs/no-mistakes/recover/<run>`.
|
||||
If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement.
|
||||
After synchronization, commit the follow-up on top and re-run `no-mistakes axi run --intent "..."` with the original user intent.
|
||||
This preserves every prior gate-fix commit regardless of its configured subject.
|
||||
|
||||
Reference in New Issue
Block a user