agent-relay-orchestrator 0.119.10 → 0.119.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/workspace-probe/merge.ts +263 -10
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ import { prMergedState } from "../workspace-pr";
|
|
|
7
7
|
import { deleteBranchIfSafe, owningRepoRoot } from "./cleanup";
|
|
8
8
|
import { refreshWorkspaceDeps } from "./deps";
|
|
9
9
|
import { type LandGatesResult } from "./land-gates-runner";
|
|
10
|
-
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, workspaceGitState } from "./git-state";
|
|
10
|
+
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
|
|
11
11
|
import { type MergePhase, mergePhaseTimeoutMs, throwIfMergeAborted, withMergePhaseTimeout } from "./merge-timeouts";
|
|
12
12
|
import { nextBranchName } from "./names";
|
|
13
13
|
import { parseWorktrees, shortBranch } from "./parse";
|
|
@@ -438,6 +438,66 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
|
|
|
438
438
|
* agent with no checkout once cleanup reclaims the worktree (#327).
|
|
439
439
|
* - Gone owner: reclaim the spent worktree/branch and go terminal `merged`.
|
|
440
440
|
*/
|
|
441
|
+
/**
|
|
442
|
+
* #950 — before a no-op land goes terminal `merged` (or recycles a live owner), verify the
|
|
443
|
+
* branch's landed work is actually on the UPSTREAM, not just on local base. A lost push race
|
|
444
|
+
* (see {@link mergeRebaseFf}) can leave a merge commit on local base that never reached origin;
|
|
445
|
+
* preview then fires `noop` because the branch is an ancestor of local base — and the old path
|
|
446
|
+
* blessed it as `merged` while origin never got it, diverging local base and wedging the repo.
|
|
447
|
+
* Outcomes:
|
|
448
|
+
* - "clean": no upstream / push disabled, or local base is already contained in origin
|
|
449
|
+
* (nothing unpublished) — safe to finalize as-is.
|
|
450
|
+
* - "published": local base was cleanly AHEAD of origin (carried the unpushed land) and we
|
|
451
|
+
* fast-forward-pushed it — now safe to finalize, with pushed=true.
|
|
452
|
+
* - "refuse": local base DIVERGED from origin (unpushed commits that can't fast-forward),
|
|
453
|
+
* or the publish push failed — must NOT finalize unpushed work as merged.
|
|
454
|
+
*/
|
|
455
|
+
async function publishNoopBaseIfStranded(
|
|
456
|
+
input: WorkspaceMergeInput,
|
|
457
|
+
worktreePath: string,
|
|
458
|
+
repoRoot: string,
|
|
459
|
+
base: string | undefined,
|
|
460
|
+
signal?: AbortSignal,
|
|
461
|
+
): Promise<"clean" | "published" | "refuse"> {
|
|
462
|
+
if (!base) return "clean";
|
|
463
|
+
if (input.push === false || !workspacePushEnabled()) return "clean";
|
|
464
|
+
const upstream = await upstreamRef(worktreePath, base, signal);
|
|
465
|
+
if (!upstream) return "clean";
|
|
466
|
+
const slash = upstream.indexOf("/");
|
|
467
|
+
const remote = slash > 0 ? upstream.slice(0, slash) : undefined;
|
|
468
|
+
if (!remote) return "clean";
|
|
469
|
+
throwIfMergeAborted(signal);
|
|
470
|
+
await mergeGit(["fetch", remote, base], worktreePath, "rebase", `workspace merge fetch ${remote}/${base} before noop finalize`, signal);
|
|
471
|
+
const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} before noop finalize`, signal)).stdout;
|
|
472
|
+
const baseSha = (await mergeGit(["rev-parse", "--verify", base], worktreePath, "rebase", `workspace merge resolve ${base} before noop finalize`, signal)).stdout;
|
|
473
|
+
if (!upstreamSha || !baseSha || baseSha === upstreamSha) return "clean";
|
|
474
|
+
// Local base fully contained in origin (behind/equal) — the land is already published.
|
|
475
|
+
if ((await mergeGit(["merge-base", "--is-ancestor", baseSha, upstreamSha], worktreePath, "rebase", `workspace merge check ${base} contained in ${upstream}`, signal)).ok) return "clean";
|
|
476
|
+
// Local base strictly ahead of origin — publish the stranded land with a fast-forward push.
|
|
477
|
+
if ((await mergeGit(["merge-base", "--is-ancestor", upstreamSha, baseSha], worktreePath, "rebase", `workspace merge check ${base} ahead of ${upstream}`, signal)).ok) {
|
|
478
|
+
throwIfMergeAborted(signal);
|
|
479
|
+
const push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge publish stranded ${base} to ${remote}`, signal);
|
|
480
|
+
return push.ok ? "published" : "refuse";
|
|
481
|
+
}
|
|
482
|
+
// Diverged: unpushed local commits AND origin has commits we lack. Left as-is, local base stays
|
|
483
|
+
// diverged and EVERY later real land hits the divergence refusal forever — a repo-wide host wedge
|
|
484
|
+
// (#950 follow-up SHOULD-FIX 3). Attempt an auto-reconcile: replay the unpushed commits onto fresh
|
|
485
|
+
// origin in a clean base checkout and publish, converting the divergence into a clean, published
|
|
486
|
+
// state. Content is preserved (SHAs of the reconciled commits change — a merge replays as its
|
|
487
|
+
// first-parent delta); this only fires on an already-wedged base, so a content-faithful publish
|
|
488
|
+
// beats a permanent refusal. If it can't be done cleanly/safely, refuse — the caller escalates to
|
|
489
|
+
// a CLEAR conflict (steward) rather than a silent perpetual refusal.
|
|
490
|
+
const localOnly = (await mergeGit(["rev-list", "--reverse", "--first-parent", `${upstreamSha}..${baseSha}`], worktreePath, "rebase", `workspace merge scan unpushed ${base} commits before reconcile`, signal)).stdout.split("\n").filter(Boolean);
|
|
491
|
+
if (localOnly.length === 0) return "refuse";
|
|
492
|
+
const baseWorktree = await worktreeForBranch(repoRoot, base, signal);
|
|
493
|
+
if (!baseWorktree || baseWorktree.dirty) return "refuse";
|
|
494
|
+
const replay = await replayCommitsOntoUpstream(baseWorktree.path, upstreamSha, localOnly, baseSha, base, signal);
|
|
495
|
+
if (!replay.ok) return "refuse";
|
|
496
|
+
throwIfMergeAborted(signal);
|
|
497
|
+
const push = await mergeGit(["push", remote, `${base}:${base}`], baseWorktree.path, "rebase", `workspace merge publish reconciled ${base} to ${remote}`, signal);
|
|
498
|
+
return push.ok ? "published" : "refuse";
|
|
499
|
+
}
|
|
500
|
+
|
|
441
501
|
async function resolveNoopMerge(
|
|
442
502
|
input: WorkspaceMergeInput,
|
|
443
503
|
worktreePath: string,
|
|
@@ -453,6 +513,16 @@ async function resolveNoopMerge(
|
|
|
453
513
|
signal?: AbortSignal,
|
|
454
514
|
): Promise<WorkspaceMergeResult> {
|
|
455
515
|
throwIfMergeAborted(signal);
|
|
516
|
+
// #950 — a `noop` preview means the branch is already an ancestor of LOCAL base, but that base
|
|
517
|
+
// may carry a merge commit a lost push race never published. Verify it's on origin (publishing
|
|
518
|
+
// it if we cleanly can) before going terminal — never bless unpushed work as `merged`.
|
|
519
|
+
const publishState = await publishNoopBaseIfStranded(input, worktreePath, repoRoot, preview.baseRef, signal);
|
|
520
|
+
if (publishState === "refuse") {
|
|
521
|
+
// Diverged and could not auto-reconcile (SHOULD-FIX 3): escalate to a CLEAR steward-actionable
|
|
522
|
+
// state instead of a benign review_requested that would perpetually re-refuse and wedge the repo.
|
|
523
|
+
return head({ conflict: true, status: "conflict", error: `local ${preview.baseRef ?? "base"} carries unpushed landed work that diverged from origin and could not be auto-reconciled; a steward must reconcile and land it (#950)` });
|
|
524
|
+
}
|
|
525
|
+
const pushedStranded = publishState === "published" ? { pushed: true } : {};
|
|
456
526
|
const ownerRepo = branch ? await owningRepoRoot(worktreePath, repoRoot) : repoRoot;
|
|
457
527
|
// Live owner (#327): recycle-to-continue instead of bricking the session.
|
|
458
528
|
if (input.deleteBranch === false) {
|
|
@@ -474,11 +544,11 @@ async function resolveNoopMerge(
|
|
|
474
544
|
const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
|
|
475
545
|
// merged:false → no `branch.landed` notice (nothing landed); newBranch makes the
|
|
476
546
|
// relay repoint the row and return it to `active` rather than terminal `merged`.
|
|
477
|
-
return head({ merged: false, noop: true, status: "active", baseSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, ...deleteResult, ...(reportDeps ? { depsRefresh } : {}), error: undefined });
|
|
547
|
+
return head({ merged: false, noop: true, status: "active", baseSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, ...deleteResult, ...(reportDeps ? { depsRefresh } : {}), ...pushedStranded, error: undefined });
|
|
478
548
|
}
|
|
479
549
|
}
|
|
480
550
|
// No base or checkout failed — stay live on the current branch, don't strand at `merged`.
|
|
481
|
-
return head({ merged: false, noop: true, status: "active", worktreeRemoved: false, branchDeleted: false, error: undefined });
|
|
551
|
+
return head({ merged: false, noop: true, status: "active", worktreeRemoved: false, branchDeleted: false, ...pushedStranded, error: undefined });
|
|
482
552
|
}
|
|
483
553
|
// Owner is gone — reclaim the spent worktree/branch and go terminal.
|
|
484
554
|
if (branch) {
|
|
@@ -486,9 +556,9 @@ async function resolveNoopMerge(
|
|
|
486
556
|
const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], ownerRepo, "cleanup", "workspace merge remove noop worktree", signal);
|
|
487
557
|
const worktreeRemoved = removed.ok;
|
|
488
558
|
const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, preview.baseRef, undefined, signal) : { branchDeleted: false };
|
|
489
|
-
return head({ status: "merged", noop: true, worktreeRemoved, ...deleteResult, error: undefined });
|
|
559
|
+
return head({ status: "merged", noop: true, worktreeRemoved, ...deleteResult, ...pushedStranded, error: undefined });
|
|
490
560
|
}
|
|
491
|
-
return head({ status: "merged", noop: true, error: undefined });
|
|
561
|
+
return head({ status: "merged", noop: true, ...pushedStranded, error: undefined });
|
|
492
562
|
}
|
|
493
563
|
|
|
494
564
|
async function mergePr(
|
|
@@ -660,6 +730,145 @@ async function syncLocalBaseToUpstream(
|
|
|
660
730
|
return { ok: true, baseSync };
|
|
661
731
|
}
|
|
662
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Restore `baseWorktreePath` to `restoreSha` — the PRE-replay snapshot that still carries the
|
|
735
|
+
* preserved local-only commits — after an aborted/failed replay (#950). Runs its git ops WITHOUT
|
|
736
|
+
* the merge signal on purpose: the replay reset already moved base to origin, so the preserved work
|
|
737
|
+
* survives ONLY in `restoreSha`; if this cleanup reused the (possibly-aborted) merge signal,
|
|
738
|
+
* `mergeGit` would rethrow immediately on the aborted signal (see its `throwIfMergeAborted` guard)
|
|
739
|
+
* and SKIP the restoration, stranding base half-reset at origin with the work dropped — the exact
|
|
740
|
+
* data loss a cancellation must not cause. Clears any in-progress cherry-pick, hard-resets to
|
|
741
|
+
* `restoreSha`, then VERIFIES HEAD actually landed on it. Returns whether the restore is PROVEN, so
|
|
742
|
+
* the caller can escalate a hard conflict when it cannot confirm the base is safe (never assume).
|
|
743
|
+
*/
|
|
744
|
+
async function restoreBaseWorktreeToSnapshot(baseWorktreePath: string, restoreSha: string, base: string): Promise<boolean> {
|
|
745
|
+
try {
|
|
746
|
+
// No signal: this must run to completion even under an aborted/timed-out merge.
|
|
747
|
+
await mergeGit(["cherry-pick", "--abort"], baseWorktreePath, "cleanup", `workspace merge abort in-progress replay of ${base}`);
|
|
748
|
+
const reset = await mergeGit(["reset", "--hard", restoreSha], baseWorktreePath, "cleanup", `workspace merge restore ${base} to pre-replay snapshot`);
|
|
749
|
+
if (!reset.ok) return false;
|
|
750
|
+
const head = (await mergeGit(["rev-parse", "HEAD"], baseWorktreePath, "cleanup", `workspace merge verify ${base} restored to snapshot`)).stdout.trim();
|
|
751
|
+
return head !== "" && head === restoreSha.trim();
|
|
752
|
+
} catch {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Replay `commits` (oldest-first) onto `upstreamSha` in a CLEAN base worktree via cherry-pick, so
|
|
759
|
+
* unpushed local commits that can no longer fast-forward are PRESERVED (their content re-lands on
|
|
760
|
+
* fresh origin) instead of being discarded by a hard reset (#950). Merge commits replay as their
|
|
761
|
+
* first-parent delta (`-m 1`). Commits are attributed to the relay identity like the no-ff land, so
|
|
762
|
+
* a base checkout without a configured git identity can still replay.
|
|
763
|
+
*
|
|
764
|
+
* ABORT-SAFE (#950 review): the reset-to-origin runs BEFORE the replay, so a merge cancellation /
|
|
765
|
+
* total-timeout firing after it would leave base at `upstreamSha` with the preserved commits gone.
|
|
766
|
+
* The whole replay therefore runs inside a try; on ANY failure OR abort we restore `restoreSha` with
|
|
767
|
+
* a FRESH (non-aborted) signal and VERIFY the restore landed. If restoration cannot be PROVEN we
|
|
768
|
+
* return an explicit `conflict` so the caller escalates to a steward rather than reporting a benign
|
|
769
|
+
* state over a half-reset base. The caller escalates for a manual/steward reconcile either way.
|
|
770
|
+
*/
|
|
771
|
+
async function replayCommitsOntoUpstream(
|
|
772
|
+
baseWorktreePath: string,
|
|
773
|
+
upstreamSha: string,
|
|
774
|
+
commits: string[],
|
|
775
|
+
restoreSha: string,
|
|
776
|
+
base: string,
|
|
777
|
+
signal?: AbortSignal,
|
|
778
|
+
): Promise<{ ok: true } | { ok: false; conflict?: boolean; error: string }> {
|
|
779
|
+
let failure: string | undefined;
|
|
780
|
+
try {
|
|
781
|
+
const reset = await mergeGit(["reset", "--hard", upstreamSha], baseWorktreePath, "rebase", `workspace merge rewind ${base} to origin before replay`, signal);
|
|
782
|
+
if (!reset.ok) {
|
|
783
|
+
failure = reset.stderr || `failed to rewind ${base} to origin before replaying unpushed commits`;
|
|
784
|
+
} else {
|
|
785
|
+
for (const sha of commits) {
|
|
786
|
+
throwIfMergeAborted(signal);
|
|
787
|
+
const parents = (await mergeGit(["rev-list", "--parents", "-n", "1", sha], baseWorktreePath, "rebase", `workspace merge inspect ${sha} parents before replay`, signal)).stdout.split(/\s+/).filter(Boolean);
|
|
788
|
+
const pickArgs = parents.length > 2
|
|
789
|
+
? ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "cherry-pick", "-m", "1", sha]
|
|
790
|
+
: ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "cherry-pick", sha];
|
|
791
|
+
const pick = await mergeGit(pickArgs, baseWorktreePath, "rebase", `workspace merge replay unpushed ${sha} onto origin`, signal);
|
|
792
|
+
if (!pick.ok) {
|
|
793
|
+
failure = `cannot cleanly replay unpushed ${base} commit ${sha.slice(0, 9)} onto origin; needs manual reconcile (#950)`;
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
} catch (err) {
|
|
799
|
+
// Abort / total-timeout / unexpected throw mid-replay — base may be half-reset at origin.
|
|
800
|
+
failure = `replay of unpushed ${base} commits interrupted before completion (${errMessage(err)}); restoring ${base} (#950)`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (!failure) return { ok: true };
|
|
804
|
+
|
|
805
|
+
// Restore the pre-replay snapshot (still holding the preserved commits) with a fresh signal, then
|
|
806
|
+
// PROVE it landed. If we cannot, the base may be stranded at origin with the work dropped — a hard
|
|
807
|
+
// conflict for a steward, never a silent half-reset.
|
|
808
|
+
const restored = await restoreBaseWorktreeToSnapshot(baseWorktreePath, restoreSha, base);
|
|
809
|
+
if (!restored) {
|
|
810
|
+
return { ok: false, conflict: true, error: `${failure}; and could not restore ${base} to its pre-replay snapshot ${restoreSha.slice(0, 9)} — base may be left reset to origin with unpushed work dropped; escalating to steward (#950)` };
|
|
811
|
+
}
|
|
812
|
+
return { ok: false, error: failure };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Undo a base-ref advance whose publish lost a push race (#950). The merge commit is only on the
|
|
817
|
+
* LOCAL base and origin moved past us, so it can no longer fast-forward — stranding it would
|
|
818
|
+
* diverge local base from origin and wedge every later land (preview would see the branch as an
|
|
819
|
+
* ancestor of local base → noop → terminal `merged` for work never published). Rewind local base
|
|
820
|
+
* to the fetched origin tip so local == origin: no phantom merge, no divergence.
|
|
821
|
+
*
|
|
822
|
+
* MUST-FIX (#950 review): local base may carry PRE-EXISTING unpushed commits that are NOT the merge
|
|
823
|
+
* we just created (e.g. work stranded by an EARLIER push race). Committed work isn't dirty, so the
|
|
824
|
+
* clean-worktree guard doesn't catch it — a blind hard reset to origin would DISCARD it. So compute
|
|
825
|
+
* the local-only commits on the PRE-land base tip (`preLandBaseSha`, which excludes the merge we're
|
|
826
|
+
* intentionally dropping); if there are none, rewind to origin as before; if there are some, PRESERVE
|
|
827
|
+
* them by replaying onto fresh origin (a clean worktree is required to do this safely — otherwise
|
|
828
|
+
* REFUSE and surface for a steward, never reset over the unpushed work). Returns an explicit
|
|
829
|
+
* success/failure so the caller can VERIFY the base actually healed before reporting a recoverable
|
|
830
|
+
* state (a failed rollback must not masquerade as healed).
|
|
831
|
+
*/
|
|
832
|
+
async function rewindBaseAfterPushRace(
|
|
833
|
+
repoRoot: string,
|
|
834
|
+
base: string,
|
|
835
|
+
advancedBaseTip: string,
|
|
836
|
+
preLandBaseSha: string | undefined,
|
|
837
|
+
upstreamSha: string,
|
|
838
|
+
baseWorktree: { path: string; dirty: boolean } | undefined,
|
|
839
|
+
dirtyBefore: Set<string> | undefined,
|
|
840
|
+
signal?: AbortSignal,
|
|
841
|
+
): Promise<{ ok: true } | { ok: false; conflict?: boolean; error: string }> {
|
|
842
|
+
// Commits on the PRE-land base tip that origin lacks — pre-existing unpushed work, NOT the merge
|
|
843
|
+
// we just made (that lives only on advancedBaseTip). These must survive the rewind.
|
|
844
|
+
// `--first-parent` walks base's MAINLINE (the sequence of lands): each merge replays once as its
|
|
845
|
+
// first-parent delta (`-m 1`) instead of also re-applying the branch commits it already contains.
|
|
846
|
+
const localOnly = preLandBaseSha
|
|
847
|
+
? (await mergeGit(["rev-list", "--reverse", "--first-parent", `${upstreamSha}..${preLandBaseSha}`], repoRoot, "rebase", `workspace merge scan unpushed ${base} commits before rewind`, signal)).stdout.split("\n").filter(Boolean)
|
|
848
|
+
: [];
|
|
849
|
+
|
|
850
|
+
if (localOnly.length === 0) {
|
|
851
|
+
// Nothing but the phantom merge to drop — safe to rewind straight to origin.
|
|
852
|
+
if (baseWorktree && !baseWorktree.dirty) {
|
|
853
|
+
const reset = await mergeGit(["reset", "--hard", upstreamSha], baseWorktree.path, "rebase", `workspace merge rewind ${base} to ${upstreamSha} after push race`, signal);
|
|
854
|
+
if (!reset.ok) return { ok: false, error: reset.stderr || `failed to rewind ${base} worktree to origin after push race` };
|
|
855
|
+
return { ok: true };
|
|
856
|
+
}
|
|
857
|
+
const update = await mergeGit(["update-ref", `refs/heads/${base}`, upstreamSha, advancedBaseTip], repoRoot, "rebase", `workspace merge rewind ${base} ref after push race`, signal);
|
|
858
|
+
if (!update.ok) return { ok: false, error: update.stderr || `failed to rewind ${base} ref to origin after push race` };
|
|
859
|
+
if (baseWorktree?.dirty && dirtyBefore) await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, advancedBaseTip, upstreamSha, dirtyBefore, signal);
|
|
860
|
+
return { ok: true };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Pre-existing unpushed commits present. Replaying requires a CLEAN base checkout; without one we
|
|
864
|
+
// refuse rather than risk discarding committed work — the branch and the unpushed commits both
|
|
865
|
+
// stay put and a steward reconciles.
|
|
866
|
+
if (!baseWorktree || baseWorktree.dirty) {
|
|
867
|
+
return { ok: false, error: `local ${base} carries ${localOnly.length} unpushed commit(s) not on origin and its checkout is ${baseWorktree ? "dirty" : "absent"}; refusing to rewind (would discard committed work) — needs manual/steward reconcile (#950)` };
|
|
868
|
+
}
|
|
869
|
+
return await replayCommitsOntoUpstream(baseWorktree.path, upstreamSha, localOnly, advancedBaseTip, base, signal);
|
|
870
|
+
}
|
|
871
|
+
|
|
663
872
|
async function mergeRebaseFf(
|
|
664
873
|
input: WorkspaceMergeInput,
|
|
665
874
|
worktreePath: string,
|
|
@@ -782,6 +991,10 @@ async function mergeRebaseFf(
|
|
|
782
991
|
// land: fall through to ref-plumbing (update-ref / synthesized no-ff merge) below, then
|
|
783
992
|
// best-effort sync clean landed paths in the dirty checkout while preserving WIP (#681).
|
|
784
993
|
let baseTip = headSha;
|
|
994
|
+
// Snapshot base's PRE-advance tip so a lost-push-race rewind can tell the merge we're about to
|
|
995
|
+
// create (which it should drop) apart from any PRE-EXISTING unpushed commits on base (which it
|
|
996
|
+
// must PRESERVE, not hard-reset over) — #950 data-loss guard.
|
|
997
|
+
const preLandBaseSha = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge snapshot ${base} tip before advance`, signal)).stdout || undefined;
|
|
785
998
|
throwIfMergeAborted(signal);
|
|
786
999
|
const baseWorktree = await worktreeForBranch(repoRoot, base, signal);
|
|
787
1000
|
const dirtyBasePathsBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path, signal) : undefined;
|
|
@@ -830,14 +1043,54 @@ async function mergeRebaseFf(
|
|
|
830
1043
|
const baseWorktreeSyncField = baseSync && !baseSync.reconciled ? { baseWorktreeSync: baseSync } : {};
|
|
831
1044
|
if (baseSync && !baseSync.reconciled && baseWorktree) logBaseWorktreeStrand(base, baseWorktree.path, baseSync);
|
|
832
1045
|
|
|
833
|
-
// Publish the advanced base so local and origin converge (#190). We verified
|
|
834
|
-
//
|
|
835
|
-
//
|
|
1046
|
+
// Publish the advanced base so local and origin converge (#190). We verified origin was an
|
|
1047
|
+
// ancestor of base above, so this is a fast-forward — but that check ran BEFORE the base
|
|
1048
|
+
// advance, and origin can move in the fetch→push window (#950: a sibling host landing the same
|
|
1049
|
+
// origin, CI, a human push). If it did, this push is a non-ff and is rejected AFTER local base
|
|
1050
|
+
// already carries the merge commit. Leaving the merge stranded on local base is the historical
|
|
1051
|
+
// multi-host land wedge: local base diverges from origin, later previews see the branch as an
|
|
1052
|
+
// ancestor of local base → noop → terminal `merged` for work never published, and every land
|
|
1053
|
+
// after refuses on divergence. So on a push failure, re-fetch and try to publish in the SAME
|
|
1054
|
+
// execution; if origin genuinely moved past us, rewind local base to the fresh origin tip
|
|
1055
|
+
// (local == origin, no phantom, no divergence) and bounce to review_requested for the next scan.
|
|
836
1056
|
let pushed = false;
|
|
837
1057
|
if (upstream && remote && pushEnabled) {
|
|
838
1058
|
throwIfMergeAborted(signal);
|
|
839
|
-
|
|
840
|
-
if (!push.ok)
|
|
1059
|
+
let push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge push ${base} to ${remote}`, signal);
|
|
1060
|
+
if (!push.ok) {
|
|
1061
|
+
throwIfMergeAborted(signal);
|
|
1062
|
+
const refetch = await mergeGit(["fetch", remote, base], worktreePath, "rebase", `workspace merge re-fetch ${remote}/${base} after push race`, signal);
|
|
1063
|
+
const upstreamSha = refetch.ok ? (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} after push race`, signal)).stdout : "";
|
|
1064
|
+
// Transient loss (a ref lock, or a compatible parallel push): origin is still an ancestor
|
|
1065
|
+
// of our advanced base, so the merge is a clean fast-forward — just re-push it.
|
|
1066
|
+
if (upstreamSha && (await mergeGit(["merge-base", "--is-ancestor", upstreamSha, baseTip], worktreePath, "rebase", "workspace merge check base still ff after push race", signal)).ok) {
|
|
1067
|
+
throwIfMergeAborted(signal);
|
|
1068
|
+
push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge re-push ${base} to ${remote} after race`, signal);
|
|
1069
|
+
}
|
|
1070
|
+
if (!push.ok) {
|
|
1071
|
+
// Origin moved past us — the advanced merge can't fast-forward. Rewind local base to the
|
|
1072
|
+
// fresh origin tip so nothing is stranded (no mergedSha: nothing landed), PRESERVING any
|
|
1073
|
+
// pre-existing unpushed commits (#950 MUST-FIX 1). Then VERIFY the base actually healed
|
|
1074
|
+
// before reporting the retryable review_requested — a failed rollback must surface a HARD
|
|
1075
|
+
// state (conflict → steward), never masquerade as healed (#950 MUST-FIX 2).
|
|
1076
|
+
if (!upstreamSha) {
|
|
1077
|
+
return head({ conflict: true, status: "conflict", error: `push to ${remote}/${base} failed and the fresh origin tip is unresolvable; cannot safely rewind ${base} — escalating (#950)` });
|
|
1078
|
+
}
|
|
1079
|
+
const rewind = await rewindBaseAfterPushRace(repoRoot, base, baseTip, preLandBaseSha, upstreamSha, baseWorktree, dirtyBasePathsBefore, signal);
|
|
1080
|
+
if (!rewind.ok) {
|
|
1081
|
+
return head({ conflict: true, status: "conflict", error: rewind.error });
|
|
1082
|
+
}
|
|
1083
|
+
// Verify, don't assume: local base must now contain the fresh origin tip AND must NOT still
|
|
1084
|
+
// carry the phantom merge we failed to publish. Anything else means the base is still wedged.
|
|
1085
|
+
const healedBaseSha = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge verify ${base} healed after push race`, signal)).stdout;
|
|
1086
|
+
const originContained = Boolean(healedBaseSha) && (await mergeGit(["merge-base", "--is-ancestor", upstreamSha, healedBaseSha], repoRoot, "rebase", `workspace merge verify origin contained in ${base} after rewind`, signal)).ok;
|
|
1087
|
+
const phantomDropped = Boolean(healedBaseSha) && !(await mergeGit(["merge-base", "--is-ancestor", baseTip, healedBaseSha], repoRoot, "rebase", `workspace merge verify phantom dropped from ${base} after rewind`, signal)).ok;
|
|
1088
|
+
if (!originContained || !phantomDropped) {
|
|
1089
|
+
return head({ conflict: true, status: "conflict", error: `rewind of ${base} after push race did not heal (origin ${originContained ? "contained" : "MISSING"}, phantom ${phantomDropped ? "dropped" : "STILL PRESENT"}); escalating rather than reporting healed (#950)` });
|
|
1090
|
+
}
|
|
1091
|
+
return head({ status: "review_requested", error: push.stderr || `git push to ${remote}/${base} failed` });
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
841
1094
|
pushed = true;
|
|
842
1095
|
}
|
|
843
1096
|
|