agent-relay-orchestrator 0.10.20 → 0.10.21
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 +2 -2
- package/src/api.ts +46 -1
- package/src/control.ts +33 -1
- package/src/relay.ts +5 -0
- package/src/self-supervision.ts +82 -0
- package/src/self-upgrade.ts +143 -0
- package/src/workspace-probe.ts +329 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.21",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"agent-relay-sdk": "0.2.
|
|
19
|
+
"agent-relay-sdk": "0.2.2"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest"
|
package/src/api.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { ProviderProbeCache } from "./provider-probe";
|
|
|
8
8
|
import { captureSession, captureTerminal, createTerminalGuest, listSessions, sendTerminalInput, resizeTerminal, stopTerminalGuest } from "./spawn";
|
|
9
9
|
import type { TerminalSnapshot } from "./spawn";
|
|
10
10
|
import { VERSION, runtimeMetadata } from "./version";
|
|
11
|
-
import { probeWorkspace } from "./workspace-probe";
|
|
11
|
+
import { previewWorkspaceMerge, probeWorkspace, workspaceDiff, workspaceGitState } from "./workspace-probe";
|
|
12
12
|
|
|
13
13
|
interface DirectoryEntry {
|
|
14
14
|
name: string;
|
|
@@ -373,6 +373,51 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
|
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
if (req.method === "GET" && url.pathname === "/api/workspace/state") {
|
|
377
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
378
|
+
try {
|
|
379
|
+
const { target } = resolveInsideBase(url.searchParams.get("path") || undefined, config.baseDir);
|
|
380
|
+
return json(workspaceGitState({
|
|
381
|
+
worktreePath: target,
|
|
382
|
+
baseRef: url.searchParams.get("baseRef") || undefined,
|
|
383
|
+
baseSha: url.searchParams.get("baseSha") || undefined,
|
|
384
|
+
}));
|
|
385
|
+
} catch (e) {
|
|
386
|
+
return error((e as Error).message);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (req.method === "GET" && url.pathname === "/api/workspace/diff") {
|
|
391
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
392
|
+
try {
|
|
393
|
+
const { target } = resolveInsideBase(url.searchParams.get("path") || undefined, config.baseDir);
|
|
394
|
+
return json(workspaceDiff({
|
|
395
|
+
worktreePath: target,
|
|
396
|
+
baseRef: url.searchParams.get("baseRef") || undefined,
|
|
397
|
+
baseSha: url.searchParams.get("baseSha") || undefined,
|
|
398
|
+
includePatch: url.searchParams.get("patch") !== "0",
|
|
399
|
+
}));
|
|
400
|
+
} catch (e) {
|
|
401
|
+
return error((e as Error).message);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (req.method === "GET" && url.pathname === "/api/workspace/merge-preview") {
|
|
406
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
407
|
+
try {
|
|
408
|
+
const { target } = resolveInsideBase(url.searchParams.get("path") || undefined, config.baseDir);
|
|
409
|
+
const strategy = url.searchParams.get("strategy");
|
|
410
|
+
return json(previewWorkspaceMerge({
|
|
411
|
+
worktreePath: target,
|
|
412
|
+
baseRef: url.searchParams.get("baseRef") || undefined,
|
|
413
|
+
baseSha: url.searchParams.get("baseSha") || undefined,
|
|
414
|
+
strategy: strategy === "pr" || strategy === "rebase-ff" || strategy === "auto" ? strategy : undefined,
|
|
415
|
+
}));
|
|
416
|
+
} catch (e) {
|
|
417
|
+
return error((e as Error).message);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
376
421
|
if (req.method === "GET" && url.pathname === "/api/providers") {
|
|
377
422
|
return (async () => {
|
|
378
423
|
const snapshot = await probeCache.getSnapshot(url.searchParams.get("refresh") === "1");
|
package/src/control.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { OrchestratorConfig } from "./config";
|
|
2
2
|
import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
|
|
3
|
+
import { handleSelfUpgrade } from "./self-upgrade";
|
|
3
4
|
import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
|
|
4
|
-
import { cleanupWorkspace } from "./workspace-probe";
|
|
5
|
+
import { cleanupWorkspace, mergeWorkspace, reconcileWorkspace } from "./workspace-probe";
|
|
5
6
|
|
|
6
7
|
interface ControlHandler {
|
|
7
8
|
handleCommand(command: RelayCommand): Promise<boolean>;
|
|
@@ -74,8 +75,39 @@ export function createControlHandler(
|
|
|
74
75
|
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
75
76
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
76
77
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
78
|
+
branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
|
|
79
|
+
deleteBranch: command.params.deleteBranch !== false,
|
|
77
80
|
});
|
|
78
81
|
await relay.updateCommand(command.id, "succeeded", result);
|
|
82
|
+
} else if (command.type === "workspace.reconcile") {
|
|
83
|
+
const result = reconcileWorkspace({
|
|
84
|
+
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
85
|
+
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
86
|
+
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
87
|
+
branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
|
|
88
|
+
baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
|
|
89
|
+
baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
|
|
90
|
+
});
|
|
91
|
+
await relay.updateCommand(command.id, "succeeded", result);
|
|
92
|
+
} else if (command.type === "workspace.merge") {
|
|
93
|
+
const result = mergeWorkspace({
|
|
94
|
+
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
95
|
+
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
96
|
+
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
97
|
+
branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
|
|
98
|
+
baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
|
|
99
|
+
baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
|
|
100
|
+
strategy: command.params.strategy === "pr" || command.params.strategy === "rebase-ff" || command.params.strategy === "auto" ? command.params.strategy : undefined,
|
|
101
|
+
deleteBranch: command.params.deleteBranch !== false,
|
|
102
|
+
prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
|
|
103
|
+
prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
|
|
104
|
+
});
|
|
105
|
+
await relay.updateCommand(command.id, "succeeded", result as unknown as Record<string, unknown>);
|
|
106
|
+
} else if (command.type === "orchestrator.upgrade") {
|
|
107
|
+
// Install + restart ourselves. Intentionally NOT marked "succeeded": the
|
|
108
|
+
// relay settles it by reconciling the version we report after we restart,
|
|
109
|
+
// since the success ack can't survive our own process teardown.
|
|
110
|
+
await handleSelfUpgrade(command, config, relay);
|
|
79
111
|
} else {
|
|
80
112
|
throw new Error(`unsupported orchestrator command: ${command.type}`);
|
|
81
113
|
}
|
package/src/relay.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OrchestratorConfig } from "./config";
|
|
2
2
|
import type { ProviderProbeCache } from "./provider-probe";
|
|
3
|
+
import { detectSelfSupervision } from "./self-supervision";
|
|
3
4
|
import { GIT_SHA, ORCHESTRATOR_PROTOCOL_VERSION, VERSION, runtimeMetadata } from "./version";
|
|
4
5
|
import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
|
|
5
6
|
|
|
@@ -99,6 +100,7 @@ export function buildRegistrationMeta(
|
|
|
99
100
|
now = Date.now,
|
|
100
101
|
pid = process.pid,
|
|
101
102
|
): Record<string, unknown> {
|
|
103
|
+
const supervision = detectSelfSupervision();
|
|
102
104
|
return {
|
|
103
105
|
...runtime,
|
|
104
106
|
pid,
|
|
@@ -107,6 +109,9 @@ export function buildRegistrationMeta(
|
|
|
107
109
|
version: VERSION,
|
|
108
110
|
protocolVersion: ORCHESTRATOR_PROTOCOL_VERSION,
|
|
109
111
|
gitSha: GIT_SHA,
|
|
112
|
+
supervisor: supervision.supervisor,
|
|
113
|
+
...(supervision.selfUnit ? { selfUnit: supervision.selfUnit } : {}),
|
|
114
|
+
...(supervision.runtimePrefix ? { runtimePrefix: supervision.runtimePrefix } : {}),
|
|
110
115
|
};
|
|
111
116
|
}
|
|
112
117
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export interface SelfSupervision {
|
|
5
|
+
supervisor: "process" | "systemd" | "unknown";
|
|
6
|
+
selfUnit?: string;
|
|
7
|
+
runtimePrefix?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let cached: SelfSupervision | undefined;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Detect how this orchestrator process is supervised so the relay can target a
|
|
14
|
+
* remote self-upgrade at the correct systemd unit and install prefix. Result is
|
|
15
|
+
* stable for the process lifetime, so it is computed once and cached.
|
|
16
|
+
*/
|
|
17
|
+
export function detectSelfSupervision(moduleUrl: string = import.meta.url): SelfSupervision {
|
|
18
|
+
if (cached) return cached;
|
|
19
|
+
cached = { supervisor: detectSupervisorRaw(), runtimePrefix: detectRuntimePrefix(moduleUrl) };
|
|
20
|
+
const unit = detectSystemdUnit();
|
|
21
|
+
if (unit) {
|
|
22
|
+
cached.supervisor = "systemd";
|
|
23
|
+
cached.selfUnit = unit;
|
|
24
|
+
}
|
|
25
|
+
return cached;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Reset the cache. Test-only. */
|
|
29
|
+
export function resetSelfSupervisionCache(): void {
|
|
30
|
+
cached = undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function detectSupervisorRaw(): SelfSupervision["supervisor"] {
|
|
34
|
+
// /proc only exists on Linux; on macOS (launchd) we can't introspect cheaply.
|
|
35
|
+
try {
|
|
36
|
+
readFileSync("/proc/self/cgroup", "utf8");
|
|
37
|
+
return "process";
|
|
38
|
+
} catch {
|
|
39
|
+
return "unknown";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse the systemd unit owning this process from /proc/self/cgroup. A
|
|
45
|
+
* --user service cgroup looks like:
|
|
46
|
+
* 0::/user.slice/user-1000.slice/user@1000.service/app.slice/agent-relay-orchestrator.service
|
|
47
|
+
* We want the LAST `*.service` segment that isn't the user manager itself.
|
|
48
|
+
*/
|
|
49
|
+
export function parseSystemdUnitFromCgroup(cgroup: string): string | undefined {
|
|
50
|
+
const services = cgroup
|
|
51
|
+
.split("\n")
|
|
52
|
+
.flatMap((line) => line.split("/"))
|
|
53
|
+
.filter((seg) => seg.endsWith(".service"))
|
|
54
|
+
.filter((seg) => !/^user@\d+\.service$/.test(seg) && seg !== "init.scope");
|
|
55
|
+
const last = services.at(-1);
|
|
56
|
+
return last && last.length > ".service".length ? last : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function detectSystemdUnit(): string | undefined {
|
|
60
|
+
try {
|
|
61
|
+
return parseSystemdUnitFromCgroup(readFileSync("/proc/self/cgroup", "utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The install prefix is the directory above `node_modules` when the orchestrator
|
|
69
|
+
* runs from an installed package (e.g. ~/.agent-relay/runtime). Undefined when
|
|
70
|
+
* running from a source/workspace checkout.
|
|
71
|
+
*/
|
|
72
|
+
export function detectRuntimePrefix(moduleUrl: string): string | undefined {
|
|
73
|
+
let path: string;
|
|
74
|
+
try {
|
|
75
|
+
path = fileURLToPath(moduleUrl);
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const marker = "/node_modules/";
|
|
80
|
+
const idx = path.indexOf(marker);
|
|
81
|
+
return idx >= 0 ? path.slice(0, idx) : undefined;
|
|
82
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { OrchestratorConfig } from "./config";
|
|
4
|
+
import type { RelayClient, RelayCommand } from "./relay";
|
|
5
|
+
import { detectSelfSupervision } from "./self-supervision";
|
|
6
|
+
|
|
7
|
+
const VALID_PROVIDERS = new Set(["auto", "all", "codex", "claude", "orchestrator"]);
|
|
8
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
9
|
+
|
|
10
|
+
export interface SelfUpgradeRunner {
|
|
11
|
+
run(cmd: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
12
|
+
commandExists(name: string): boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const defaultRunner: SelfUpgradeRunner = {
|
|
16
|
+
async run(cmd) {
|
|
17
|
+
const proc = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" });
|
|
18
|
+
const [stdout, stderr] = await Promise.all([
|
|
19
|
+
new Response(proc.stdout).text(),
|
|
20
|
+
new Response(proc.stderr).text(),
|
|
21
|
+
]);
|
|
22
|
+
const exitCode = await proc.exited;
|
|
23
|
+
return { exitCode, stdout, stderr };
|
|
24
|
+
},
|
|
25
|
+
commandExists(name) {
|
|
26
|
+
try {
|
|
27
|
+
return Bun.spawnSync({ cmd: ["which", name], stdout: "ignore", stderr: "ignore" }).exitCode === 0;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface SelfUpgradePlan {
|
|
35
|
+
targetVersion: string;
|
|
36
|
+
providers: string[];
|
|
37
|
+
unit: string;
|
|
38
|
+
runtimePrefix?: string;
|
|
39
|
+
binary: string;
|
|
40
|
+
installCmd: string[];
|
|
41
|
+
restartCmd: string[];
|
|
42
|
+
/** restart runs decoupled from this process's cgroup (transient unit) */
|
|
43
|
+
restartDetached: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the upgrade plan from a command's params + detected self-supervision.
|
|
48
|
+
* Throws (with an operator-facing message) when the request is invalid or the
|
|
49
|
+
* host can't self-restart — caught by the caller and reported as a failed command.
|
|
50
|
+
*/
|
|
51
|
+
export function planSelfUpgrade(
|
|
52
|
+
params: Record<string, unknown>,
|
|
53
|
+
supervision = detectSelfSupervision(),
|
|
54
|
+
runner: SelfUpgradeRunner = defaultRunner,
|
|
55
|
+
): SelfUpgradePlan {
|
|
56
|
+
const targetVersion = typeof params.targetVersion === "string" ? params.targetVersion.trim() : "";
|
|
57
|
+
if (!SEMVER_RE.test(targetVersion)) {
|
|
58
|
+
throw new Error(`invalid targetVersion "${targetVersion}" (expected x.y.z)`);
|
|
59
|
+
}
|
|
60
|
+
const providers = normalizeProviders(params.providers);
|
|
61
|
+
|
|
62
|
+
if (supervision.supervisor !== "systemd" || !supervision.selfUnit) {
|
|
63
|
+
throw new Error("orchestrator is not under systemd --user; remote self-upgrade requires a systemd unit (P1)");
|
|
64
|
+
}
|
|
65
|
+
const unit = supervision.selfUnit;
|
|
66
|
+
const binary = resolveBinary(supervision.runtimePrefix);
|
|
67
|
+
|
|
68
|
+
const installCmd = [
|
|
69
|
+
binary, "upgrade",
|
|
70
|
+
"--version", targetVersion,
|
|
71
|
+
"--providers", providers.join(","),
|
|
72
|
+
"--no-restart",
|
|
73
|
+
"--yes",
|
|
74
|
+
];
|
|
75
|
+
if (supervision.runtimePrefix) installCmd.push("--runtime-prefix", supervision.runtimePrefix);
|
|
76
|
+
|
|
77
|
+
// Decouple the restart from this orchestrator's own cgroup: restarting our unit
|
|
78
|
+
// SIGTERMs us, and a child in our cgroup would be killed mid-restart. systemd-run
|
|
79
|
+
// schedules it as an independent transient unit that survives our teardown.
|
|
80
|
+
const restartDetached = runner.commandExists("systemd-run");
|
|
81
|
+
const restartCmd = restartDetached
|
|
82
|
+
? ["systemd-run", "--user", "--collect", "--description", "agent-relay orchestrator self-upgrade restart", "systemctl", "--user", "restart", unit]
|
|
83
|
+
: ["setsid", "systemctl", "--user", "restart", unit];
|
|
84
|
+
|
|
85
|
+
return { targetVersion, providers, unit, runtimePrefix: supervision.runtimePrefix, binary, installCmd, restartCmd, restartDetached };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Handle an orchestrator.upgrade command: validate, install the target version
|
|
90
|
+
* WITHOUT restart (so install failures are caught while we're still alive), then
|
|
91
|
+
* launch a decoupled restart. The command is intentionally left "running" — the
|
|
92
|
+
* relay settles it by reconciling the version we report after we come back up.
|
|
93
|
+
*/
|
|
94
|
+
export async function handleSelfUpgrade(
|
|
95
|
+
command: RelayCommand,
|
|
96
|
+
_config: OrchestratorConfig,
|
|
97
|
+
relay: RelayClient,
|
|
98
|
+
runner: SelfUpgradeRunner = defaultRunner,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const plan = planSelfUpgrade(command.params, detectSelfSupervision(), runner);
|
|
101
|
+
await relay.updateCommand(command.id, "running", {
|
|
102
|
+
phase: "installing",
|
|
103
|
+
targetVersion: plan.targetVersion,
|
|
104
|
+
providers: plan.providers,
|
|
105
|
+
unit: plan.unit,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const install = await runner.run(plan.installCmd);
|
|
109
|
+
if (install.exitCode !== 0) {
|
|
110
|
+
throw new Error(`install failed (exit ${install.exitCode}): ${(install.stderr || install.stdout).trim().slice(-500)}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await relay.updateCommand(command.id, "running", {
|
|
114
|
+
phase: "restart-pending",
|
|
115
|
+
targetVersion: plan.targetVersion,
|
|
116
|
+
unit: plan.unit,
|
|
117
|
+
restartDetached: plan.restartDetached,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Fire the restart and return. We do not await its effect — it tears us down.
|
|
121
|
+
const restart = await runner.run(plan.restartCmd);
|
|
122
|
+
if (restart.exitCode !== 0) {
|
|
123
|
+
throw new Error(`restart failed (exit ${restart.exitCode}): ${(restart.stderr || restart.stdout).trim().slice(-500)}`);
|
|
124
|
+
}
|
|
125
|
+
console.error(`[orchestrator] self-upgrade to ${plan.targetVersion} installed; restart dispatched for ${plan.unit}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeProviders(value: unknown): string[] {
|
|
129
|
+
const list = Array.isArray(value)
|
|
130
|
+
? value.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean)
|
|
131
|
+
: [];
|
|
132
|
+
const filtered = list.filter((p) => VALID_PROVIDERS.has(p));
|
|
133
|
+
const providers = filtered.length ? filtered : ["orchestrator"];
|
|
134
|
+
return [...new Set(providers)];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveBinary(runtimePrefix?: string): string {
|
|
138
|
+
if (runtimePrefix) {
|
|
139
|
+
const local = join(runtimePrefix, "node_modules", ".bin", "agent-relay");
|
|
140
|
+
if (existsSync(local)) return local;
|
|
141
|
+
}
|
|
142
|
+
return "agent-relay";
|
|
143
|
+
}
|
package/src/workspace-probe.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { mkdirSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, join, resolve } from "node:path";
|
|
5
|
-
import type { WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree } from "agent-relay-sdk";
|
|
5
|
+
import type { WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus } from "agent-relay-sdk";
|
|
6
|
+
|
|
7
|
+
const MAX_DIFF_PATCH_BYTES = 200_000;
|
|
6
8
|
|
|
7
9
|
interface WorkspaceResolutionInput {
|
|
8
10
|
cwd: string;
|
|
@@ -145,13 +147,336 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
|
|
|
145
147
|
};
|
|
146
148
|
}
|
|
147
149
|
|
|
148
|
-
export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string }): { workspaceId?: string; removed: boolean; worktreePath?: string } {
|
|
150
|
+
export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; deleteBranch?: boolean }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean } {
|
|
149
151
|
if (!workspace.worktreePath) throw new Error("worktreePath required");
|
|
150
152
|
const path = resolve(workspace.worktreePath);
|
|
151
153
|
const repo = workspace.repoRoot ? resolve(workspace.repoRoot) : path;
|
|
152
154
|
const result = git(["worktree", "remove", "--force", path], repo);
|
|
153
155
|
if (!result.ok) throw new Error(result.stderr || `failed to remove workspace ${path}`);
|
|
154
|
-
|
|
156
|
+
// Once the worktree is gone the agent/... branch is litter — delete it so
|
|
157
|
+
// branches don't accumulate. Best-effort: don't fail cleanup if it can't
|
|
158
|
+
// (e.g. branch already gone, or checked out elsewhere).
|
|
159
|
+
let branchDeleted = false;
|
|
160
|
+
if (workspace.branch && workspace.deleteBranch !== false) {
|
|
161
|
+
branchDeleted = git(["branch", "-D", workspace.branch], repo).ok;
|
|
162
|
+
}
|
|
163
|
+
return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read-only git interrogation of a worktree: how much work it holds (commits
|
|
168
|
+
* ahead/behind base, dirty files, last commit). Computed on the host because
|
|
169
|
+
* that is where git and the worktree live. Never throws — failures land in the
|
|
170
|
+
* returned `error`/`missing` fields so callers can degrade gracefully.
|
|
171
|
+
*/
|
|
172
|
+
export function workspaceGitState(input: { worktreePath?: string; baseRef?: string; baseSha?: string }): WorkspaceGitState {
|
|
173
|
+
if (!input.worktreePath) return { error: "worktreePath required" };
|
|
174
|
+
const path = resolve(input.worktreePath);
|
|
175
|
+
if (!existsSync(path)) return { missing: true };
|
|
176
|
+
|
|
177
|
+
const status = git(["status", "--porcelain"], path);
|
|
178
|
+
if (!status.ok) return { error: status.stderr || "git status failed" };
|
|
179
|
+
const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
|
|
180
|
+
|
|
181
|
+
const state: WorkspaceGitState = { dirty: dirtyCount > 0, dirtyCount };
|
|
182
|
+
|
|
183
|
+
const log = git(["log", "-1", "--format=%H%x1f%ct%x1f%s"], path);
|
|
184
|
+
if (log.ok && log.stdout) {
|
|
185
|
+
const [sha, ct, ...rest] = log.stdout.split("\x1f");
|
|
186
|
+
if (sha) {
|
|
187
|
+
const at = Number(ct) * 1000;
|
|
188
|
+
state.lastCommit = { sha, message: rest.join("\x1f"), ...(Number.isFinite(at) ? { at } : {}) };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Resolve a base to diff against: prefer the named branch (gives real behind
|
|
193
|
+
// counts), fall back to the fork SHA (behind is then always 0, ahead is the
|
|
194
|
+
// agent's new commits — still the signal we care about for cleanup).
|
|
195
|
+
const base = resolveBaseRef(path, input.baseRef, input.baseSha);
|
|
196
|
+
if (base) {
|
|
197
|
+
state.baseRef = base;
|
|
198
|
+
const counts = git(["rev-list", "--left-right", "--count", `${base}...HEAD`], path);
|
|
199
|
+
if (counts.ok && counts.stdout) {
|
|
200
|
+
const [behind, ahead] = counts.stdout.split(/\s+/).map((n) => Number(n));
|
|
201
|
+
if (Number.isFinite(behind)) state.behind = behind;
|
|
202
|
+
if (Number.isFinite(ahead)) state.ahead = ahead;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return state;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveBaseRef(worktreePath: string, baseRef?: string, baseSha?: string): string | undefined {
|
|
210
|
+
for (const candidate of [baseRef, baseSha]) {
|
|
211
|
+
if (candidate && git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], worktreePath).ok) {
|
|
212
|
+
return candidate;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Exit-time decision for an orphaned worktree (owner agent disappeared). Probe
|
|
220
|
+
* the worktree and either remove it (genuinely empty — clean tree, no commits
|
|
221
|
+
* ahead of base) or leave it intact and report a status the relay can flag for
|
|
222
|
+
* review. Never destroys work on uncertainty: any error or unknown ahead-count
|
|
223
|
+
* results in a flag, not a delete.
|
|
224
|
+
*/
|
|
225
|
+
export function reconcileWorkspace(workspace: { id?: string; repoRoot?: string; worktreePath?: string; branch?: string; baseRef?: string; baseSha?: string }): {
|
|
226
|
+
workspaceId?: string;
|
|
227
|
+
removed: boolean;
|
|
228
|
+
status: WorkspaceStatus;
|
|
229
|
+
gitState: WorkspaceGitState;
|
|
230
|
+
} {
|
|
231
|
+
const gitState = workspaceGitState(workspace);
|
|
232
|
+
if (gitState.missing) {
|
|
233
|
+
return { workspaceId: workspace.id, removed: false, status: "cleaned", gitState };
|
|
234
|
+
}
|
|
235
|
+
const empty = gitState.error === undefined && gitState.dirtyCount === 0 && gitState.ahead === 0;
|
|
236
|
+
if (empty) {
|
|
237
|
+
cleanupWorkspace({ id: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, branch: workspace.branch });
|
|
238
|
+
return { workspaceId: workspace.id, removed: true, status: "cleaned", gitState };
|
|
239
|
+
}
|
|
240
|
+
return { workspaceId: workspace.id, removed: false, status: "review_requested", gitState };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Diff a worktree's committed work against its base (base...HEAD): per-file
|
|
245
|
+
* line counts plus a size-capped unified patch, so the dashboard can show what
|
|
246
|
+
* an agent produced without an SSH session. Read-only; degrades into fields.
|
|
247
|
+
*/
|
|
248
|
+
export function workspaceDiff(input: { worktreePath?: string; baseRef?: string; baseSha?: string; includePatch?: boolean }): WorkspaceDiff {
|
|
249
|
+
if (!input.worktreePath) return { files: [], error: "worktreePath required" };
|
|
250
|
+
const path = resolve(input.worktreePath);
|
|
251
|
+
if (!existsSync(path)) return { files: [], missing: true };
|
|
252
|
+
|
|
253
|
+
const base = resolveBaseRef(path, input.baseRef, input.baseSha);
|
|
254
|
+
const range = base ? `${base}...HEAD` : "HEAD";
|
|
255
|
+
const result: WorkspaceDiff = { files: [], baseRef: base };
|
|
256
|
+
|
|
257
|
+
const status = git(["status", "--porcelain"], path);
|
|
258
|
+
if (status.ok) result.dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
|
|
259
|
+
|
|
260
|
+
if (base) {
|
|
261
|
+
const counts = git(["rev-list", "--count", `${base}..HEAD`], path);
|
|
262
|
+
if (counts.ok && counts.stdout) {
|
|
263
|
+
const ahead = Number(counts.stdout);
|
|
264
|
+
if (Number.isFinite(ahead)) result.ahead = ahead;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const numstat = git(["diff", "--numstat", range], path);
|
|
269
|
+
if (!numstat.ok) return { ...result, error: numstat.stderr || "git diff failed" };
|
|
270
|
+
result.files = parseNumstat(numstat.stdout);
|
|
271
|
+
|
|
272
|
+
if (input.includePatch !== false) {
|
|
273
|
+
const patch = git(["diff", range], path);
|
|
274
|
+
if (patch.ok && patch.stdout) {
|
|
275
|
+
if (patch.stdout.length > MAX_DIFF_PATCH_BYTES) {
|
|
276
|
+
result.patch = patch.stdout.slice(0, MAX_DIFF_PATCH_BYTES);
|
|
277
|
+
result.truncated = true;
|
|
278
|
+
} else {
|
|
279
|
+
result.patch = patch.stdout;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseNumstat(output: string): WorkspaceDiffFile[] {
|
|
287
|
+
if (!output) return [];
|
|
288
|
+
return output.split("\n").filter(Boolean).map((line) => {
|
|
289
|
+
const [add, del, ...rest] = line.split("\t");
|
|
290
|
+
const path = rest.join("\t");
|
|
291
|
+
const binary = add === "-" && del === "-";
|
|
292
|
+
return {
|
|
293
|
+
path,
|
|
294
|
+
binary,
|
|
295
|
+
...(binary ? {} : { additions: Number(add) || 0, deletions: Number(del) || 0 }),
|
|
296
|
+
};
|
|
297
|
+
}).filter((file) => file.path);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface WorkspaceMergeInput {
|
|
301
|
+
id?: string;
|
|
302
|
+
repoRoot?: string;
|
|
303
|
+
worktreePath?: string;
|
|
304
|
+
branch?: string;
|
|
305
|
+
baseRef?: string;
|
|
306
|
+
baseSha?: string;
|
|
307
|
+
strategy?: "pr" | "rebase-ff" | "auto";
|
|
308
|
+
deleteBranch?: boolean;
|
|
309
|
+
prTitle?: string;
|
|
310
|
+
prBody?: string;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function hasOriginRemote(cwd: string): boolean {
|
|
314
|
+
return git(["remote", "get-url", "origin"], cwd).ok;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function ghAvailable(): boolean {
|
|
318
|
+
return Boolean(Bun.which("gh"));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Predict whether merging the branch's commits into base would conflict, using
|
|
323
|
+
* git's three-way merge-tree (no working-tree changes). Exit 0 = clean, exit 1
|
|
324
|
+
* = conflicts. Anything else is treated as "unknown" (undefined).
|
|
325
|
+
*/
|
|
326
|
+
function predictConflict(worktreePath: string, base: string): boolean | undefined {
|
|
327
|
+
const result = git(["merge-tree", "--write-tree", "--name-only", base, "HEAD"], worktreePath);
|
|
328
|
+
if (result.ok) return false;
|
|
329
|
+
// git exits 1 specifically for merge conflicts; other failures are unknown.
|
|
330
|
+
return /CONFLICT|conflict/.test(result.stdout + result.stderr) || result.stdout.length > 0 ? true : undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Branch name a base ref points at (only meaningful for refs/heads). */
|
|
334
|
+
function baseBranchName(worktreePath: string, baseRef?: string): string | undefined {
|
|
335
|
+
if (!baseRef) return undefined;
|
|
336
|
+
const ref = baseRef.startsWith("refs/heads/") ? baseRef.slice("refs/heads/".length) : baseRef;
|
|
337
|
+
return git(["show-ref", "--verify", "--quiet", `refs/heads/${ref}`], worktreePath).ok ? ref : undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Locate the worktree (if any) that currently has `branch` checked out. */
|
|
341
|
+
function worktreeForBranch(repoRoot: string, branch: string): { path: string; dirty: boolean } | undefined {
|
|
342
|
+
const list = git(["worktree", "list", "--porcelain"], repoRoot);
|
|
343
|
+
if (!list.ok) return undefined;
|
|
344
|
+
const match = parseWorktrees(list.stdout).find((worktree) => worktree.branch === branch);
|
|
345
|
+
if (!match) return undefined;
|
|
346
|
+
const status = git(["status", "--porcelain"], match.path);
|
|
347
|
+
return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Read-only pre-flight for integrating a workspace's work. Reports the strategy
|
|
352
|
+
* `auto` would pick plus whether the merge is clean, would conflict, or is a
|
|
353
|
+
* no-op — so the dashboard can warn before the user commits to it.
|
|
354
|
+
*/
|
|
355
|
+
export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto" }): WorkspaceMergePreview {
|
|
356
|
+
const gitState = workspaceGitState(input);
|
|
357
|
+
const remote = input.worktreePath ? hasOriginRemote(resolve(input.worktreePath)) : false;
|
|
358
|
+
const gh = ghAvailable();
|
|
359
|
+
const baseBranch = input.worktreePath ? baseBranchName(resolve(input.worktreePath), input.baseRef) : undefined;
|
|
360
|
+
// PR needs a remote, gh, and a real base branch to target; otherwise land locally.
|
|
361
|
+
const strategy: "pr" | "rebase-ff" = input.strategy === "pr" || input.strategy === "rebase-ff"
|
|
362
|
+
? input.strategy
|
|
363
|
+
: remote && gh && baseBranch ? "pr" : "rebase-ff";
|
|
364
|
+
const base: WorkspaceMergePreview = { strategy, hasRemote: remote, ghAvailable: gh, baseRef: baseBranch ?? gitState.baseRef };
|
|
365
|
+
|
|
366
|
+
if (gitState.missing) return { ...base, missing: true, reason: "worktree no longer exists" };
|
|
367
|
+
if (gitState.error) return { ...base, error: gitState.error };
|
|
368
|
+
base.ahead = gitState.ahead;
|
|
369
|
+
base.behind = gitState.behind;
|
|
370
|
+
base.dirtyCount = gitState.dirtyCount;
|
|
371
|
+
if ((gitState.dirtyCount ?? 0) > 0) return { ...base, reason: "worktree has uncommitted changes" };
|
|
372
|
+
if ((gitState.ahead ?? 0) === 0) return { ...base, reason: "no commits to merge" };
|
|
373
|
+
if (gitState.baseRef && input.worktreePath) {
|
|
374
|
+
const conflict = predictConflict(resolve(input.worktreePath), gitState.baseRef);
|
|
375
|
+
base.conflict = conflict;
|
|
376
|
+
base.cleanFastForward = conflict === false && (gitState.behind ?? 0) === 0;
|
|
377
|
+
}
|
|
378
|
+
return base;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Integrate a workspace's work back into its base branch. Two strategies:
|
|
383
|
+
* - rebase-ff: rebase the agent branch onto base, fast-forward base to it,
|
|
384
|
+
* then remove the worktree and delete the branch. Lands work locally.
|
|
385
|
+
* - pr: push the branch to origin and open a PR via gh. Leaves the worktree
|
|
386
|
+
* and branch intact (the PR needs them).
|
|
387
|
+
* Refuses on a dirty worktree, predicted conflicts, or nothing to merge. Never
|
|
388
|
+
* destroys work on uncertainty.
|
|
389
|
+
*/
|
|
390
|
+
export function mergeWorkspace(input: WorkspaceMergeInput): WorkspaceMergeResult {
|
|
391
|
+
if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
|
|
392
|
+
const worktreePath = resolve(input.worktreePath);
|
|
393
|
+
const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
|
|
394
|
+
const branch = input.branch ?? shortBranch(git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath).stdout || undefined);
|
|
395
|
+
const preview = previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy });
|
|
396
|
+
const strategy = preview.strategy;
|
|
397
|
+
const head = (field: Partial<WorkspaceMergeResult>): WorkspaceMergeResult => ({ workspaceId: input.id, strategy, merged: false, status: "review_requested", branch, baseRef: preview.baseRef, ...field });
|
|
398
|
+
|
|
399
|
+
if (preview.missing) return head({ status: "cleaned", error: preview.reason });
|
|
400
|
+
if (preview.error) return head({ status: "review_requested", error: preview.error });
|
|
401
|
+
if (preview.reason) return head({ status: "review_requested", error: preview.reason });
|
|
402
|
+
if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
|
|
403
|
+
|
|
404
|
+
if (strategy === "pr") return mergePr(input, worktreePath, branch, preview, head);
|
|
405
|
+
return mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function mergePr(
|
|
409
|
+
input: WorkspaceMergeInput,
|
|
410
|
+
worktreePath: string,
|
|
411
|
+
branch: string | undefined,
|
|
412
|
+
preview: WorkspaceMergePreview,
|
|
413
|
+
head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
|
|
414
|
+
): WorkspaceMergeResult {
|
|
415
|
+
if (!branch) return head({ status: "review_requested", error: "cannot determine branch to push" });
|
|
416
|
+
const base = preview.baseRef;
|
|
417
|
+
const push = git(["push", "-u", "origin", branch], worktreePath);
|
|
418
|
+
if (!push.ok) return head({ status: "review_requested", error: push.stderr || "git push failed" });
|
|
419
|
+
|
|
420
|
+
const title = input.prTitle || git(["log", "-1", "--format=%s"], worktreePath).stdout || `Merge ${branch}`;
|
|
421
|
+
const body = input.prBody || `Automated PR for agent workspace branch \`${branch}\`.`;
|
|
422
|
+
const args = ["pr", "create", "--head", branch, "--title", title, "--body", body];
|
|
423
|
+
if (base) args.push("--base", base);
|
|
424
|
+
const proc = Bun.spawnSync(["gh", ...args], { cwd: worktreePath, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
425
|
+
const stdout = proc.stdout.toString().trim();
|
|
426
|
+
if (proc.exitCode !== 0) {
|
|
427
|
+
return head({ status: "review_requested", error: proc.stderr.toString().trim() || "gh pr create failed" });
|
|
428
|
+
}
|
|
429
|
+
const prUrl = stdout.split("\n").map((line) => line.trim()).find((line) => /^https?:\/\//.test(line));
|
|
430
|
+
// PR opened: work is on its way out but not landed. Keep the worktree/branch
|
|
431
|
+
// alive for the PR; record the plan with the URL.
|
|
432
|
+
return head({ status: "merge_planned", prUrl, error: undefined });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function mergeRebaseFf(
|
|
436
|
+
input: WorkspaceMergeInput,
|
|
437
|
+
worktreePath: string,
|
|
438
|
+
repoRoot: string,
|
|
439
|
+
branch: string | undefined,
|
|
440
|
+
preview: WorkspaceMergePreview,
|
|
441
|
+
head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
|
|
442
|
+
): WorkspaceMergeResult {
|
|
443
|
+
const base = preview.baseRef;
|
|
444
|
+
if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
|
|
445
|
+
if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
|
|
446
|
+
|
|
447
|
+
// Rebase the agent branch onto base so base can fast-forward to it.
|
|
448
|
+
if ((preview.behind ?? 0) > 0) {
|
|
449
|
+
const rebase = git(["rebase", base], worktreePath);
|
|
450
|
+
if (!rebase.ok) {
|
|
451
|
+
git(["rebase", "--abort"], worktreePath);
|
|
452
|
+
return head({ conflict: true, status: "conflict", error: rebase.stderr || "rebase onto base failed" });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const headSha = git(["rev-parse", "HEAD"], worktreePath).stdout;
|
|
456
|
+
|
|
457
|
+
// Advance base to the rebased branch. If base is checked out somewhere, do a
|
|
458
|
+
// real ff-only merge there so its working tree stays consistent; otherwise
|
|
459
|
+
// just move the ref. Refuse if the base worktree has uncommitted changes.
|
|
460
|
+
const baseWorktree = worktreeForBranch(repoRoot, base);
|
|
461
|
+
if (baseWorktree) {
|
|
462
|
+
if (baseWorktree.dirty) return head({ status: "review_requested", error: `base branch '${base}' has uncommitted changes in ${baseWorktree.path}` });
|
|
463
|
+
const ff = git(["merge", "--ff-only", branch], baseWorktree.path);
|
|
464
|
+
if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
|
|
465
|
+
} else {
|
|
466
|
+
const update = git(["update-ref", `refs/heads/${base}`, headSha], repoRoot);
|
|
467
|
+
if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Work is landed. Tear down the worktree and delete the agent branch.
|
|
471
|
+
const deleteBranch = input.deleteBranch !== false;
|
|
472
|
+
let worktreeRemoved = false;
|
|
473
|
+
let branchDeleted = false;
|
|
474
|
+
if (deleteBranch) {
|
|
475
|
+
const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
|
|
476
|
+
worktreeRemoved = removed.ok;
|
|
477
|
+
if (worktreeRemoved) branchDeleted = git(["branch", "-D", branch], repoRoot).ok;
|
|
478
|
+
}
|
|
479
|
+
return head({ merged: true, status: "merged", mergedSha: headSha, worktreeRemoved, branchDeleted, error: undefined });
|
|
155
480
|
}
|
|
156
481
|
|
|
157
482
|
async function availableBranch(repoRoot: string, base: string): Promise<string> {
|