agent-relay-orchestrator 0.118.3 → 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/git.ts +10 -8
- package/src/process.ts +90 -9
- package/src/workspace-probe/merge-timeouts.ts +45 -0
- package/src/workspace-probe/merge.ts +123 -53
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);
|
package/src/git.ts
CHANGED
|
@@ -3,21 +3,23 @@
|
|
|
3
3
|
// `git -C` invocation lives in one place.
|
|
4
4
|
|
|
5
5
|
import { execProcess } from "./process";
|
|
6
|
+
import type { ExecResult } from "./process";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
type GitResult = Pick<ExecResult, "ok" | "stdout" | "stderr" | "exitCode" | "timedOut">;
|
|
9
|
+
|
|
10
|
+
interface GitOptions {
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
timeoutLabel?: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/** Run `git -C cwd <args>` and capture trimmed stdout/stderr; never throws. */
|
|
14
|
-
export async function git(args: string[], cwd: string): Promise<GitResult> {
|
|
15
|
-
return await execProcess(["git", "-C", cwd, ...args]);
|
|
16
|
+
export async function git(args: string[], cwd: string, options: GitOptions = {}): Promise<GitResult> {
|
|
17
|
+
return await execProcess(["git", "-C", cwd, ...args], options);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** Run `git -C cwd <args>` and preserve stdout exactly for path-safe parsers. */
|
|
19
|
-
export async function gitRaw(args: string[], cwd: string): Promise<GitResult> {
|
|
20
|
-
return await execProcess(["git", "-C", cwd, ...args], { trimStdout: false });
|
|
21
|
+
export async function gitRaw(args: string[], cwd: string, options: GitOptions = {}): Promise<GitResult> {
|
|
22
|
+
return await execProcess(["git", "-C", cwd, ...args], { ...options, trimStdout: false });
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
/** Like {@link git} but throws on a non-zero exit, returning stdout on success. */
|
package/src/process.ts
CHANGED
|
@@ -3,6 +3,7 @@ export interface ExecResult {
|
|
|
3
3
|
exitCode: number | null;
|
|
4
4
|
stdout: string;
|
|
5
5
|
stderr: string;
|
|
6
|
+
timedOut?: boolean;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
interface ExecOptions {
|
|
@@ -12,11 +13,56 @@ interface ExecOptions {
|
|
|
12
13
|
stderr?: "pipe" | "ignore";
|
|
13
14
|
trimStdout?: boolean;
|
|
14
15
|
trimStderr?: boolean;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
timeoutLabel?: string;
|
|
18
|
+
streamDrainGraceMs?: number;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
const DEFAULT_STREAM_DRAIN_GRACE_MS = 1_000;
|
|
22
|
+
|
|
23
|
+
interface StreamCapture {
|
|
24
|
+
done: Promise<void>;
|
|
25
|
+
text(): string;
|
|
26
|
+
cancel(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function captureStream(stream: ReadableStream<Uint8Array> | undefined): StreamCapture {
|
|
30
|
+
if (!stream) return { done: Promise.resolve(), text: () => "", cancel: () => {} };
|
|
31
|
+
const reader = stream.getReader();
|
|
32
|
+
const decoder = new TextDecoder();
|
|
33
|
+
let output = "";
|
|
34
|
+
let canceled = false;
|
|
35
|
+
const done = (async () => {
|
|
36
|
+
try {
|
|
37
|
+
while (!canceled) {
|
|
38
|
+
const chunk = await reader.read();
|
|
39
|
+
if (chunk.done) break;
|
|
40
|
+
output += decoder.decode(chunk.value, { stream: true });
|
|
41
|
+
}
|
|
42
|
+
output += decoder.decode();
|
|
43
|
+
} catch {
|
|
44
|
+
// Intentional cancellation on process timeout or a stuck post-exit pipe.
|
|
45
|
+
} finally {
|
|
46
|
+
try { reader.releaseLock(); } catch {}
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
return {
|
|
50
|
+
done,
|
|
51
|
+
text: () => output,
|
|
52
|
+
cancel: () => {
|
|
53
|
+
canceled = true;
|
|
54
|
+
void reader.cancel().catch(() => {});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sleep(ms: number): Promise<void> {
|
|
60
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function timeoutMessage(cmd: string[], options: ExecOptions): string {
|
|
64
|
+
const label = options.timeoutLabel ?? cmd.join(" ");
|
|
65
|
+
return `${label} timed out after ${options.timeoutMs}ms`;
|
|
20
66
|
}
|
|
21
67
|
|
|
22
68
|
export async function execProcess(cmd: string[], options: ExecOptions = {}): Promise<ExecResult> {
|
|
@@ -27,15 +73,50 @@ export async function execProcess(cmd: string[], options: ExecOptions = {}): Pro
|
|
|
27
73
|
stdout: options.stdout ?? "pipe",
|
|
28
74
|
stderr: options.stderr ?? "pipe",
|
|
29
75
|
});
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
76
|
+
const stdoutCapture = options.stdout === "ignore" ? captureStream(undefined) : captureStream(proc.stdout);
|
|
77
|
+
const stderrCapture = options.stderr === "ignore" ? captureStream(undefined) : captureStream(proc.stderr);
|
|
78
|
+
let timedOut = false;
|
|
79
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
let killTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
81
|
+
let timeoutResolve: ((value: null) => void) | undefined;
|
|
82
|
+
const timeoutPromise = new Promise<null>((resolve) => { timeoutResolve = resolve; });
|
|
83
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
84
|
+
timeout = setTimeout(() => {
|
|
85
|
+
timedOut = true;
|
|
86
|
+
try { proc.kill("SIGTERM"); } catch {}
|
|
87
|
+
killTimeout = setTimeout(() => {
|
|
88
|
+
try { proc.kill("SIGKILL"); } catch {}
|
|
89
|
+
}, 1_000);
|
|
90
|
+
killTimeout.unref?.();
|
|
91
|
+
timeoutResolve?.(null);
|
|
92
|
+
}, options.timeoutMs);
|
|
93
|
+
timeout.unref?.();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const exitCode = await (timeout ? Promise.race([proc.exited, timeoutPromise]) : proc.exited);
|
|
97
|
+
if (timeout) clearTimeout(timeout);
|
|
98
|
+
if (!timedOut && killTimeout) clearTimeout(killTimeout);
|
|
99
|
+
if (timedOut) {
|
|
100
|
+
stdoutCapture.cancel();
|
|
101
|
+
stderrCapture.cancel();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const drainGraceMs = options.streamDrainGraceMs ?? DEFAULT_STREAM_DRAIN_GRACE_MS;
|
|
105
|
+
await Promise.race([Promise.allSettled([stdoutCapture.done, stderrCapture.done]), sleep(drainGraceMs)]);
|
|
106
|
+
stdoutCapture.cancel();
|
|
107
|
+
stderrCapture.cancel();
|
|
108
|
+
|
|
109
|
+
const stdout = stdoutCapture.text();
|
|
110
|
+
let stderr = stderrCapture.text();
|
|
111
|
+
if (timedOut) {
|
|
112
|
+
const msg = timeoutMessage(cmd, options);
|
|
113
|
+
stderr = stderr ? `${stderr}\n${msg}` : msg;
|
|
114
|
+
}
|
|
35
115
|
return {
|
|
36
|
-
ok: exitCode === 0,
|
|
116
|
+
ok: !timedOut && exitCode === 0,
|
|
37
117
|
exitCode,
|
|
38
118
|
stdout: options.trimStdout === false ? stdout : stdout.trim(),
|
|
39
119
|
stderr: options.trimStderr === false ? stderr : stderr.trim(),
|
|
120
|
+
...(timedOut ? { timedOut } : {}),
|
|
40
121
|
};
|
|
41
122
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type MergePhase = "total" | "prep" | "preview" | "fetch" | "rebase" | "gates" | "synthesize" | "worktree-add" | "cleanup";
|
|
2
|
+
|
|
3
|
+
const MERGE_PHASE_TIMEOUTS_MS: Record<MergePhase, number> = {
|
|
4
|
+
total: 10 * 60_000,
|
|
5
|
+
prep: 60_000,
|
|
6
|
+
preview: 30_000,
|
|
7
|
+
fetch: 60_000,
|
|
8
|
+
rebase: 60_000,
|
|
9
|
+
gates: 5 * 60_000,
|
|
10
|
+
synthesize: 60_000,
|
|
11
|
+
"worktree-add": 60_000,
|
|
12
|
+
cleanup: 30_000,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function positiveEnvMs(name: string): number | undefined {
|
|
16
|
+
const raw = process.env[name];
|
|
17
|
+
if (!raw) return undefined;
|
|
18
|
+
const parsed = Number(raw);
|
|
19
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function mergePhaseTimeoutMs(phase: MergePhase): number {
|
|
23
|
+
const key = `AGENT_RELAY_WORKSPACE_MERGE_${phase.toUpperCase().replace(/-/g, "_")}_TIMEOUT_MS`;
|
|
24
|
+
return positiveEnvMs(key)
|
|
25
|
+
?? positiveEnvMs("AGENT_RELAY_WORKSPACE_MERGE_PHASE_TIMEOUT_MS")
|
|
26
|
+
?? MERGE_PHASE_TIMEOUTS_MS[phase];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function withMergePhaseTimeout<T>(phase: MergePhase, run: () => Promise<T>): Promise<T> {
|
|
30
|
+
const timeoutMs = mergePhaseTimeoutMs(phase);
|
|
31
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
const work = run();
|
|
33
|
+
work.catch(() => {});
|
|
34
|
+
try {
|
|
35
|
+
return await Promise.race([
|
|
36
|
+
work,
|
|
37
|
+
new Promise<T>((_, reject) => {
|
|
38
|
+
timeout = setTimeout(() => reject(new Error(`workspace merge phase "${phase}" timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
39
|
+
timeout.unref?.();
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
42
|
+
} finally {
|
|
43
|
+
if (timeout) clearTimeout(timeout);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -1,34 +1,51 @@
|
|
|
1
1
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
-
import type
|
|
4
|
+
import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk";
|
|
5
5
|
import { git, gitRaw } from "../git";
|
|
6
6
|
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,
|
|
10
|
+
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, workspaceGitState } from "./git-state";
|
|
11
|
+
import { type MergePhase, mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
|
|
11
12
|
import { nextBranchName } from "./names";
|
|
12
13
|
import { parseWorktrees, shortBranch } from "./parse";
|
|
13
14
|
import { json, resolveRequestedPath } from "./request";
|
|
14
15
|
import type { WorkspaceMergeInput } from "./types";
|
|
15
16
|
import { workspacePushEnabled } from "../config";
|
|
16
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
|
+
|
|
17
36
|
/** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
|
|
18
|
-
async function countBehind(worktreePath: string, base: string): Promise<number> {
|
|
19
|
-
const counts = await
|
|
20
|
-
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") };
|
|
21
40
|
const behind = Number(counts.stdout.split(/\s+/)[0]);
|
|
22
|
-
return Number.isFinite(behind) ? behind :
|
|
41
|
+
return Number.isFinite(behind) ? { behind } : { error: "git rev-list produced an invalid behind count" };
|
|
23
42
|
}
|
|
24
43
|
|
|
25
44
|
async function hasOriginRemote(cwd: string): Promise<boolean> {
|
|
26
45
|
return (await git(["remote", "get-url", "origin"], cwd)).ok;
|
|
27
46
|
}
|
|
28
47
|
|
|
29
|
-
function ghAvailable(): boolean {
|
|
30
|
-
return Boolean(Bun.which("gh"));
|
|
31
|
-
}
|
|
48
|
+
function ghAvailable(): boolean { return Boolean(Bun.which("gh")); }
|
|
32
49
|
|
|
33
50
|
/**
|
|
34
51
|
* Ground-truth merge state for `branch`, via gh.
|
|
@@ -60,11 +77,11 @@ async function baseBranchName(worktreePath: string, baseRef?: string): Promise<s
|
|
|
60
77
|
|
|
61
78
|
/** Locate the worktree (if any) that currently has `branch` checked out. */
|
|
62
79
|
async function worktreeForBranch(repoRoot: string, branch: string): Promise<{ path: string; dirty: boolean } | undefined> {
|
|
63
|
-
const list = await
|
|
80
|
+
const list = await mergeGit(["worktree", "list", "--porcelain"], repoRoot, "rebase", "workspace merge list worktrees");
|
|
64
81
|
if (!list.ok) return undefined;
|
|
65
82
|
const match = parseWorktrees(list.stdout).find((worktree) => worktree.branch === branch);
|
|
66
83
|
if (!match) return undefined;
|
|
67
|
-
const status = await
|
|
84
|
+
const status = await mergeGit(["status", "--porcelain"], match.path, "rebase", `workspace merge status ${branch} worktree`);
|
|
68
85
|
return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
|
|
69
86
|
}
|
|
70
87
|
|
|
@@ -73,7 +90,7 @@ function splitNul(stdout: string): string[] {
|
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
|
|
76
|
-
const status = await
|
|
93
|
+
const status = await mergeGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath, "rebase", "workspace merge dirty path scan");
|
|
77
94
|
if (!status.ok) return new Set<string>();
|
|
78
95
|
const paths = new Set<string>();
|
|
79
96
|
const entries = splitNul(status.stdout);
|
|
@@ -89,25 +106,25 @@ async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
|
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
async function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string): Promise<string[]> {
|
|
92
|
-
const diff = await
|
|
109
|
+
const diff = await mergeGitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath, "rebase", "workspace merge changed path scan");
|
|
93
110
|
return diff.ok ? splitNul(diff.stdout) : [];
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
async function restorePathsFromHead(worktreePath: string, paths: string[]): Promise<boolean> {
|
|
97
114
|
if (paths.length === 0) return true;
|
|
98
|
-
return (await
|
|
115
|
+
return (await mergeGit(["restore", "--staged", "--worktree", "--", ...paths], worktreePath, "rebase", "workspace merge restore clean landed paths")).ok;
|
|
99
116
|
}
|
|
100
117
|
|
|
101
118
|
async function resetIndexPathsToHead(worktreePath: string, paths: string[]): Promise<boolean> {
|
|
102
119
|
if (paths.length === 0) return true;
|
|
103
|
-
return (await
|
|
120
|
+
return (await mergeGit(["reset", "-q", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge reset preserved paths")).ok;
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
/** Landed paths whose working copy still differs from the advanced HEAD (ground truth, not
|
|
107
124
|
* exit codes): the checkout is mixed for these — reads/builds/publishes see STALE files. */
|
|
108
125
|
async function pathsDifferingFromHead(worktreePath: string, paths: string[]): Promise<string[]> {
|
|
109
126
|
if (paths.length === 0) return [];
|
|
110
|
-
const diff = await
|
|
127
|
+
const diff = await mergeGitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge verify base worktree sync");
|
|
111
128
|
// A failed diff can't prove the checkout is clean — treat every landed path as suspect.
|
|
112
129
|
return diff.ok ? splitNul(diff.stdout) : [...paths];
|
|
113
130
|
}
|
|
@@ -151,7 +168,7 @@ async function syncDirtyBaseWorktreeAfterRefAdvance(
|
|
|
151
168
|
dirtyBefore: Set<string> | undefined,
|
|
152
169
|
): Promise<BaseWorktreeSyncResult> {
|
|
153
170
|
if (!baseWorktree?.dirty || !dirtyBefore) return RECONCILED;
|
|
154
|
-
const readTree = await
|
|
171
|
+
const readTree = await mergeGit(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path, "rebase", "workspace merge sync dirty base worktree");
|
|
155
172
|
if (readTree.ok) return RECONCILED;
|
|
156
173
|
|
|
157
174
|
const changedPaths = await changedPathList(baseWorktree.path, oldBaseTip, newBaseTip);
|
|
@@ -351,13 +368,19 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
|
|
|
351
368
|
if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
|
|
352
369
|
const worktreePath = resolve(input.worktreePath);
|
|
353
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}`);
|
|
354
372
|
// Probe the live HEAD branch first — it's the authoritative source. Fall back to the
|
|
355
373
|
// DB-recorded branch only when the live probe fails (detached HEAD, missing worktree, etc.).
|
|
356
374
|
// This fixes #232: a stale DB branch value (non-null mismatch) would pass through the
|
|
357
375
|
// `input.branch ?? ...` guard unchanged and cause git to attempt merging a non-existent ref.
|
|
358
|
-
const liveBranch = shortBranch((await
|
|
376
|
+
const liveBranch = shortBranch((await mergeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, "prep", "workspace merge resolve live branch")).stdout || undefined);
|
|
359
377
|
const branch = liveBranch ?? input.branch;
|
|
360
|
-
|
|
378
|
+
let preview: WorkspaceMergePreview;
|
|
379
|
+
try {
|
|
380
|
+
preview = await withMergePhaseTimeout("preview", () => previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy }));
|
|
381
|
+
} catch (err) {
|
|
382
|
+
return { workspaceId: input.id, strategy: "rebase-ff", merged: false, status: "review_requested", branch, error: errMessage(err) };
|
|
383
|
+
}
|
|
361
384
|
const strategy = preview.strategy;
|
|
362
385
|
const head = (field: Partial<WorkspaceMergeResult>): WorkspaceMergeResult => ({ workspaceId: input.id, strategy, merged: false, status: "review_requested", branch, baseRef: preview.baseRef, ...field });
|
|
363
386
|
|
|
@@ -392,6 +415,7 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
|
|
|
392
415
|
if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
|
|
393
416
|
|
|
394
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)"}`);
|
|
395
419
|
return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
|
|
396
420
|
}
|
|
397
421
|
|
|
@@ -531,14 +555,16 @@ async function synthesizeNoFfMerge(
|
|
|
531
555
|
baseSha: string,
|
|
532
556
|
branchSha: string,
|
|
533
557
|
message: string,
|
|
558
|
+
timeoutMs?: number,
|
|
534
559
|
): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
|
|
535
|
-
const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot);
|
|
560
|
+
const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree" });
|
|
536
561
|
if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
|
|
537
562
|
const treeOid = tree.stdout.split("\n")[0]?.trim();
|
|
538
563
|
if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
|
|
539
564
|
const commit = await git(
|
|
540
565
|
["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
|
|
541
566
|
repoRoot,
|
|
567
|
+
{ timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree" },
|
|
542
568
|
);
|
|
543
569
|
if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
|
|
544
570
|
return { ok: true, mergeSha: commit.stdout };
|
|
@@ -551,9 +577,9 @@ async function recordNoFfMerge(
|
|
|
551
577
|
branchSha: string,
|
|
552
578
|
message: string,
|
|
553
579
|
): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
|
|
554
|
-
const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message);
|
|
580
|
+
const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message, mergePhaseTimeoutMs("synthesize"));
|
|
555
581
|
if (!synth.ok) return synth;
|
|
556
|
-
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`);
|
|
557
583
|
if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
|
|
558
584
|
return { ok: true, mergeSha: synth.mergeSha };
|
|
559
585
|
}
|
|
@@ -579,9 +605,15 @@ async function runLandGatesOnIntegratedTree(
|
|
|
579
605
|
headSha: string,
|
|
580
606
|
mergeMessage: string,
|
|
581
607
|
): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
|
|
582
|
-
|
|
608
|
+
console.error(`[orchestrator] workspace.merge gate-start worktree=${worktreePath} behind=${behind}`);
|
|
609
|
+
if (behind === 0) return { gates: await withMergePhaseTimeout("gates", () => runLandGates(worktreePath)) };
|
|
583
610
|
|
|
584
|
-
|
|
611
|
+
let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
|
|
612
|
+
try {
|
|
613
|
+
synth = await withMergePhaseTimeout("synthesize", () => synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage, mergePhaseTimeoutMs("synthesize")));
|
|
614
|
+
} catch (err) {
|
|
615
|
+
return { abort: { error: errMessage(err) } };
|
|
616
|
+
}
|
|
585
617
|
if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
|
|
586
618
|
|
|
587
619
|
// Detached worktree in an isolated temp dir so the gates see the integrated tree on disk
|
|
@@ -589,15 +621,33 @@ async function runLandGatesOnIntegratedTree(
|
|
|
589
621
|
// creates the leaf, so hand it a not-yet-existing path under a freshly made parent dir.
|
|
590
622
|
const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
|
|
591
623
|
const tmpWorktree = join(tmpParent, "tree");
|
|
592
|
-
|
|
624
|
+
let add: Awaited<ReturnType<typeof git>>;
|
|
625
|
+
try {
|
|
626
|
+
add = await withMergePhaseTimeout("worktree-add", () => git(
|
|
627
|
+
["worktree", "add", "--detach", tmpWorktree, synth.mergeSha],
|
|
628
|
+
repoRoot,
|
|
629
|
+
{ timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add" },
|
|
630
|
+
));
|
|
631
|
+
} catch (err) {
|
|
632
|
+
rmSync(tmpParent, { recursive: true, force: true });
|
|
633
|
+
return { abort: { error: errMessage(err) } };
|
|
634
|
+
}
|
|
593
635
|
if (!add.ok) {
|
|
594
636
|
rmSync(tmpParent, { recursive: true, force: true });
|
|
595
637
|
return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
|
|
596
638
|
}
|
|
597
639
|
try {
|
|
598
|
-
return { gates: await runLandGates(tmpWorktree) };
|
|
640
|
+
return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree)) };
|
|
599
641
|
} finally {
|
|
600
|
-
|
|
642
|
+
try {
|
|
643
|
+
await withMergePhaseTimeout("cleanup", () => git(
|
|
644
|
+
["worktree", "remove", "--force", tmpWorktree],
|
|
645
|
+
repoRoot,
|
|
646
|
+
{ timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge land-gate worktree cleanup" },
|
|
647
|
+
));
|
|
648
|
+
} catch (err) {
|
|
649
|
+
console.error(`[orchestrator] land-gate integrated worktree cleanup timed out/failed: ${errMessage(err)}`);
|
|
650
|
+
}
|
|
601
651
|
rmSync(tmpParent, { recursive: true, force: true });
|
|
602
652
|
}
|
|
603
653
|
}
|
|
@@ -616,7 +666,7 @@ async function syncLocalBaseToUpstream(
|
|
|
616
666
|
base: string,
|
|
617
667
|
upstream: string,
|
|
618
668
|
): Promise<{ ok: true; baseSync?: BaseWorktreeSyncResult } | { ok: false; error: string }> {
|
|
619
|
-
const upstreamSha = (await
|
|
669
|
+
const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream}`)).stdout;
|
|
620
670
|
if (!upstreamSha) return { ok: false, error: `cannot resolve ${upstream} to sync ${base}` };
|
|
621
671
|
const baseWorktree = await worktreeForBranch(repoRoot, base);
|
|
622
672
|
// Sync IN the base worktree only when it's clean — that keeps its working tree consistent
|
|
@@ -625,14 +675,14 @@ async function syncLocalBaseToUpstream(
|
|
|
625
675
|
// lands: advance the ref directly with update-ref, then best-effort sync the checked-out
|
|
626
676
|
// index/worktree forward for paths that are not human-modified (#681).
|
|
627
677
|
if (baseWorktree && !baseWorktree.dirty) {
|
|
628
|
-
const ff = await
|
|
678
|
+
const ff = await mergeGit(["merge", "--ff-only", upstream], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${upstream}`);
|
|
629
679
|
if (!ff.ok) return { ok: false, error: ff.stderr || `failed to fast-forward ${base} to ${upstream}` };
|
|
630
680
|
return { ok: true };
|
|
631
681
|
}
|
|
632
|
-
const oldBaseTip = (await
|
|
682
|
+
const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before upstream sync`)).stdout;
|
|
633
683
|
const dirtyBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
|
|
634
684
|
const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha];
|
|
635
|
-
const update = await
|
|
685
|
+
const update = await mergeGit(updateArgs, repoRoot, "rebase", `workspace merge update ${base} to ${upstream}`);
|
|
636
686
|
if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` };
|
|
637
687
|
const baseSync = oldBaseTip ? await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore) : undefined;
|
|
638
688
|
return { ok: true, baseSync };
|
|
@@ -668,7 +718,8 @@ async function mergeRebaseFf(
|
|
|
668
718
|
// points (the upstream sync below and the final land). A mixed state from either is surfaced
|
|
669
719
|
// loudly rather than swallowed as a log warning (#824).
|
|
670
720
|
let baseSync: BaseWorktreeSyncResult | undefined;
|
|
671
|
-
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;
|
|
672
723
|
const slash = upstream ? upstream.indexOf("/") : -1;
|
|
673
724
|
const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
|
|
674
725
|
const pushEnabled = input.push !== false && workspacePushEnabled() && Boolean(remote);
|
|
@@ -677,10 +728,12 @@ async function mergeRebaseFf(
|
|
|
677
728
|
// gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
|
|
678
729
|
// base verbatim). headSha is the preserved landed commit; when base has advanced we
|
|
679
730
|
// tie the branch in with a no-ff merge so the agent's commits keep their identity.
|
|
680
|
-
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") });
|
|
681
734
|
// Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
|
|
682
735
|
// an empty/failed read just omits it from the message body.
|
|
683
|
-
const landedSubject = (await
|
|
736
|
+
const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject")).stdout || undefined;
|
|
684
737
|
|
|
685
738
|
// #902 BLOCKING 2 — NO mutation of `refs/heads/<base>` may happen until gates pass, so resolve
|
|
686
739
|
// the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is the common
|
|
@@ -688,17 +741,28 @@ async function mergeRebaseFf(
|
|
|
688
741
|
// the local-base sync to AFTER the gates. On a gate failure `refs/heads/<base>` must be byte-
|
|
689
742
|
// identical to before the land attempt — so the only `refs/heads/<base>` mutation is the
|
|
690
743
|
// sync+advance below, all of it gated.
|
|
691
|
-
|
|
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}`) });
|
|
692
747
|
let needSync = false;
|
|
693
748
|
if (upstream && remote && pushEnabled) {
|
|
694
|
-
|
|
695
|
-
|
|
749
|
+
try {
|
|
750
|
+
const fetch = await withMergePhaseTimeout("fetch", () => git(
|
|
751
|
+
["fetch", remote, base],
|
|
752
|
+
worktreePath,
|
|
753
|
+
{ timeoutMs: mergePhaseTimeoutMs("fetch"), timeoutLabel: `workspace merge fetch ${remote}/${base}` },
|
|
754
|
+
));
|
|
755
|
+
if (!fetch.ok && fetch.timedOut) return head({ status: "review_requested", error: fetch.stderr || `fetch ${remote}/${base} timed out` });
|
|
756
|
+
} catch (err) {
|
|
757
|
+
return head({ status: "review_requested", error: errMessage(err) });
|
|
758
|
+
}
|
|
759
|
+
if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}`)).ok) {
|
|
696
760
|
// Origin moved ahead. Sync-then-land iff local base is cleanly behind (ancestor
|
|
697
761
|
// of upstream); otherwise it's genuine divergence — refuse without mutating.
|
|
698
|
-
if (!(await
|
|
762
|
+
if (!(await mergeGit(["merge-base", "--is-ancestor", base, upstream], worktreePath, "rebase", `workspace merge compare ${base} to ${upstream}`)).ok) {
|
|
699
763
|
return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
|
|
700
764
|
}
|
|
701
|
-
const upstreamSha = (await
|
|
765
|
+
const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} for integration`)).stdout;
|
|
702
766
|
if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
|
|
703
767
|
// Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
|
|
704
768
|
integrationBaseSha = upstreamSha;
|
|
@@ -707,7 +771,9 @@ async function mergeRebaseFf(
|
|
|
707
771
|
}
|
|
708
772
|
|
|
709
773
|
// Behind relative to the TRUE integration base (origin-ahead ⟹ behind>0 ⟹ a real no-ff merge).
|
|
710
|
-
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;
|
|
711
777
|
|
|
712
778
|
// #902 BLOCKING 1 / closes #903 — run the repo's configured land gates against the EXACT tree
|
|
713
779
|
// that will become base's new tip, BEFORE advancing the ref. behind===0 → the worktree HEAD IS
|
|
@@ -752,28 +818,32 @@ async function mergeRebaseFf(
|
|
|
752
818
|
const dirtyBasePathsBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
|
|
753
819
|
if (baseWorktree && !baseWorktree.dirty) {
|
|
754
820
|
if (behind === 0) {
|
|
755
|
-
const ff = await
|
|
821
|
+
const ff = await mergeGit(["merge", "--ff-only", branch], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${branch}`);
|
|
756
822
|
if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
|
|
757
823
|
} else {
|
|
758
|
-
const merge = await
|
|
824
|
+
const merge = await mergeGit(
|
|
759
825
|
["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "merge", "--no-ff", "-m", landMergeMessage(branch, landedSubject), branch],
|
|
760
826
|
baseWorktree.path,
|
|
827
|
+
"rebase",
|
|
828
|
+
`workspace merge no-ff ${branch} into ${base}`,
|
|
761
829
|
);
|
|
762
830
|
if (!merge.ok) {
|
|
763
|
-
await
|
|
831
|
+
await mergeGit(["merge", "--abort"], baseWorktree.path, "rebase", `workspace merge abort failed no-ff ${branch}`);
|
|
764
832
|
return head({ conflict: true, status: "conflict", error: merge.stderr || "merge into base failed" });
|
|
765
833
|
}
|
|
766
|
-
baseTip = (await
|
|
834
|
+
baseTip = (await mergeGit(["rev-parse", "HEAD"], baseWorktree.path, "rebase", `workspace merge resolve ${base} after no-ff`)).stdout;
|
|
767
835
|
}
|
|
768
836
|
} else if (behind === 0) {
|
|
769
|
-
const oldBaseTip = (await
|
|
837
|
+
const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before fast-forward ref update`)).stdout;
|
|
770
838
|
const update = oldBaseTip
|
|
771
|
-
? await
|
|
772
|
-
: 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`);
|
|
773
841
|
if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
|
|
774
842
|
if (oldBaseTip) baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore));
|
|
775
843
|
} else {
|
|
776
|
-
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`) });
|
|
777
847
|
const merged = await recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
|
|
778
848
|
if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
|
|
779
849
|
baseTip = merged.mergeSha;
|
|
@@ -793,7 +863,7 @@ async function mergeRebaseFf(
|
|
|
793
863
|
// means origin raced us — surface it instead of claiming an unpublished land.
|
|
794
864
|
let pushed = false;
|
|
795
865
|
if (upstream && remote && pushEnabled) {
|
|
796
|
-
const push = await
|
|
866
|
+
const push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge push ${base} to ${remote}`);
|
|
797
867
|
if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
|
|
798
868
|
pushed = true;
|
|
799
869
|
}
|
|
@@ -807,11 +877,11 @@ async function mergeRebaseFf(
|
|
|
807
877
|
const deleteBranch = input.deleteBranch !== false;
|
|
808
878
|
if (!deleteBranch) {
|
|
809
879
|
const fresh = await nextBranchName(repoRoot, branch);
|
|
810
|
-
const switched = await
|
|
880
|
+
const switched = await mergeGit(["checkout", "-B", fresh, base], worktreePath, "rebase", `workspace merge recycle worktree to ${fresh}`);
|
|
811
881
|
if (switched.ok) {
|
|
812
882
|
// The old branch is now fully contained in base (fast-forwarded, or merged in
|
|
813
883
|
// as the no-ff merge's second parent) — drop the litter.
|
|
814
|
-
const oldDeleted = (await
|
|
884
|
+
const oldDeleted = (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok;
|
|
815
885
|
// The worktree just moved onto the advanced base, which may declare deps the
|
|
816
886
|
// shared (symlinked) node_modules lacks. Re-provision so the recycled session
|
|
817
887
|
// continues from a buildable state (issue #51). No-op when nothing is stale.
|
|
@@ -822,8 +892,8 @@ async function mergeRebaseFf(
|
|
|
822
892
|
// Recycle failed — keep the existing branch. Still landed, still active.
|
|
823
893
|
return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
|
|
824
894
|
}
|
|
825
|
-
const removed = await
|
|
895
|
+
const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], repoRoot, "cleanup", "workspace merge remove landed worktree");
|
|
826
896
|
const worktreeRemoved = removed.ok;
|
|
827
|
-
const branchDeleted = worktreeRemoved ? (await
|
|
897
|
+
const branchDeleted = worktreeRemoved ? (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok : false;
|
|
828
898
|
return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
|
|
829
899
|
}
|