agent-relay-orchestrator 0.118.4 → 0.118.5
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/control.ts +37 -20
- package/src/workspace-probe/merge-timeouts.ts +5 -1
- package/src/workspace-probe/merge.ts +77 -47
package/package.json
CHANGED
package/src/control.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { handleSelfUpgrade } from "./self-upgrade";
|
|
|
6
6
|
import { readLocalProviderConfigs } from "./provider-config-migration";
|
|
7
7
|
import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
|
|
8
8
|
import { cleanupWorkspace, discardRecoveryBranch, idleRefreshWorktree, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
|
|
9
|
+
import { withMergePhaseTimeout } from "./workspace-probe/merge-timeouts";
|
|
9
10
|
import { armWorkspacePrAutoMerge, mergeWorkspacePr, refreshWorkspacePrBranch } from "./workspace-pr";
|
|
10
11
|
import type { WorkspaceMergeResult } from "agent-relay-sdk";
|
|
11
12
|
|
|
@@ -118,26 +119,42 @@ export function createControlHandler(
|
|
|
118
119
|
});
|
|
119
120
|
await relay.updateCommand(command.id, "succeeded", result);
|
|
120
121
|
} else if (command.type === "workspace.merge") {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
?
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
122
|
+
let result: WorkspaceMergeResult;
|
|
123
|
+
try {
|
|
124
|
+
result = await withMergePhaseTimeout("total", () => mergeWorkspace({
|
|
125
|
+
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
126
|
+
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
127
|
+
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
128
|
+
branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
|
|
129
|
+
baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
|
|
130
|
+
baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
|
|
131
|
+
strategy: command.params.strategy === "pr" || command.params.strategy === "rebase-ff" || command.params.strategy === "auto" ? command.params.strategy : undefined,
|
|
132
|
+
deleteBranch: command.params.deleteBranch !== false,
|
|
133
|
+
push: command.params.push !== false,
|
|
134
|
+
prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
|
|
135
|
+
prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
|
|
136
|
+
autoMerge: command.params.autoMerge === "on-green" || command.params.autoMerge === "on-approval" || command.params.autoMerge === "manual" ? command.params.autoMerge : undefined,
|
|
137
|
+
prLanded: isRecord(command.params.prLanded)
|
|
138
|
+
? {
|
|
139
|
+
sha: typeof command.params.prLanded.sha === "string" ? command.params.prLanded.sha : undefined,
|
|
140
|
+
subject: typeof command.params.prLanded.subject === "string" ? command.params.prLanded.subject : undefined,
|
|
141
|
+
}
|
|
142
|
+
: undefined,
|
|
143
|
+
}));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const branch = typeof command.params.branch === "string" ? command.params.branch : undefined;
|
|
146
|
+
const baseRef = typeof command.params.baseRef === "string" ? command.params.baseRef : undefined;
|
|
147
|
+
result = {
|
|
148
|
+
workspaceId: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
149
|
+
strategy: command.params.strategy === "pr" ? "pr" : "rebase-ff",
|
|
150
|
+
merged: false,
|
|
151
|
+
status: "review_requested",
|
|
152
|
+
...(branch ? { branch } : {}),
|
|
153
|
+
...(baseRef ? { baseRef } : {}),
|
|
154
|
+
error: errMessage(err),
|
|
155
|
+
};
|
|
156
|
+
console.error(`[orchestrator] workspace.merge failed before completion: ${result.error}`);
|
|
157
|
+
}
|
|
141
158
|
// #638 — settle `failed` (carrying the error) for a no-op merge instead of
|
|
142
159
|
// `succeeded`; see mergeCommandStatus.
|
|
143
160
|
await relay.updateCommand(command.id, mergeCommandStatus(result), result as unknown as Record<string, unknown>, result.error);
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
export type MergePhase = "preview" | "fetch" | "synthesize" | "worktree-add" | "cleanup";
|
|
1
|
+
export type MergePhase = "total" | "prep" | "preview" | "fetch" | "rebase" | "gates" | "synthesize" | "worktree-add" | "cleanup";
|
|
2
2
|
|
|
3
3
|
const MERGE_PHASE_TIMEOUTS_MS: Record<MergePhase, number> = {
|
|
4
|
+
total: 10 * 60_000,
|
|
5
|
+
prep: 60_000,
|
|
4
6
|
preview: 30_000,
|
|
5
7
|
fetch: 60_000,
|
|
8
|
+
rebase: 60_000,
|
|
9
|
+
gates: 5 * 60_000,
|
|
6
10
|
synthesize: 60_000,
|
|
7
11
|
"worktree-add": 60_000,
|
|
8
12
|
cleanup: 30_000,
|
|
@@ -7,29 +7,45 @@ import { execProcess } from "../process";
|
|
|
7
7
|
import { prMergedState } from "../workspace-pr";
|
|
8
8
|
import { refreshWorkspaceDeps } from "./deps";
|
|
9
9
|
import { type LandGatesResult, runLandGates } from "./land-gates-runner";
|
|
10
|
-
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin,
|
|
11
|
-
import { mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
|
|
10
|
+
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, workspaceGitState } from "./git-state";
|
|
11
|
+
import { type MergePhase, mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
|
|
12
12
|
import { nextBranchName } from "./names";
|
|
13
13
|
import { parseWorktrees, shortBranch } from "./parse";
|
|
14
14
|
import { json, resolveRequestedPath } from "./request";
|
|
15
15
|
import type { WorkspaceMergeInput } from "./types";
|
|
16
16
|
import { workspacePushEnabled } from "../config";
|
|
17
17
|
|
|
18
|
+
async function mergeGit(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string): ReturnType<typeof git> {
|
|
19
|
+
return git(args, cwd, {
|
|
20
|
+
timeoutMs: mergePhaseTimeoutMs(phase),
|
|
21
|
+
timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function mergeGitRaw(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string): ReturnType<typeof gitRaw> {
|
|
26
|
+
return gitRaw(args, cwd, {
|
|
27
|
+
timeoutMs: mergePhaseTimeoutMs(phase),
|
|
28
|
+
timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function gitError(result: Awaited<ReturnType<typeof git>>, fallback: string): string {
|
|
33
|
+
return result.stderr || result.stdout || fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
/** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
|
|
19
|
-
async function countBehind(worktreePath: string, base: string): Promise<number> {
|
|
20
|
-
const counts = await
|
|
21
|
-
if (!counts.ok || !counts.stdout) return
|
|
37
|
+
async function countBehind(worktreePath: string, base: string): Promise<{ behind: number } | { error: string }> {
|
|
38
|
+
const counts = await mergeGit(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath, "rebase", "workspace merge count behind integration base");
|
|
39
|
+
if (!counts.ok || !counts.stdout) return { error: gitError(counts, "failed to count branch behind integration base") };
|
|
22
40
|
const behind = Number(counts.stdout.split(/\s+/)[0]);
|
|
23
|
-
return Number.isFinite(behind) ? behind :
|
|
41
|
+
return Number.isFinite(behind) ? { behind } : { error: "git rev-list produced an invalid behind count" };
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
async function hasOriginRemote(cwd: string): Promise<boolean> {
|
|
27
45
|
return (await git(["remote", "get-url", "origin"], cwd)).ok;
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
function ghAvailable(): boolean {
|
|
31
|
-
return Boolean(Bun.which("gh"));
|
|
32
|
-
}
|
|
48
|
+
function ghAvailable(): boolean { return Boolean(Bun.which("gh")); }
|
|
33
49
|
|
|
34
50
|
/**
|
|
35
51
|
* Ground-truth merge state for `branch`, via gh.
|
|
@@ -61,11 +77,11 @@ async function baseBranchName(worktreePath: string, baseRef?: string): Promise<s
|
|
|
61
77
|
|
|
62
78
|
/** Locate the worktree (if any) that currently has `branch` checked out. */
|
|
63
79
|
async function worktreeForBranch(repoRoot: string, branch: string): Promise<{ path: string; dirty: boolean } | undefined> {
|
|
64
|
-
const list = await
|
|
80
|
+
const list = await mergeGit(["worktree", "list", "--porcelain"], repoRoot, "rebase", "workspace merge list worktrees");
|
|
65
81
|
if (!list.ok) return undefined;
|
|
66
82
|
const match = parseWorktrees(list.stdout).find((worktree) => worktree.branch === branch);
|
|
67
83
|
if (!match) return undefined;
|
|
68
|
-
const status = await
|
|
84
|
+
const status = await mergeGit(["status", "--porcelain"], match.path, "rebase", `workspace merge status ${branch} worktree`);
|
|
69
85
|
return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
|
|
70
86
|
}
|
|
71
87
|
|
|
@@ -74,7 +90,7 @@ function splitNul(stdout: string): string[] {
|
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
|
|
77
|
-
const status = await
|
|
93
|
+
const status = await mergeGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath, "rebase", "workspace merge dirty path scan");
|
|
78
94
|
if (!status.ok) return new Set<string>();
|
|
79
95
|
const paths = new Set<string>();
|
|
80
96
|
const entries = splitNul(status.stdout);
|
|
@@ -90,25 +106,25 @@ async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
|
|
|
90
106
|
}
|
|
91
107
|
|
|
92
108
|
async function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string): Promise<string[]> {
|
|
93
|
-
const diff = await
|
|
109
|
+
const diff = await mergeGitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath, "rebase", "workspace merge changed path scan");
|
|
94
110
|
return diff.ok ? splitNul(diff.stdout) : [];
|
|
95
111
|
}
|
|
96
112
|
|
|
97
113
|
async function restorePathsFromHead(worktreePath: string, paths: string[]): Promise<boolean> {
|
|
98
114
|
if (paths.length === 0) return true;
|
|
99
|
-
return (await
|
|
115
|
+
return (await mergeGit(["restore", "--staged", "--worktree", "--", ...paths], worktreePath, "rebase", "workspace merge restore clean landed paths")).ok;
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
async function resetIndexPathsToHead(worktreePath: string, paths: string[]): Promise<boolean> {
|
|
103
119
|
if (paths.length === 0) return true;
|
|
104
|
-
return (await
|
|
120
|
+
return (await mergeGit(["reset", "-q", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge reset preserved paths")).ok;
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
/** Landed paths whose working copy still differs from the advanced HEAD (ground truth, not
|
|
108
124
|
* exit codes): the checkout is mixed for these — reads/builds/publishes see STALE files. */
|
|
109
125
|
async function pathsDifferingFromHead(worktreePath: string, paths: string[]): Promise<string[]> {
|
|
110
126
|
if (paths.length === 0) return [];
|
|
111
|
-
const diff = await
|
|
127
|
+
const diff = await mergeGitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge verify base worktree sync");
|
|
112
128
|
// A failed diff can't prove the checkout is clean — treat every landed path as suspect.
|
|
113
129
|
return diff.ok ? splitNul(diff.stdout) : [...paths];
|
|
114
130
|
}
|
|
@@ -152,7 +168,7 @@ async function syncDirtyBaseWorktreeAfterRefAdvance(
|
|
|
152
168
|
dirtyBefore: Set<string> | undefined,
|
|
153
169
|
): Promise<BaseWorktreeSyncResult> {
|
|
154
170
|
if (!baseWorktree?.dirty || !dirtyBefore) return RECONCILED;
|
|
155
|
-
const readTree = await
|
|
171
|
+
const readTree = await mergeGit(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path, "rebase", "workspace merge sync dirty base worktree");
|
|
156
172
|
if (readTree.ok) return RECONCILED;
|
|
157
173
|
|
|
158
174
|
const changedPaths = await changedPathList(baseWorktree.path, oldBaseTip, newBaseTip);
|
|
@@ -352,11 +368,12 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
|
|
|
352
368
|
if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
|
|
353
369
|
const worktreePath = resolve(input.worktreePath);
|
|
354
370
|
const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
|
|
371
|
+
console.error(`[orchestrator] workspace.merge prep-start workspace=${input.id ?? "(unknown)"} worktree=${worktreePath} repo=${repoRoot}`);
|
|
355
372
|
// Probe the live HEAD branch first — it's the authoritative source. Fall back to the
|
|
356
373
|
// DB-recorded branch only when the live probe fails (detached HEAD, missing worktree, etc.).
|
|
357
374
|
// This fixes #232: a stale DB branch value (non-null mismatch) would pass through the
|
|
358
375
|
// `input.branch ?? ...` guard unchanged and cause git to attempt merging a non-existent ref.
|
|
359
|
-
const liveBranch = shortBranch((await
|
|
376
|
+
const liveBranch = shortBranch((await mergeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, "prep", "workspace merge resolve live branch")).stdout || undefined);
|
|
360
377
|
const branch = liveBranch ?? input.branch;
|
|
361
378
|
let preview: WorkspaceMergePreview;
|
|
362
379
|
try {
|
|
@@ -398,6 +415,7 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
|
|
|
398
415
|
if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
|
|
399
416
|
|
|
400
417
|
if (strategy === "pr") return await mergePr(input, worktreePath, branch, preview, head);
|
|
418
|
+
console.error(`[orchestrator] workspace.merge rebase-start workspace=${input.id ?? "(unknown)"} branch=${branch ?? "(unknown)"} base=${preview.baseRef ?? "(unknown)"}`);
|
|
401
419
|
return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
|
|
402
420
|
}
|
|
403
421
|
|
|
@@ -559,9 +577,9 @@ async function recordNoFfMerge(
|
|
|
559
577
|
branchSha: string,
|
|
560
578
|
message: string,
|
|
561
579
|
): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
|
|
562
|
-
const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message);
|
|
580
|
+
const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message, mergePhaseTimeoutMs("synthesize"));
|
|
563
581
|
if (!synth.ok) return synth;
|
|
564
|
-
const update = await
|
|
582
|
+
const update = await mergeGit(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot, "rebase", `workspace merge advance ${base} to synthesized merge`);
|
|
565
583
|
if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
|
|
566
584
|
return { ok: true, mergeSha: synth.mergeSha };
|
|
567
585
|
}
|
|
@@ -587,7 +605,8 @@ async function runLandGatesOnIntegratedTree(
|
|
|
587
605
|
headSha: string,
|
|
588
606
|
mergeMessage: string,
|
|
589
607
|
): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
|
|
590
|
-
|
|
608
|
+
console.error(`[orchestrator] workspace.merge gate-start worktree=${worktreePath} behind=${behind}`);
|
|
609
|
+
if (behind === 0) return { gates: await withMergePhaseTimeout("gates", () => runLandGates(worktreePath)) };
|
|
591
610
|
|
|
592
611
|
let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
|
|
593
612
|
try {
|
|
@@ -618,7 +637,7 @@ async function runLandGatesOnIntegratedTree(
|
|
|
618
637
|
return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
|
|
619
638
|
}
|
|
620
639
|
try {
|
|
621
|
-
return { gates: await runLandGates(tmpWorktree) };
|
|
640
|
+
return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree)) };
|
|
622
641
|
} finally {
|
|
623
642
|
try {
|
|
624
643
|
await withMergePhaseTimeout("cleanup", () => git(
|
|
@@ -647,7 +666,7 @@ async function syncLocalBaseToUpstream(
|
|
|
647
666
|
base: string,
|
|
648
667
|
upstream: string,
|
|
649
668
|
): Promise<{ ok: true; baseSync?: BaseWorktreeSyncResult } | { ok: false; error: string }> {
|
|
650
|
-
const upstreamSha = (await
|
|
669
|
+
const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream}`)).stdout;
|
|
651
670
|
if (!upstreamSha) return { ok: false, error: `cannot resolve ${upstream} to sync ${base}` };
|
|
652
671
|
const baseWorktree = await worktreeForBranch(repoRoot, base);
|
|
653
672
|
// Sync IN the base worktree only when it's clean — that keeps its working tree consistent
|
|
@@ -656,14 +675,14 @@ async function syncLocalBaseToUpstream(
|
|
|
656
675
|
// lands: advance the ref directly with update-ref, then best-effort sync the checked-out
|
|
657
676
|
// index/worktree forward for paths that are not human-modified (#681).
|
|
658
677
|
if (baseWorktree && !baseWorktree.dirty) {
|
|
659
|
-
const ff = await
|
|
678
|
+
const ff = await mergeGit(["merge", "--ff-only", upstream], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${upstream}`);
|
|
660
679
|
if (!ff.ok) return { ok: false, error: ff.stderr || `failed to fast-forward ${base} to ${upstream}` };
|
|
661
680
|
return { ok: true };
|
|
662
681
|
}
|
|
663
|
-
const oldBaseTip = (await
|
|
682
|
+
const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before upstream sync`)).stdout;
|
|
664
683
|
const dirtyBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
|
|
665
684
|
const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha];
|
|
666
|
-
const update = await
|
|
685
|
+
const update = await mergeGit(updateArgs, repoRoot, "rebase", `workspace merge update ${base} to ${upstream}`);
|
|
667
686
|
if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` };
|
|
668
687
|
const baseSync = oldBaseTip ? await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore) : undefined;
|
|
669
688
|
return { ok: true, baseSync };
|
|
@@ -699,7 +718,8 @@ async function mergeRebaseFf(
|
|
|
699
718
|
// points (the upstream sync below and the final land). A mixed state from either is surfaced
|
|
700
719
|
// loudly rather than swallowed as a log warning (#824).
|
|
701
720
|
let baseSync: BaseWorktreeSyncResult | undefined;
|
|
702
|
-
const
|
|
721
|
+
const upstreamResult = await mergeGit(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, "rebase", `workspace merge resolve upstream for ${base}`);
|
|
722
|
+
const upstream = upstreamResult.ok && upstreamResult.stdout ? upstreamResult.stdout : undefined;
|
|
703
723
|
const slash = upstream ? upstream.indexOf("/") : -1;
|
|
704
724
|
const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
|
|
705
725
|
const pushEnabled = input.push !== false && workspacePushEnabled() && Boolean(remote);
|
|
@@ -708,10 +728,12 @@ async function mergeRebaseFf(
|
|
|
708
728
|
// gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
|
|
709
729
|
// base verbatim). headSha is the preserved landed commit; when base has advanced we
|
|
710
730
|
// tie the branch in with a no-ff merge so the agent's commits keep their identity.
|
|
711
|
-
const
|
|
731
|
+
const headResult = await mergeGit(["rev-parse", "HEAD"], worktreePath, "rebase", "workspace merge resolve workspace HEAD before gates");
|
|
732
|
+
const headSha = headResult.stdout;
|
|
733
|
+
if (!headResult.ok || !headSha) return head({ status: "review_requested", error: gitError(headResult, "failed to resolve workspace HEAD before gates") });
|
|
712
734
|
// Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
|
|
713
735
|
// an empty/failed read just omits it from the message body.
|
|
714
|
-
const landedSubject = (await
|
|
736
|
+
const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject")).stdout || undefined;
|
|
715
737
|
|
|
716
738
|
// #902 BLOCKING 2 — NO mutation of `refs/heads/<base>` may happen until gates pass, so resolve
|
|
717
739
|
// the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is the common
|
|
@@ -719,7 +741,9 @@ async function mergeRebaseFf(
|
|
|
719
741
|
// the local-base sync to AFTER the gates. On a gate failure `refs/heads/<base>` must be byte-
|
|
720
742
|
// identical to before the land attempt — so the only `refs/heads/<base>` mutation is the
|
|
721
743
|
// sync+advance below, all of it gated.
|
|
722
|
-
|
|
744
|
+
const integrationBaseResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve integration base ${base}`);
|
|
745
|
+
let integrationBaseSha = integrationBaseResult.stdout;
|
|
746
|
+
if (!integrationBaseResult.ok || !integrationBaseSha) return head({ status: "review_requested", error: gitError(integrationBaseResult, `failed to resolve integration base ${base}`) });
|
|
723
747
|
let needSync = false;
|
|
724
748
|
if (upstream && remote && pushEnabled) {
|
|
725
749
|
try {
|
|
@@ -732,13 +756,13 @@ async function mergeRebaseFf(
|
|
|
732
756
|
} catch (err) {
|
|
733
757
|
return head({ status: "review_requested", error: errMessage(err) });
|
|
734
758
|
}
|
|
735
|
-
if (!(await
|
|
759
|
+
if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}`)).ok) {
|
|
736
760
|
// Origin moved ahead. Sync-then-land iff local base is cleanly behind (ancestor
|
|
737
761
|
// of upstream); otherwise it's genuine divergence — refuse without mutating.
|
|
738
|
-
if (!(await
|
|
762
|
+
if (!(await mergeGit(["merge-base", "--is-ancestor", base, upstream], worktreePath, "rebase", `workspace merge compare ${base} to ${upstream}`)).ok) {
|
|
739
763
|
return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
|
|
740
764
|
}
|
|
741
|
-
const upstreamSha = (await
|
|
765
|
+
const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} for integration`)).stdout;
|
|
742
766
|
if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
|
|
743
767
|
// Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
|
|
744
768
|
integrationBaseSha = upstreamSha;
|
|
@@ -747,7 +771,9 @@ async function mergeRebaseFf(
|
|
|
747
771
|
}
|
|
748
772
|
|
|
749
773
|
// Behind relative to the TRUE integration base (origin-ahead ⟹ behind>0 ⟹ a real no-ff merge).
|
|
750
|
-
const
|
|
774
|
+
const behindResult = await countBehind(worktreePath, integrationBaseSha);
|
|
775
|
+
if ("error" in behindResult) return head({ status: "review_requested", error: behindResult.error });
|
|
776
|
+
const behind = behindResult.behind;
|
|
751
777
|
|
|
752
778
|
// #902 BLOCKING 1 / closes #903 — run the repo's configured land gates against the EXACT tree
|
|
753
779
|
// that will become base's new tip, BEFORE advancing the ref. behind===0 → the worktree HEAD IS
|
|
@@ -792,28 +818,32 @@ async function mergeRebaseFf(
|
|
|
792
818
|
const dirtyBasePathsBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
|
|
793
819
|
if (baseWorktree && !baseWorktree.dirty) {
|
|
794
820
|
if (behind === 0) {
|
|
795
|
-
const ff = await
|
|
821
|
+
const ff = await mergeGit(["merge", "--ff-only", branch], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${branch}`);
|
|
796
822
|
if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
|
|
797
823
|
} else {
|
|
798
|
-
const merge = await
|
|
824
|
+
const merge = await mergeGit(
|
|
799
825
|
["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "merge", "--no-ff", "-m", landMergeMessage(branch, landedSubject), branch],
|
|
800
826
|
baseWorktree.path,
|
|
827
|
+
"rebase",
|
|
828
|
+
`workspace merge no-ff ${branch} into ${base}`,
|
|
801
829
|
);
|
|
802
830
|
if (!merge.ok) {
|
|
803
|
-
await
|
|
831
|
+
await mergeGit(["merge", "--abort"], baseWorktree.path, "rebase", `workspace merge abort failed no-ff ${branch}`);
|
|
804
832
|
return head({ conflict: true, status: "conflict", error: merge.stderr || "merge into base failed" });
|
|
805
833
|
}
|
|
806
|
-
baseTip = (await
|
|
834
|
+
baseTip = (await mergeGit(["rev-parse", "HEAD"], baseWorktree.path, "rebase", `workspace merge resolve ${base} after no-ff`)).stdout;
|
|
807
835
|
}
|
|
808
836
|
} else if (behind === 0) {
|
|
809
|
-
const oldBaseTip = (await
|
|
837
|
+
const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before fast-forward ref update`)).stdout;
|
|
810
838
|
const update = oldBaseTip
|
|
811
|
-
? await
|
|
812
|
-
: await
|
|
839
|
+
? await mergeGit(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot, "rebase", `workspace merge update ${base} fast-forward`)
|
|
840
|
+
: await mergeGit(["update-ref", `refs/heads/${base}`, headSha], repoRoot, "rebase", `workspace merge create/update ${base} fast-forward`);
|
|
813
841
|
if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
|
|
814
842
|
if (oldBaseTip) baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore));
|
|
815
843
|
} else {
|
|
816
|
-
const
|
|
844
|
+
const baseShaResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before no-ff ref update`);
|
|
845
|
+
const baseSha = baseShaResult.stdout;
|
|
846
|
+
if (!baseShaResult.ok || !baseSha) return head({ status: "review_requested", error: gitError(baseShaResult, `failed to resolve ${base} before no-ff ref update`) });
|
|
817
847
|
const merged = await recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
|
|
818
848
|
if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
|
|
819
849
|
baseTip = merged.mergeSha;
|
|
@@ -833,7 +863,7 @@ async function mergeRebaseFf(
|
|
|
833
863
|
// means origin raced us — surface it instead of claiming an unpublished land.
|
|
834
864
|
let pushed = false;
|
|
835
865
|
if (upstream && remote && pushEnabled) {
|
|
836
|
-
const push = await
|
|
866
|
+
const push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge push ${base} to ${remote}`);
|
|
837
867
|
if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
|
|
838
868
|
pushed = true;
|
|
839
869
|
}
|
|
@@ -847,11 +877,11 @@ async function mergeRebaseFf(
|
|
|
847
877
|
const deleteBranch = input.deleteBranch !== false;
|
|
848
878
|
if (!deleteBranch) {
|
|
849
879
|
const fresh = await nextBranchName(repoRoot, branch);
|
|
850
|
-
const switched = await
|
|
880
|
+
const switched = await mergeGit(["checkout", "-B", fresh, base], worktreePath, "rebase", `workspace merge recycle worktree to ${fresh}`);
|
|
851
881
|
if (switched.ok) {
|
|
852
882
|
// The old branch is now fully contained in base (fast-forwarded, or merged in
|
|
853
883
|
// as the no-ff merge's second parent) — drop the litter.
|
|
854
|
-
const oldDeleted = (await
|
|
884
|
+
const oldDeleted = (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok;
|
|
855
885
|
// The worktree just moved onto the advanced base, which may declare deps the
|
|
856
886
|
// shared (symlinked) node_modules lacks. Re-provision so the recycled session
|
|
857
887
|
// continues from a buildable state (issue #51). No-op when nothing is stale.
|
|
@@ -862,8 +892,8 @@ async function mergeRebaseFf(
|
|
|
862
892
|
// Recycle failed — keep the existing branch. Still landed, still active.
|
|
863
893
|
return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
|
|
864
894
|
}
|
|
865
|
-
const removed = await
|
|
895
|
+
const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], repoRoot, "cleanup", "workspace merge remove landed worktree");
|
|
866
896
|
const worktreeRemoved = removed.ok;
|
|
867
|
-
const branchDeleted = worktreeRemoved ? (await
|
|
897
|
+
const branchDeleted = worktreeRemoved ? (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok : false;
|
|
868
898
|
return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
|
|
869
899
|
}
|