@sven1103/opencode-worktree-workflow 0.6.3 → 1.0.0-alpha.1
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/README.md +74 -1
- package/package.json +1 -1
- package/src/cli.js +21 -13
- package/src/core/task-binding.js +154 -0
- package/src/core/worktree-service.js +653 -0
- package/src/index.js +293 -763
- package/src/runtime/state-store.js +300 -0
package/src/index.js
CHANGED
|
@@ -1,637 +1,351 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
1
3
|
import fs from "node:fs/promises";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
await fs.access(targetPath);
|
|
21
|
-
return true;
|
|
22
|
-
} catch {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function isMissingGitRepositoryError(message) {
|
|
28
|
-
return /not a git repository/i.test(message);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function isMissingRemoteError(message, remote) {
|
|
32
|
-
return new RegExp(`No such remote:?\s+${remote}|does not appear to be a git repository|Could not read from remote repository`, "i").test(message);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function readJsonFile(filePath) {
|
|
36
|
-
if (!(await pathExists(filePath))) {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const source = await fs.readFile(filePath, "utf8");
|
|
41
|
-
const data = parse(source);
|
|
42
|
-
return data && typeof data === "object" ? data : null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function normalizeBranchPrefix(prefix) {
|
|
46
|
-
if (!prefix) {
|
|
47
|
-
return "";
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function slugifyTitle(title) {
|
|
54
|
-
return title
|
|
55
|
-
.normalize("NFKD")
|
|
56
|
-
.replace(/[\u0300-\u036f]/g, "")
|
|
57
|
-
.toLowerCase()
|
|
58
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
59
|
-
.replace(/^-+|-+$/g, "")
|
|
60
|
-
.replace(/-{2,}/g, "-");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function formatRootTemplate(template, repoRoot) {
|
|
64
|
-
const repoName = path.basename(repoRoot);
|
|
65
|
-
return template
|
|
66
|
-
.replaceAll("$REPO", repoName)
|
|
67
|
-
.replaceAll("$ROOT", repoRoot)
|
|
68
|
-
.replaceAll("$ROOT_PARENT", path.dirname(repoRoot));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function parseShortBranch(branchRef) {
|
|
72
|
-
const prefix = "refs/heads/";
|
|
73
|
-
return branchRef.startsWith(prefix) ? branchRef.slice(prefix.length) : branchRef;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function parseWorktreeList(output) {
|
|
77
|
-
const entries = [];
|
|
78
|
-
let current = null;
|
|
79
|
-
|
|
80
|
-
for (const rawLine of output.split("\n")) {
|
|
81
|
-
const line = rawLine.trim();
|
|
82
|
-
if (!line) {
|
|
83
|
-
if (current) {
|
|
84
|
-
entries.push(current);
|
|
85
|
-
current = null;
|
|
86
|
-
}
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (line.startsWith("worktree ")) {
|
|
91
|
-
if (current) {
|
|
92
|
-
entries.push(current);
|
|
93
|
-
}
|
|
94
|
-
current = { path: line.slice("worktree ".length) };
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (!current) {
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (line.startsWith("HEAD ")) {
|
|
103
|
-
current.head = line.slice("HEAD ".length);
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (line.startsWith("branch ")) {
|
|
108
|
-
current.branchRef = line.slice("branch ".length);
|
|
109
|
-
current.branch = parseShortBranch(current.branchRef);
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (line === "detached") {
|
|
114
|
-
current.detached = true;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (current) {
|
|
119
|
-
entries.push(current);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return entries;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function shellQuote(value) {
|
|
126
|
-
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function formatWorktreeSummary(item) {
|
|
130
|
-
return `${item.branch || "(detached)"} -> ${item.path}${item.head ? ` (${item.head.slice(0, 12)})` : ""}`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function formatCopyPasteCommands(item) {
|
|
134
|
-
const selector = item.branch || item.path;
|
|
135
|
-
const branchFlag = item.status === "safe" ? "-d" : "-D";
|
|
6
|
+
import { createWorktreeWorkflowService, __internalService, isMissingGitRepositoryError, isMissingRemoteError } from "./core/worktree-service.js";
|
|
7
|
+
import {
|
|
8
|
+
buildWorkspaceContext,
|
|
9
|
+
buildWtCleanCommandPromptParts,
|
|
10
|
+
buildWtNewCommandPromptParts,
|
|
11
|
+
classifyToolExecution,
|
|
12
|
+
decideContinuity,
|
|
13
|
+
deriveTaskTitle,
|
|
14
|
+
deriveWorkspaceRole,
|
|
15
|
+
extractHandoffArtifactPath,
|
|
16
|
+
getToolRewritePolicy,
|
|
17
|
+
hasOpaqueRepoRootAbsoluteReference,
|
|
18
|
+
inferTaskLifecycleTransition,
|
|
19
|
+
rewriteRepoScopedPathIntoWorktree,
|
|
20
|
+
} from "./core/task-binding.js";
|
|
21
|
+
import { createRuntimeStateStore } from "./runtime/state-store.js";
|
|
136
22
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
];
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function formatPreviewSection(title, items, { includeCommands = false } = {}) {
|
|
144
|
-
if (items.length === 0) {
|
|
145
|
-
return [title, "- none"];
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const lines = [title];
|
|
149
|
-
|
|
150
|
-
for (const item of items) {
|
|
151
|
-
lines.push(`- ${formatWorktreeSummary(item)}: ${item.reason}`);
|
|
152
|
-
|
|
153
|
-
if (includeCommands && item.branch) {
|
|
154
|
-
lines.push(...formatCopyPasteCommands(item));
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return lines;
|
|
23
|
+
function publishStructuredResult(context, result) {
|
|
24
|
+
context.metadata({ metadata: { result } });
|
|
25
|
+
return result.message || JSON.stringify(result, null, 2);
|
|
159
26
|
}
|
|
160
27
|
|
|
161
|
-
|
|
162
|
-
return [
|
|
163
|
-
`Worktrees connected to this repository against ${defaultBranch}:`,
|
|
164
|
-
"",
|
|
165
|
-
...formatPreviewSection("Safe to clean automatically:", grouped.safe, { includeCommands: true }),
|
|
166
|
-
"",
|
|
167
|
-
...formatPreviewSection("Needs review before cleanup:", grouped.review, { includeCommands: true }),
|
|
168
|
-
"",
|
|
169
|
-
...formatPreviewSection("Not cleanable here:", grouped.blocked),
|
|
170
|
-
"",
|
|
171
|
-
"Run `/wt-clean apply` to remove only the safe group.",
|
|
172
|
-
"Run `/wt-clean apply <branch-or-path>` to also remove selected review items.",
|
|
173
|
-
].join("\n");
|
|
174
|
-
}
|
|
28
|
+
const WORKSPACE_SYSTEM_CONTEXT_MARKER = "Active workspace context:";
|
|
175
29
|
|
|
176
|
-
function
|
|
30
|
+
function formatWorkspaceSystemContext(workspaceContext) {
|
|
177
31
|
return [
|
|
178
|
-
|
|
179
|
-
`-
|
|
180
|
-
`-
|
|
181
|
-
`-
|
|
182
|
-
|
|
183
|
-
`- base ref: ${result.base_ref}`,
|
|
184
|
-
`- base commit: ${result.base_commit}`,
|
|
32
|
+
WORKSPACE_SYSTEM_CONTEXT_MARKER,
|
|
33
|
+
`- task_id: ${workspaceContext.task_id}`,
|
|
34
|
+
`- task_title: ${workspaceContext.task_title}`,
|
|
35
|
+
`- worktree_path: ${workspaceContext.worktree_path}`,
|
|
36
|
+
"Policy: operate within this worktree for repository actions.",
|
|
185
37
|
].join("\n");
|
|
186
38
|
}
|
|
187
39
|
|
|
188
|
-
function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (requestedSelectors.length > 0) {
|
|
201
|
-
lines.push("");
|
|
202
|
-
lines.push("Requested selectors:");
|
|
203
|
-
for (const selector of requestedSelectors) {
|
|
204
|
-
lines.push(`- ${selector}`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (failed.length > 0) {
|
|
209
|
-
lines.push("");
|
|
210
|
-
lines.push("Cleanup skipped for:");
|
|
211
|
-
for (const item of failed) {
|
|
212
|
-
lines.push(`- ${item.branch || item.selector} -> ${item.path || "(no path)"}: ${item.reason}`);
|
|
40
|
+
function createGitRunner($, directory) {
|
|
41
|
+
return async function git(args, options = {}) {
|
|
42
|
+
const cwd = options.cwd ?? directory;
|
|
43
|
+
const command = `git ${args.map((arg) => $.escape(String(arg))).join(" ")}`;
|
|
44
|
+
const result = await $`${{ raw: command }}`.cwd(cwd).quiet().nothrow();
|
|
45
|
+
const stdout = result.text().trim();
|
|
46
|
+
const stderr = result.stderr.toString("utf8").trim();
|
|
47
|
+
if (!options.allowFailure && result.exitCode !== 0) {
|
|
48
|
+
throw new Error(stderr || stdout || `Git command failed: ${command}`);
|
|
213
49
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return lines.join("\n");
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function toStructuredCleanupItem(item) {
|
|
220
|
-
return {
|
|
221
|
-
branch: item.branch ?? null,
|
|
222
|
-
worktree_path: item.path ?? item.worktree_path ?? null,
|
|
223
|
-
head: item.head ?? null,
|
|
224
|
-
status: item.status ?? null,
|
|
225
|
-
reason: item.reason ?? null,
|
|
226
|
-
detached: Boolean(item.detached),
|
|
227
|
-
selectable: typeof item.selectable === "boolean" ? item.selectable : null,
|
|
50
|
+
return { stdout, stderr, exitCode: result.exitCode };
|
|
228
51
|
};
|
|
229
52
|
}
|
|
230
53
|
|
|
231
|
-
function
|
|
232
|
-
return
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
return {
|
|
259
|
-
...result,
|
|
260
|
-
message: formatPrepareSummary(result),
|
|
261
|
-
};
|
|
54
|
+
function resolveSafeHandoffPath({ rawPath, repoRoot, directory }) {
|
|
55
|
+
if (typeof rawPath !== "string" || !rawPath.trim()) return null;
|
|
56
|
+
const candidate = path.resolve(path.isAbsolute(rawPath) ? rawPath : path.join(directory, rawPath));
|
|
57
|
+
const normalized = candidate.split(path.sep).join("/");
|
|
58
|
+
const safePattern = /\/\.opencode\/sessions\/[A-Za-z0-9_-]+\/handoffs\/[A-Za-z0-9._-]+\.json$/;
|
|
59
|
+
if (!safePattern.test(normalized)) return null;
|
|
60
|
+
const base = path.resolve(repoRoot);
|
|
61
|
+
const canonicalBase = (() => {
|
|
62
|
+
try {
|
|
63
|
+
return realpathSync(base);
|
|
64
|
+
} catch {
|
|
65
|
+
return base;
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
const canonicalCandidate = (() => {
|
|
69
|
+
try {
|
|
70
|
+
return realpathSync(candidate);
|
|
71
|
+
} catch {
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
const relative = path.relative(canonicalBase, canonicalCandidate);
|
|
76
|
+
const insideRepo = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
77
|
+
if (!insideRepo) return null;
|
|
78
|
+
return canonicalCandidate;
|
|
262
79
|
}
|
|
263
80
|
|
|
264
|
-
function
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
81
|
+
async function enrichHandoffArtifact(filePath, workspaceContext) {
|
|
82
|
+
const source = await fs.readFile(filePath, "utf8");
|
|
83
|
+
const parsed = JSON.parse(source);
|
|
84
|
+
const payload = parsed && typeof parsed === "object" && typeof parsed.payload === "object" ? parsed.payload : {};
|
|
85
|
+
const next = {
|
|
86
|
+
...parsed,
|
|
87
|
+
payload: {
|
|
88
|
+
...payload,
|
|
89
|
+
...workspaceContext,
|
|
268
90
|
},
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
return result.message || JSON.stringify(result, null, 2);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function buildCleanupPreviewResult({ defaultBranch, baseBranch, baseRef, grouped }) {
|
|
275
|
-
const structuredGroups = {
|
|
276
|
-
safe: grouped.safe.map(toStructuredCleanupItem),
|
|
277
|
-
review: grouped.review.map(toStructuredCleanupItem),
|
|
278
|
-
blocked: grouped.blocked.map(toStructuredCleanupItem),
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
return {
|
|
282
|
-
schema_version: RESULT_SCHEMA_VERSION,
|
|
283
|
-
ok: true,
|
|
284
|
-
mode: "preview",
|
|
285
|
-
default_branch: defaultBranch,
|
|
286
|
-
base_branch: baseBranch,
|
|
287
|
-
base_ref: baseRef,
|
|
288
|
-
groups: structuredGroups,
|
|
289
|
-
message: formatPreview(grouped, baseBranch),
|
|
290
91
|
};
|
|
92
|
+
await fs.writeFile(filePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
291
93
|
}
|
|
292
94
|
|
|
293
|
-
function
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
base_branch: baseBranch,
|
|
300
|
-
base_ref: baseRef,
|
|
301
|
-
requested_selectors: requestedSelectors,
|
|
302
|
-
removed: removed.map((item) => ({
|
|
303
|
-
...toStructuredCleanupItem(item),
|
|
304
|
-
selected: Boolean(item.selected),
|
|
305
|
-
})),
|
|
306
|
-
failed: failed.map(toStructuredCleanupFailure),
|
|
307
|
-
message: formatCleanupSummary(baseBranch, removed, failed, requestedSelectors),
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function splitCleanupToken(value) {
|
|
312
|
-
if (typeof value !== "string") {
|
|
313
|
-
return [];
|
|
95
|
+
async function readJsonIfExists(filePath) {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return null;
|
|
100
|
+
throw error;
|
|
314
101
|
}
|
|
315
|
-
|
|
316
|
-
return value
|
|
317
|
-
.trim()
|
|
318
|
-
.split(/\s+/)
|
|
319
|
-
.filter(Boolean);
|
|
320
102
|
}
|
|
321
103
|
|
|
322
|
-
function
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
selectors: tokens.slice(1),
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (tokens[0] === "preview") {
|
|
333
|
-
return {
|
|
334
|
-
mode: "preview",
|
|
335
|
-
selectors: tokens.slice(1),
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
return {
|
|
340
|
-
mode: null,
|
|
341
|
-
selectors: tokens,
|
|
342
|
-
};
|
|
104
|
+
function inferLifecycleSignalFromResultArtifact(resultArtifact) {
|
|
105
|
+
const status = typeof resultArtifact?.status === "string" ? resultArtifact.status : "";
|
|
106
|
+
const resultType = typeof resultArtifact?.result_type === "string" ? resultArtifact.result_type : "";
|
|
107
|
+
if (status === "blocked" || resultType === "blocked") return "block";
|
|
108
|
+
if (status === "done" || resultType === "implementation_summary") return "complete";
|
|
109
|
+
return null;
|
|
343
110
|
}
|
|
344
111
|
|
|
345
|
-
function
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (modeValue === "apply" || modeValue === "preview") {
|
|
358
|
-
explicitMode = modeValue;
|
|
359
|
-
} else {
|
|
360
|
-
selectors.unshift(...splitCleanupToken(modeValue));
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
for (const selector of selectors) {
|
|
365
|
-
if (typeof selector !== "string") {
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
if (selector.includes(" ")) {
|
|
370
|
-
normalizedSelectors.push(...splitCleanupToken(selector));
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
normalizedSelectors.push(selector);
|
|
112
|
+
async function resolveTaskResultLifecycleSignal(handoffPath) {
|
|
113
|
+
const handoff = await readJsonIfExists(handoffPath);
|
|
114
|
+
if (!handoff || typeof handoff !== "object") return null;
|
|
115
|
+
const handoffID = typeof handoff.handoff_id === "string" ? handoff.handoff_id : null;
|
|
116
|
+
if (!handoffID) return null;
|
|
117
|
+
const sessionDir = path.dirname(path.dirname(handoffPath));
|
|
118
|
+
const resultsDir = path.join(sessionDir, "results");
|
|
119
|
+
let resultFiles = [];
|
|
120
|
+
try {
|
|
121
|
+
resultFiles = await fs.readdir(resultsDir);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error;
|
|
375
124
|
}
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
125
|
+
const candidates = [];
|
|
126
|
+
for (const fileName of resultFiles) {
|
|
127
|
+
if (!fileName.endsWith(".json")) continue;
|
|
128
|
+
const filePath = path.join(resultsDir, fileName);
|
|
129
|
+
const artifact = await readJsonIfExists(filePath);
|
|
130
|
+
if (!artifact || artifact.source_handoff_id !== handoffID) continue;
|
|
131
|
+
const signal = inferLifecycleSignalFromResultArtifact(artifact);
|
|
132
|
+
if (!signal) continue;
|
|
133
|
+
candidates.push({ filePath, artifact, signal });
|
|
381
134
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
135
|
+
if (candidates.length === 0) return null;
|
|
136
|
+
candidates.sort((a, b) => {
|
|
137
|
+
const aCreated = typeof a.artifact?.created_at === "string" ? a.artifact.created_at : "";
|
|
138
|
+
const bCreated = typeof b.artifact?.created_at === "string" ? b.artifact.created_at : "";
|
|
139
|
+
return aCreated.localeCompare(bCreated);
|
|
140
|
+
});
|
|
141
|
+
const latest = candidates[candidates.length - 1];
|
|
385
142
|
return {
|
|
386
|
-
|
|
387
|
-
|
|
143
|
+
signal: latest.signal,
|
|
144
|
+
handoff,
|
|
145
|
+
result_artifact_path: latest.filePath,
|
|
388
146
|
};
|
|
389
147
|
}
|
|
390
148
|
|
|
391
149
|
export const __internal = {
|
|
392
|
-
RESULT_SCHEMA_VERSION,
|
|
393
|
-
|
|
394
|
-
buildCleanupPreviewResult,
|
|
395
|
-
buildPrepareResult,
|
|
396
|
-
classifyEntry,
|
|
150
|
+
RESULT_SCHEMA_VERSION: __internalService.RESULT_SCHEMA_VERSION,
|
|
151
|
+
...__internalService,
|
|
397
152
|
isMissingGitRepositoryError,
|
|
398
153
|
isMissingRemoteError,
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
154
|
+
decideContinuity,
|
|
155
|
+
classifyToolExecution,
|
|
156
|
+
deriveTaskTitle,
|
|
157
|
+
deriveWorkspaceRole,
|
|
158
|
+
extractHandoffArtifactPath,
|
|
159
|
+
buildWorkspaceContext,
|
|
160
|
+
inferTaskLifecycleTransition,
|
|
161
|
+
getToolRewritePolicy,
|
|
162
|
+
rewriteRepoScopedPathIntoWorktree,
|
|
163
|
+
hasOpaqueRepoRootAbsoluteReference,
|
|
403
164
|
};
|
|
404
165
|
|
|
405
|
-
function selectorMatches(item, selector) {
|
|
406
|
-
const normalized = path.resolve(selector);
|
|
407
|
-
return item.branch === selector || item.path === normalized;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function classifyEntry(entry, repoRoot, activeWorktree, protectedBranches, mergedIntoBase) {
|
|
411
|
-
const entryPath = path.resolve(entry.path);
|
|
412
|
-
const branchName = entry.branch;
|
|
413
|
-
const item = {
|
|
414
|
-
branch: branchName,
|
|
415
|
-
path: entryPath,
|
|
416
|
-
head: entry.head,
|
|
417
|
-
detached: Boolean(entry.detached),
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
if (!branchName || entry.detached) {
|
|
421
|
-
return {
|
|
422
|
-
...item,
|
|
423
|
-
status: "blocked",
|
|
424
|
-
reason: !branchName ? "no branch" : "detached HEAD",
|
|
425
|
-
selectable: false,
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
if (entryPath === path.resolve(repoRoot)) {
|
|
430
|
-
return {
|
|
431
|
-
...item,
|
|
432
|
-
status: "blocked",
|
|
433
|
-
reason: entryPath === activeWorktree ? "repository root, current worktree, protected branch" : "repository root",
|
|
434
|
-
selectable: false,
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
if (entryPath === activeWorktree) {
|
|
439
|
-
return {
|
|
440
|
-
...item,
|
|
441
|
-
status: "blocked",
|
|
442
|
-
reason: "current worktree",
|
|
443
|
-
selectable: false,
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (protectedBranches.has(branchName)) {
|
|
448
|
-
return {
|
|
449
|
-
...item,
|
|
450
|
-
status: "blocked",
|
|
451
|
-
reason: "protected branch",
|
|
452
|
-
selectable: false,
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
if (mergedIntoBase) {
|
|
457
|
-
return {
|
|
458
|
-
...item,
|
|
459
|
-
status: "safe",
|
|
460
|
-
reason: "merged into base branch by git ancestry",
|
|
461
|
-
selectable: true,
|
|
462
|
-
};
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
return {
|
|
466
|
-
...item,
|
|
467
|
-
status: "review",
|
|
468
|
-
reason: "not merged into base branch by git ancestry",
|
|
469
|
-
selectable: true,
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
|
|
473
166
|
export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const stderr = result.stderr.toString("utf8").trim();
|
|
167
|
+
const service = createWorktreeWorkflowService({
|
|
168
|
+
directory,
|
|
169
|
+
git: createGitRunner($, directory),
|
|
170
|
+
stateStore: createRuntimeStateStore(),
|
|
171
|
+
});
|
|
480
172
|
|
|
481
|
-
|
|
482
|
-
|
|
173
|
+
async function onToolExecuteBefore(input) {
|
|
174
|
+
const toolName = input?.tool?.name ?? input?.toolName;
|
|
175
|
+
const args = input?.args || {};
|
|
176
|
+
const classification = classifyToolExecution({ toolName, args });
|
|
177
|
+
if (classification.bypass) return input;
|
|
178
|
+
const rewritePolicy = getToolRewritePolicy({ toolName });
|
|
179
|
+
|
|
180
|
+
const sessionID = input?.sessionID ?? input?.context?.sessionID;
|
|
181
|
+
let binding = null;
|
|
182
|
+
|
|
183
|
+
if (classification.requiresIsolation) {
|
|
184
|
+
if (!sessionID) throw new Error(`Isolation required for ${toolName || "tool"} but sessionID is missing.`);
|
|
185
|
+
const repoRoot = await service.getRepoRoot();
|
|
186
|
+
for (const key of rewritePolicy.opaqueArgKeys) {
|
|
187
|
+
if (hasOpaqueRepoRootAbsoluteReference({ value: args[key], repoRoot })) {
|
|
188
|
+
throw new Error(`Blocked: ${toolName} ${key} includes repo-root absolute path that cannot be safely rewritten.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
binding = await service.ensureActiveWorktree({
|
|
192
|
+
sessionID,
|
|
193
|
+
title: deriveTaskTitle({ toolName, args, sessionID }),
|
|
194
|
+
workspaceRole: deriveWorkspaceRole({ subagentType: args.subagent_type }),
|
|
195
|
+
});
|
|
196
|
+
} else if (sessionID) {
|
|
197
|
+
const repoRoot = await service.getRepoRoot();
|
|
198
|
+
const { activeTask } = await service.getSessionBinding({ repoRoot, sessionID });
|
|
199
|
+
if (activeTask?.worktree_path) binding = { repoRoot, task: activeTask };
|
|
483
200
|
}
|
|
484
201
|
|
|
485
|
-
return
|
|
486
|
-
stdout,
|
|
487
|
-
stderr,
|
|
488
|
-
exitCode: result.exitCode,
|
|
489
|
-
};
|
|
490
|
-
}
|
|
202
|
+
if (!binding) return input;
|
|
491
203
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
);
|
|
204
|
+
if (toolName === "task") {
|
|
205
|
+
const handoffPath = resolveSafeHandoffPath({
|
|
206
|
+
rawPath: extractHandoffArtifactPath(args.prompt),
|
|
207
|
+
repoRoot: binding.repoRoot,
|
|
208
|
+
directory,
|
|
209
|
+
});
|
|
210
|
+
if (!handoffPath) {
|
|
211
|
+
throw new Error("Blocked: Task delegation prompt must reference a safe handoff artifact path.");
|
|
501
212
|
}
|
|
502
|
-
|
|
503
|
-
|
|
213
|
+
const workspaceContext = buildWorkspaceContext({ task: binding.task, workspaceRole: deriveWorkspaceRole({ subagentType: args.subagent_type }) });
|
|
214
|
+
await enrichHandoffArtifact(handoffPath, workspaceContext);
|
|
215
|
+
return {
|
|
216
|
+
...input,
|
|
217
|
+
args: {
|
|
218
|
+
...args,
|
|
219
|
+
prompt: `${args.prompt}\n\nWorkspace binding:\n- task_id: ${workspaceContext.task_id}\n- worktree_path: ${workspaceContext.worktree_path}\n- workspace_role: ${workspaceContext.workspace_role}`,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
504
222
|
}
|
|
505
|
-
}
|
|
506
223
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
readJsonFile(path.join(repoRoot, ".opencode", "worktree-workflow.json")),
|
|
512
|
-
]);
|
|
513
|
-
|
|
514
|
-
const merged = {
|
|
515
|
-
...DEFAULTS,
|
|
516
|
-
...(projectConfig?.worktreeWorkflow ?? {}),
|
|
517
|
-
...(projectConfigC?.worktreeWorkflow ?? {}),
|
|
518
|
-
...(sidecarConfig ?? {}),
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
return {
|
|
522
|
-
branchPrefix: normalizeBranchPrefix(merged.branchPrefix ?? DEFAULTS.branchPrefix),
|
|
523
|
-
remote: merged.remote || DEFAULTS.remote,
|
|
524
|
-
baseBranch: typeof merged.baseBranch === "string" && merged.baseBranch.trim() ? merged.baseBranch.trim() : null,
|
|
525
|
-
cleanupMode: merged.cleanupMode === "apply" ? "apply" : DEFAULTS.cleanupMode,
|
|
526
|
-
protectedBranches: Array.isArray(merged.protectedBranches)
|
|
527
|
-
? merged.protectedBranches.filter((value) => typeof value === "string")
|
|
528
|
-
: [],
|
|
529
|
-
worktreeRoot: path.resolve(repoRoot, formatRootTemplate(merged.worktreeRoot || DEFAULTS.worktreeRoot, repoRoot)),
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
async function getDefaultBranch(repoRoot, remote) {
|
|
534
|
-
const remoteHead = await git(
|
|
535
|
-
["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`],
|
|
536
|
-
{ cwd: repoRoot, allowFailure: true },
|
|
537
|
-
);
|
|
538
|
-
|
|
539
|
-
if (remoteHead.exitCode === 0 && remoteHead.stdout.startsWith(`${remote}/`)) {
|
|
540
|
-
return remoteHead.stdout.slice(remote.length + 1);
|
|
224
|
+
const nextArgs = { ...args };
|
|
225
|
+
if (toolName === "bash" && nextArgs.workdir == null && nextArgs.cwd == null) {
|
|
226
|
+
nextArgs.workdir = binding.task.worktree_path;
|
|
227
|
+
nextArgs.cwd = binding.task.worktree_path;
|
|
541
228
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
229
|
+
if ((toolName === "glob" || toolName === "grep") && nextArgs.path == null) {
|
|
230
|
+
nextArgs.path = binding.task.worktree_path;
|
|
231
|
+
}
|
|
232
|
+
for (const key of rewritePolicy.pathArgKeys) {
|
|
233
|
+
if (key in nextArgs) {
|
|
234
|
+
nextArgs[key] = rewriteRepoScopedPathIntoWorktree({
|
|
235
|
+
value: nextArgs[key],
|
|
236
|
+
repoRoot: binding.repoRoot,
|
|
237
|
+
worktreePath: binding.task.worktree_path,
|
|
238
|
+
});
|
|
552
239
|
}
|
|
553
240
|
}
|
|
554
241
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
cwd: repoRoot,
|
|
558
|
-
allowFailure: true,
|
|
559
|
-
});
|
|
560
|
-
if (hasLocal.exitCode === 0) {
|
|
561
|
-
return candidate;
|
|
562
|
-
}
|
|
242
|
+
return { ...input, args: nextArgs };
|
|
243
|
+
}
|
|
563
244
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
245
|
+
async function onToolExecuteAfter(input) {
|
|
246
|
+
const toolName = input?.tool?.name ?? input?.toolName;
|
|
247
|
+
const args = input?.args || {};
|
|
248
|
+
const sessionID = input?.sessionID ?? input?.context?.sessionID;
|
|
249
|
+
if (toolName === "worktree_prepare" && sessionID) {
|
|
250
|
+
const result = input?.metadata?.result ?? input?.result;
|
|
251
|
+
if (result?.branch && result?.worktree_path) {
|
|
252
|
+
const repoRoot = await service.getRepoRoot();
|
|
253
|
+
await service.updateStateForPrepare(repoRoot, sessionID, result, "manual");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (toolName === "task" && sessionID) {
|
|
257
|
+
const repoRoot = await service.getRepoRoot();
|
|
258
|
+
const handoffPath = resolveSafeHandoffPath({
|
|
259
|
+
rawPath: extractHandoffArtifactPath(args.prompt),
|
|
260
|
+
repoRoot,
|
|
261
|
+
directory,
|
|
567
262
|
});
|
|
568
|
-
if (
|
|
569
|
-
|
|
263
|
+
if (handoffPath) {
|
|
264
|
+
try {
|
|
265
|
+
const lifecycle = await resolveTaskResultLifecycleSignal(handoffPath);
|
|
266
|
+
if (lifecycle?.signal) {
|
|
267
|
+
const persisted = await service.recordTaskLifecycleSignal({
|
|
268
|
+
repoRoot,
|
|
269
|
+
sessionID,
|
|
270
|
+
taskID: lifecycle.handoff?.payload?.task_id,
|
|
271
|
+
worktreePath: lifecycle.handoff?.payload?.worktree_path,
|
|
272
|
+
signal: lifecycle.signal,
|
|
273
|
+
});
|
|
274
|
+
if (persisted && lifecycle.signal === "complete") {
|
|
275
|
+
try {
|
|
276
|
+
const advisory = await service.buildCleanupAdvisoryPreview({ repoRoot, activeWorktree: input?.context?.worktree ?? input?.worktree ?? directory });
|
|
277
|
+
const parts = Array.isArray(input?.output?.parts) ? [...input.output.parts] : [];
|
|
278
|
+
parts.push({ type: "text", text: advisory.message });
|
|
279
|
+
await service.recordToolUsage({ sessionID });
|
|
280
|
+
return {
|
|
281
|
+
...input,
|
|
282
|
+
output: {
|
|
283
|
+
...(input?.output && typeof input.output === "object" ? input.output : {}),
|
|
284
|
+
parts,
|
|
285
|
+
},
|
|
286
|
+
metadata: {
|
|
287
|
+
...(input?.metadata && typeof input.metadata === "object" ? input.metadata : {}),
|
|
288
|
+
advisory_cleanup_preview: advisory,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
} catch {
|
|
292
|
+
// Advisory preview is non-fatal.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// Artifact correlation/lifecycle inference is non-fatal.
|
|
298
|
+
}
|
|
570
299
|
}
|
|
571
300
|
}
|
|
301
|
+
if (sessionID) await service.recordToolUsage({ sessionID });
|
|
302
|
+
return input;
|
|
303
|
+
}
|
|
572
304
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
305
|
+
async function onCommandExecuteBefore(input) {
|
|
306
|
+
const commandName = input?.command?.name ?? input?.name;
|
|
307
|
+
const normalizedName = typeof commandName === "string" ? commandName.replace(/^\//, "") : "";
|
|
308
|
+
if (normalizedName !== "wt-new" && normalizedName !== "wt-clean") return input;
|
|
577
309
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
throw new Error("Could not determine the default branch for this repository.");
|
|
583
|
-
}
|
|
310
|
+
const argsText = typeof input?.arguments === "string" ? input.arguments : typeof input?.args === "string" ? input.args : "";
|
|
311
|
+
const parts = normalizedName === "wt-new" ? buildWtNewCommandPromptParts(argsText) : buildWtCleanCommandPromptParts(argsText);
|
|
584
312
|
|
|
585
|
-
|
|
586
|
-
|
|
313
|
+
return {
|
|
314
|
+
...input,
|
|
315
|
+
output: {
|
|
316
|
+
...(input?.output && typeof input.output === "object" ? input.output : {}),
|
|
317
|
+
parts,
|
|
318
|
+
},
|
|
319
|
+
};
|
|
587
320
|
}
|
|
588
321
|
|
|
589
|
-
async function
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
if (isMissingRemoteError(error.message || "", remote)) {
|
|
594
|
-
throw new Error(
|
|
595
|
-
`Could not fetch base branch information from remote \"${remote}\". Configure the expected remote in .opencode/worktree-workflow.json or add that remote to this repository.`,
|
|
596
|
-
);
|
|
597
|
-
}
|
|
322
|
+
async function onExperimentalChatSystemTransform(input) {
|
|
323
|
+
const sessionID = input?.sessionID ?? input?.context?.sessionID;
|
|
324
|
+
const existingSystem = typeof input?.system === "string" ? input.system : "";
|
|
325
|
+
if (!sessionID || existingSystem.includes(WORKSPACE_SYSTEM_CONTEXT_MARKER)) return input;
|
|
598
326
|
|
|
599
|
-
|
|
600
|
-
}
|
|
327
|
+
const repoRoot = await service.getRepoRoot();
|
|
328
|
+
const { activeTask } = await service.getSessionBinding({ repoRoot, sessionID });
|
|
329
|
+
if (!activeTask?.worktree_path) return input;
|
|
601
330
|
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
allowFailure: true,
|
|
331
|
+
const workspaceContext = buildWorkspaceContext({
|
|
332
|
+
task: activeTask,
|
|
333
|
+
workspaceRole: activeTask.workspace_role,
|
|
606
334
|
});
|
|
607
|
-
|
|
608
|
-
return remoteExists.exitCode === 0 ? `${remote}/${baseBranch}` : baseBranch;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
async function resolveBaseTarget(repoRoot, config) {
|
|
612
|
-
const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
|
|
613
|
-
const baseBranch = await resolveBaseBranch(repoRoot, config.remote, config.baseBranch);
|
|
614
|
-
const baseRef = await getBaseRef(repoRoot, config.remote, baseBranch);
|
|
615
|
-
|
|
335
|
+
const injected = formatWorkspaceSystemContext(workspaceContext);
|
|
616
336
|
return {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
baseRef,
|
|
337
|
+
...input,
|
|
338
|
+
system: existingSystem ? `${existingSystem}\n\n${injected}` : injected,
|
|
620
339
|
};
|
|
621
340
|
}
|
|
622
341
|
|
|
623
|
-
async function ensureBranchDoesNotExist(repoRoot, branchName) {
|
|
624
|
-
const exists = await git(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
|
625
|
-
cwd: repoRoot,
|
|
626
|
-
allowFailure: true,
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
if (exists.exitCode === 0) {
|
|
630
|
-
throw new Error(`Local branch already exists: ${branchName}`);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
342
|
return {
|
|
343
|
+
hooks: {
|
|
344
|
+
"command.execute.before": onCommandExecuteBefore,
|
|
345
|
+
"experimental.chat.system.transform": onExperimentalChatSystemTransform,
|
|
346
|
+
"tool.execute.before": onToolExecuteBefore,
|
|
347
|
+
"tool.execute.after": onToolExecuteAfter,
|
|
348
|
+
},
|
|
635
349
|
tool: {
|
|
636
350
|
worktree_prepare: tool({
|
|
637
351
|
description: "Create a synced git worktree from a descriptive title",
|
|
@@ -640,50 +354,8 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
640
354
|
},
|
|
641
355
|
async execute(args, context) {
|
|
642
356
|
context.metadata({ title: `Create worktree: ${args.title}` });
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const config = await loadWorkflowConfig(repoRoot);
|
|
646
|
-
const { defaultBranch, baseBranch, baseRef } = await resolveBaseTarget(repoRoot, config);
|
|
647
|
-
const baseCommit = (await git(["rev-parse", baseRef], { cwd: repoRoot })).stdout;
|
|
648
|
-
const slug = slugifyTitle(args.title);
|
|
649
|
-
|
|
650
|
-
if (!slug) {
|
|
651
|
-
throw new Error("Could not derive a branch name from the provided title.");
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const branchName = `${config.branchPrefix}${slug}`;
|
|
655
|
-
const worktreePath = path.join(config.worktreeRoot, slug);
|
|
656
|
-
|
|
657
|
-
await ensureBranchDoesNotExist(repoRoot, branchName);
|
|
658
|
-
|
|
659
|
-
if (await pathExists(worktreePath)) {
|
|
660
|
-
throw new Error(`Worktree path already exists: ${worktreePath}`);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
await fs.mkdir(config.worktreeRoot, { recursive: true });
|
|
664
|
-
await git(["worktree", "add", "-b", branchName, worktreePath, baseRef], {
|
|
665
|
-
cwd: repoRoot,
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
const branchCommit = (await git(["rev-parse", branchName], { cwd: repoRoot })).stdout;
|
|
669
|
-
if (branchCommit !== baseCommit) {
|
|
670
|
-
throw new Error(
|
|
671
|
-
`New branch ${branchName} does not match ${baseBranch} at ${baseCommit}. Found ${branchCommit} instead.`,
|
|
672
|
-
);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
return publishStructuredResult(
|
|
676
|
-
context,
|
|
677
|
-
buildPrepareResult({
|
|
678
|
-
title: args.title,
|
|
679
|
-
branch: branchName,
|
|
680
|
-
worktreePath,
|
|
681
|
-
defaultBranch,
|
|
682
|
-
baseBranch,
|
|
683
|
-
baseRef,
|
|
684
|
-
baseCommit,
|
|
685
|
-
}),
|
|
686
|
-
);
|
|
357
|
+
const result = await service.prepare({ title: args.title, sessionID: context.sessionID });
|
|
358
|
+
return publishStructuredResult(context, result);
|
|
687
359
|
},
|
|
688
360
|
}),
|
|
689
361
|
worktree_cleanup: tool({
|
|
@@ -691,160 +363,18 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
691
363
|
args: {
|
|
692
364
|
mode: tool.schema.string().optional().describe("Preview cleanup candidates or remove them"),
|
|
693
365
|
raw: tool.schema.string().optional().describe("Raw cleanup arguments from slash commands"),
|
|
694
|
-
selectors: tool.schema
|
|
695
|
-
.array(tool.schema.string())
|
|
696
|
-
.default([])
|
|
697
|
-
.describe("Optional branch names or worktree paths to remove explicitly"),
|
|
366
|
+
selectors: tool.schema.array(tool.schema.string()).default([]).describe("Optional branch names or worktree paths to remove explicitly"),
|
|
698
367
|
},
|
|
699
368
|
async execute(args, context) {
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
const { defaultBranch, baseBranch, baseRef } = await resolveBaseTarget(repoRoot, config);
|
|
707
|
-
const activeWorktree = path.resolve(context.worktree || repoRoot);
|
|
708
|
-
const worktreeList = await git(["worktree", "list", "--porcelain"], { cwd: repoRoot });
|
|
709
|
-
const entries = parseWorktreeList(worktreeList.stdout);
|
|
710
|
-
const protectedBranches = new Set([defaultBranch, baseBranch, ...config.protectedBranches]);
|
|
711
|
-
const grouped = {
|
|
712
|
-
safe: [],
|
|
713
|
-
review: [],
|
|
714
|
-
blocked: [],
|
|
715
|
-
};
|
|
716
|
-
|
|
717
|
-
for (const entry of entries) {
|
|
718
|
-
const branchName = entry.branch;
|
|
719
|
-
let mergedIntoBase = false;
|
|
720
|
-
|
|
721
|
-
if (branchName && !entry.detached) {
|
|
722
|
-
const merged = await git(["merge-base", "--is-ancestor", branchName, baseRef], {
|
|
723
|
-
cwd: repoRoot,
|
|
724
|
-
allowFailure: true,
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
mergedIntoBase = merged.exitCode === 0;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
const classified = classifyEntry(
|
|
731
|
-
entry,
|
|
732
|
-
repoRoot,
|
|
733
|
-
activeWorktree,
|
|
734
|
-
protectedBranches,
|
|
735
|
-
mergedIntoBase,
|
|
736
|
-
);
|
|
737
|
-
|
|
738
|
-
grouped[classified.status].push(classified);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
if (normalizedArgs.mode !== "apply") {
|
|
742
|
-
return publishStructuredResult(
|
|
743
|
-
context,
|
|
744
|
-
buildCleanupPreviewResult({
|
|
745
|
-
defaultBranch,
|
|
746
|
-
baseBranch,
|
|
747
|
-
baseRef,
|
|
748
|
-
grouped,
|
|
749
|
-
}),
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
const requestedSelectors = [...new Set(normalizedArgs.selectors || [])];
|
|
754
|
-
const selected = [];
|
|
755
|
-
const failed = [];
|
|
756
|
-
|
|
757
|
-
for (const selector of requestedSelectors) {
|
|
758
|
-
const match = [...grouped.safe, ...grouped.review, ...grouped.blocked].find((item) =>
|
|
759
|
-
selectorMatches(item, selector),
|
|
760
|
-
);
|
|
761
|
-
|
|
762
|
-
if (!match) {
|
|
763
|
-
failed.push({
|
|
764
|
-
selector,
|
|
765
|
-
reason: "selector did not match any connected worktree",
|
|
766
|
-
});
|
|
767
|
-
continue;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
if (!match.selectable) {
|
|
771
|
-
failed.push({
|
|
772
|
-
...match,
|
|
773
|
-
selector,
|
|
774
|
-
reason: `cannot remove via selector: ${match.reason}`,
|
|
775
|
-
});
|
|
776
|
-
continue;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
selected.push({
|
|
780
|
-
...match,
|
|
781
|
-
selector,
|
|
782
|
-
});
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
const targets = [...grouped.safe];
|
|
786
|
-
|
|
787
|
-
for (const item of selected) {
|
|
788
|
-
if (!targets.some((target) => target.path === item.path)) {
|
|
789
|
-
targets.push({
|
|
790
|
-
...item,
|
|
791
|
-
selector: item.selector ?? null,
|
|
792
|
-
selected: true,
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
const removed = [];
|
|
798
|
-
|
|
799
|
-
for (const candidate of targets) {
|
|
800
|
-
const removeWorktree = await git(["worktree", "remove", candidate.path], {
|
|
801
|
-
cwd: repoRoot,
|
|
802
|
-
allowFailure: true,
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
if (removeWorktree.exitCode !== 0) {
|
|
806
|
-
failed.push({
|
|
807
|
-
...candidate,
|
|
808
|
-
reason: removeWorktree.stderr || removeWorktree.stdout || "worktree remove failed",
|
|
809
|
-
});
|
|
810
|
-
continue;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
const deleteBranch = await git(
|
|
814
|
-
["branch", candidate.status === "safe" ? "-d" : "-D", candidate.branch],
|
|
815
|
-
{
|
|
816
|
-
cwd: repoRoot,
|
|
817
|
-
allowFailure: true,
|
|
818
|
-
},
|
|
819
|
-
);
|
|
820
|
-
|
|
821
|
-
if (deleteBranch.exitCode !== 0) {
|
|
822
|
-
failed.push({
|
|
823
|
-
...candidate,
|
|
824
|
-
reason: deleteBranch.stderr || deleteBranch.stdout || "branch delete failed",
|
|
825
|
-
});
|
|
826
|
-
continue;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
removed.push(candidate);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
await git(["worktree", "prune"], {
|
|
833
|
-
cwd: repoRoot,
|
|
834
|
-
allowFailure: true,
|
|
369
|
+
const result = await service.cleanup({
|
|
370
|
+
mode: args.mode,
|
|
371
|
+
raw: args.raw,
|
|
372
|
+
selectors: args.selectors,
|
|
373
|
+
worktree: context.worktree,
|
|
374
|
+
sessionID: context.sessionID,
|
|
835
375
|
});
|
|
836
|
-
|
|
837
|
-
return publishStructuredResult(
|
|
838
|
-
context,
|
|
839
|
-
buildCleanupApplyResult({
|
|
840
|
-
defaultBranch,
|
|
841
|
-
baseBranch,
|
|
842
|
-
baseRef,
|
|
843
|
-
removed,
|
|
844
|
-
failed,
|
|
845
|
-
requestedSelectors,
|
|
846
|
-
}),
|
|
847
|
-
);
|
|
376
|
+
context.metadata({ title: `Clean worktrees (${result.mode})` });
|
|
377
|
+
return publishStructuredResult(context, result);
|
|
848
378
|
},
|
|
849
379
|
}),
|
|
850
380
|
},
|