@sven1103/opencode-worktree-workflow 0.6.3 → 1.0.0-alpha.2
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/index.cjs +18 -0
- package/package.json +8 -3
- 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
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parse } from "jsonc-parser";
|
|
5
|
+
import { inferTaskLifecycleTransition } from "./task-binding.js";
|
|
6
|
+
|
|
7
|
+
export const DEFAULTS = {
|
|
8
|
+
branchPrefix: "wt/",
|
|
9
|
+
remote: "origin",
|
|
10
|
+
baseBranch: null,
|
|
11
|
+
worktreeRoot: ".worktrees/$REPO",
|
|
12
|
+
cleanupMode: "preview",
|
|
13
|
+
protectedBranches: [],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const RESULT_SCHEMA_VERSION = "1.0.0";
|
|
17
|
+
|
|
18
|
+
async function pathExists(targetPath) {
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(targetPath);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isMissingGitRepositoryError(message) {
|
|
28
|
+
return /not a git repository/i.test(message);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export 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.replaceAll("$REPO", repoName).replaceAll("$ROOT", repoRoot).replaceAll("$ROOT_PARENT", path.dirname(repoRoot));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseShortBranch(branchRef) {
|
|
69
|
+
const prefix = "refs/heads/";
|
|
70
|
+
return branchRef.startsWith(prefix) ? branchRef.slice(prefix.length) : branchRef;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseWorktreeList(output) {
|
|
74
|
+
const entries = [];
|
|
75
|
+
let current = null;
|
|
76
|
+
for (const rawLine of output.split("\n")) {
|
|
77
|
+
const line = rawLine.trim();
|
|
78
|
+
if (!line) {
|
|
79
|
+
if (current) entries.push(current);
|
|
80
|
+
current = null;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (line.startsWith("worktree ")) {
|
|
84
|
+
if (current) entries.push(current);
|
|
85
|
+
current = { path: line.slice("worktree ".length) };
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!current) continue;
|
|
89
|
+
if (line.startsWith("HEAD ")) current.head = line.slice("HEAD ".length);
|
|
90
|
+
if (line.startsWith("branch ")) {
|
|
91
|
+
current.branchRef = line.slice("branch ".length);
|
|
92
|
+
current.branch = parseShortBranch(current.branchRef);
|
|
93
|
+
}
|
|
94
|
+
if (line === "detached") current.detached = true;
|
|
95
|
+
}
|
|
96
|
+
if (current) entries.push(current);
|
|
97
|
+
return entries;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function shellQuote(value) {
|
|
101
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatWorktreeSummary(item) {
|
|
105
|
+
return `${item.branch || "(detached)"} -> ${item.path}${item.head ? ` (${item.head.slice(0, 12)})` : ""}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatCopyPasteCommands(item) {
|
|
109
|
+
const selector = item.branch || item.path;
|
|
110
|
+
const branchFlag = item.status === "safe" ? "-d" : "-D";
|
|
111
|
+
|
|
112
|
+
return [
|
|
113
|
+
` copy: /wt-clean apply ${selector}`,
|
|
114
|
+
` git: git worktree remove ${shellQuote(item.path)} && git branch ${branchFlag} ${shellQuote(item.branch)}`,
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatPreviewSection(title, items, { includeCommands = false } = {}) {
|
|
119
|
+
if (items.length === 0) return [title, "- none"];
|
|
120
|
+
const lines = [title];
|
|
121
|
+
for (const item of items) {
|
|
122
|
+
lines.push(`- ${formatWorktreeSummary(item)}: ${item.reason}`);
|
|
123
|
+
if (includeCommands && item.branch) lines.push(...formatCopyPasteCommands(item));
|
|
124
|
+
}
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatPreview(grouped, defaultBranch) {
|
|
129
|
+
return [
|
|
130
|
+
`Worktrees connected to this repository against ${defaultBranch}:`,
|
|
131
|
+
"",
|
|
132
|
+
...formatPreviewSection("Safe to clean automatically:", grouped.safe, { includeCommands: true }),
|
|
133
|
+
"",
|
|
134
|
+
...formatPreviewSection("Needs review before cleanup:", grouped.review, { includeCommands: true }),
|
|
135
|
+
"",
|
|
136
|
+
...formatPreviewSection("Not cleanable here:", grouped.blocked),
|
|
137
|
+
"",
|
|
138
|
+
"Run `/wt-clean apply` to remove only the safe group.",
|
|
139
|
+
"Run `/wt-clean apply <branch-or-path>` to also remove selected review items.",
|
|
140
|
+
].join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatPrepareSummary(result) {
|
|
144
|
+
return [
|
|
145
|
+
`Created worktree for "${result.title}".`,
|
|
146
|
+
`- branch: ${result.branch}`,
|
|
147
|
+
`- worktree: ${result.worktree_path}`,
|
|
148
|
+
`- default branch: ${result.default_branch}`,
|
|
149
|
+
`- base branch: ${result.base_branch}`,
|
|
150
|
+
`- base ref: ${result.base_ref}`,
|
|
151
|
+
`- base commit: ${result.base_commit}`,
|
|
152
|
+
].join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function formatCleanupSummary(defaultBranch, removed, failed, requestedSelectors) {
|
|
156
|
+
const lines = [`Cleaned worktrees relative to ${defaultBranch}:`];
|
|
157
|
+
if (removed.length === 0) lines.push("- none removed");
|
|
158
|
+
for (const item of removed) lines.push(`- removed (${item.selected ? "selected" : "auto"}) ${item.branch} -> ${item.path}`);
|
|
159
|
+
if (requestedSelectors.length > 0) {
|
|
160
|
+
lines.push("", "Requested selectors:");
|
|
161
|
+
for (const selector of requestedSelectors) lines.push(`- ${selector}`);
|
|
162
|
+
}
|
|
163
|
+
if (failed.length > 0) {
|
|
164
|
+
lines.push("", "Cleanup skipped for:");
|
|
165
|
+
for (const item of failed) lines.push(`- ${item.branch || item.selector} -> ${item.path || "(no path)"}: ${item.reason}`);
|
|
166
|
+
}
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function toStructuredCleanupItem(item) {
|
|
171
|
+
return {
|
|
172
|
+
branch: item.branch ?? null,
|
|
173
|
+
worktree_path: item.path ?? item.worktree_path ?? null,
|
|
174
|
+
head: item.head ?? null,
|
|
175
|
+
status: item.status ?? null,
|
|
176
|
+
reason: item.reason ?? null,
|
|
177
|
+
detached: Boolean(item.detached),
|
|
178
|
+
selectable: typeof item.selectable === "boolean" ? item.selectable : null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function toStructuredCleanupFailure(item) {
|
|
183
|
+
return {
|
|
184
|
+
selector: item.selector ?? null,
|
|
185
|
+
branch: item.branch ?? null,
|
|
186
|
+
worktree_path: item.path ?? item.worktree_path ?? null,
|
|
187
|
+
head: item.head ?? null,
|
|
188
|
+
status: item.status ?? null,
|
|
189
|
+
reason: item.reason ?? null,
|
|
190
|
+
detached: Boolean(item.detached),
|
|
191
|
+
selectable: typeof item.selectable === "boolean" ? item.selectable : null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildPrepareResult({ title, branch, worktreePath, defaultBranch, baseBranch, baseRef, baseCommit }) {
|
|
196
|
+
const result = {
|
|
197
|
+
schema_version: RESULT_SCHEMA_VERSION,
|
|
198
|
+
ok: true,
|
|
199
|
+
title,
|
|
200
|
+
branch,
|
|
201
|
+
worktree_path: worktreePath,
|
|
202
|
+
default_branch: defaultBranch,
|
|
203
|
+
base_branch: baseBranch,
|
|
204
|
+
base_ref: baseRef,
|
|
205
|
+
base_commit: baseCommit,
|
|
206
|
+
created: true,
|
|
207
|
+
};
|
|
208
|
+
return { ...result, message: formatPrepareSummary(result) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildCleanupPreviewResult({ defaultBranch, baseBranch, baseRef, grouped }) {
|
|
212
|
+
const structuredGroups = {
|
|
213
|
+
safe: grouped.safe.map(toStructuredCleanupItem),
|
|
214
|
+
review: grouped.review.map(toStructuredCleanupItem),
|
|
215
|
+
blocked: grouped.blocked.map(toStructuredCleanupItem),
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
schema_version: RESULT_SCHEMA_VERSION,
|
|
219
|
+
ok: true,
|
|
220
|
+
mode: "preview",
|
|
221
|
+
default_branch: defaultBranch,
|
|
222
|
+
base_branch: baseBranch,
|
|
223
|
+
base_ref: baseRef,
|
|
224
|
+
groups: structuredGroups,
|
|
225
|
+
message: formatPreview(grouped, baseBranch),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function withProvenanceLabel(item, provenance) {
|
|
230
|
+
if (provenance === "harness") return { ...item, provenance: "harness-managed" };
|
|
231
|
+
if (provenance === "manual") return { ...item, provenance: "manual" };
|
|
232
|
+
return { ...item, provenance: "unknown" };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function resolveItemProvenance(item, repoTasks) {
|
|
236
|
+
if (!Array.isArray(repoTasks) || repoTasks.length === 0) return "unknown";
|
|
237
|
+
const byPath = item.path ? repoTasks.find((task) => task?.worktree_path && path.resolve(task.worktree_path) === path.resolve(item.path)) : null;
|
|
238
|
+
const byBranch = !byPath && item.branch ? repoTasks.find((task) => task?.branch === item.branch || task?.task_id === item.branch) : null;
|
|
239
|
+
const task = byPath || byBranch;
|
|
240
|
+
return task?.created_by === "harness" || task?.created_by === "manual" ? task.created_by : "unknown";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function formatAdvisorySection(title, items) {
|
|
244
|
+
if (items.length === 0) return [title, "- none"];
|
|
245
|
+
const lines = [title];
|
|
246
|
+
for (const item of items) {
|
|
247
|
+
lines.push(`- ${formatWorktreeSummary(item)}: ${item.reason} (${item.provenance})`);
|
|
248
|
+
}
|
|
249
|
+
return lines;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function formatCleanupAdvisoryPreview({ grouped, baseBranch }) {
|
|
253
|
+
return [
|
|
254
|
+
`Cleanup advisory (preview) relative to ${baseBranch}:`,
|
|
255
|
+
"",
|
|
256
|
+
...formatAdvisorySection("Safe candidates:", grouped.safe),
|
|
257
|
+
"",
|
|
258
|
+
...formatAdvisorySection("Review candidates:", grouped.review),
|
|
259
|
+
"",
|
|
260
|
+
...formatAdvisorySection("Blocked:", grouped.blocked),
|
|
261
|
+
"",
|
|
262
|
+
"No cleanup has been applied.",
|
|
263
|
+
].join("\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function buildCleanupAdvisoryPreviewResult({ defaultBranch, baseBranch, baseRef, grouped, repoTasks }) {
|
|
267
|
+
const labeled = {
|
|
268
|
+
safe: grouped.safe.map((item) => withProvenanceLabel(item, resolveItemProvenance(item, repoTasks))),
|
|
269
|
+
review: grouped.review.map((item) => withProvenanceLabel(item, resolveItemProvenance(item, repoTasks))),
|
|
270
|
+
blocked: grouped.blocked.map((item) => withProvenanceLabel(item, resolveItemProvenance(item, repoTasks))),
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
schema_version: RESULT_SCHEMA_VERSION,
|
|
274
|
+
ok: true,
|
|
275
|
+
mode: "preview",
|
|
276
|
+
advisory: true,
|
|
277
|
+
default_branch: defaultBranch,
|
|
278
|
+
base_branch: baseBranch,
|
|
279
|
+
base_ref: baseRef,
|
|
280
|
+
groups: {
|
|
281
|
+
safe: labeled.safe.map((item) => ({ ...toStructuredCleanupItem(item), provenance: item.provenance ?? "unknown" })),
|
|
282
|
+
review: labeled.review.map((item) => ({ ...toStructuredCleanupItem(item), provenance: item.provenance ?? "unknown" })),
|
|
283
|
+
blocked: labeled.blocked.map((item) => ({ ...toStructuredCleanupItem(item), provenance: item.provenance ?? "unknown" })),
|
|
284
|
+
},
|
|
285
|
+
message: formatCleanupAdvisoryPreview({ grouped: labeled, baseBranch }),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function buildCleanupApplyResult({ defaultBranch, baseBranch, baseRef, removed, failed, requestedSelectors }) {
|
|
290
|
+
return {
|
|
291
|
+
schema_version: RESULT_SCHEMA_VERSION,
|
|
292
|
+
ok: true,
|
|
293
|
+
mode: "apply",
|
|
294
|
+
default_branch: defaultBranch,
|
|
295
|
+
base_branch: baseBranch,
|
|
296
|
+
base_ref: baseRef,
|
|
297
|
+
requested_selectors: requestedSelectors,
|
|
298
|
+
removed: removed.map((item) => ({ ...toStructuredCleanupItem(item), selected: Boolean(item.selected) })),
|
|
299
|
+
failed: failed.map(toStructuredCleanupFailure),
|
|
300
|
+
message: formatCleanupSummary(baseBranch, removed, failed, requestedSelectors),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function splitCleanupToken(value) {
|
|
305
|
+
if (typeof value !== "string") return [];
|
|
306
|
+
return value.trim().split(/\s+/).filter(Boolean);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function parseCleanupRawArguments(raw) {
|
|
310
|
+
const tokens = splitCleanupToken(raw);
|
|
311
|
+
if (tokens[0] === "apply") return { mode: "apply", selectors: tokens.slice(1) };
|
|
312
|
+
if (tokens[0] === "preview") return { mode: "preview", selectors: tokens.slice(1) };
|
|
313
|
+
return { mode: null, selectors: tokens };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeCleanupArgs(args, config) {
|
|
317
|
+
const selectors = Array.isArray(args.selectors) ? [...args.selectors] : [];
|
|
318
|
+
const normalizedSelectors = [];
|
|
319
|
+
const rawArgs = parseCleanupRawArguments(args.raw);
|
|
320
|
+
let explicitMode = rawArgs.mode;
|
|
321
|
+
if (rawArgs.selectors.length > 0) selectors.unshift(...rawArgs.selectors);
|
|
322
|
+
if (typeof args.mode === "string" && args.mode.trim()) {
|
|
323
|
+
const modeValue = args.mode.trim();
|
|
324
|
+
if (modeValue === "apply" || modeValue === "preview") explicitMode = modeValue;
|
|
325
|
+
else selectors.unshift(...splitCleanupToken(modeValue));
|
|
326
|
+
}
|
|
327
|
+
for (const selector of selectors) {
|
|
328
|
+
if (typeof selector !== "string") continue;
|
|
329
|
+
if (selector.includes(" ")) normalizedSelectors.push(...splitCleanupToken(selector));
|
|
330
|
+
else normalizedSelectors.push(selector);
|
|
331
|
+
}
|
|
332
|
+
const inlineApply = normalizedSelectors[0] === "apply";
|
|
333
|
+
if (inlineApply) normalizedSelectors.shift();
|
|
334
|
+
const mode = explicitMode === "apply" || inlineApply ? "apply" : explicitMode || config.cleanupMode;
|
|
335
|
+
return { mode, selectors: normalizedSelectors };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function selectorMatches(item, selector) {
|
|
339
|
+
return item.branch === selector || item.path === path.resolve(selector);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function classifyEntry(entry, repoRoot, activeWorktree, protectedBranches, mergedIntoBase) {
|
|
343
|
+
const entryPath = path.resolve(entry.path);
|
|
344
|
+
const branchName = entry.branch;
|
|
345
|
+
const item = { branch: branchName, path: entryPath, head: entry.head, detached: Boolean(entry.detached) };
|
|
346
|
+
if (!branchName || entry.detached) return { ...item, status: "blocked", reason: !branchName ? "no branch" : "detached HEAD", selectable: false };
|
|
347
|
+
if (entryPath === path.resolve(repoRoot)) {
|
|
348
|
+
return {
|
|
349
|
+
...item,
|
|
350
|
+
status: "blocked",
|
|
351
|
+
reason: entryPath === activeWorktree ? "repository root, current worktree, protected branch" : "repository root",
|
|
352
|
+
selectable: false,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (entryPath === activeWorktree) return { ...item, status: "blocked", reason: "current worktree", selectable: false };
|
|
356
|
+
if (protectedBranches.has(branchName)) return { ...item, status: "blocked", reason: "protected branch", selectable: false };
|
|
357
|
+
if (mergedIntoBase) return { ...item, status: "safe", reason: "merged into base branch by git ancestry", selectable: true };
|
|
358
|
+
return { ...item, status: "review", reason: "not merged into base branch by git ancestry", selectable: true };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function createWorktreeWorkflowService({ directory, git, stateStore }) {
|
|
362
|
+
async function computeCleanupPreview({ repoRoot, activeWorktree }) {
|
|
363
|
+
const config = await loadWorkflowConfig(repoRoot);
|
|
364
|
+
const { defaultBranch, baseBranch, baseRef } = await resolveBaseTarget(repoRoot, config);
|
|
365
|
+
const currentWorktree = path.resolve(activeWorktree || repoRoot);
|
|
366
|
+
const entries = parseWorktreeList((await git(["worktree", "list", "--porcelain"], { cwd: repoRoot })).stdout);
|
|
367
|
+
const protectedBranches = new Set([defaultBranch, baseBranch, ...config.protectedBranches]);
|
|
368
|
+
const grouped = { safe: [], review: [], blocked: [] };
|
|
369
|
+
for (const entry of entries) {
|
|
370
|
+
let mergedIntoBase = false;
|
|
371
|
+
if (entry.branch && !entry.detached) {
|
|
372
|
+
const merged = await git(["merge-base", "--is-ancestor", entry.branch, baseRef], { cwd: repoRoot, allowFailure: true });
|
|
373
|
+
mergedIntoBase = merged.exitCode === 0;
|
|
374
|
+
}
|
|
375
|
+
const classified = classifyEntry(entry, repoRoot, currentWorktree, protectedBranches, mergedIntoBase);
|
|
376
|
+
grouped[classified.status].push(classified);
|
|
377
|
+
}
|
|
378
|
+
return { defaultBranch, baseBranch, baseRef, grouped };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function getRepoRoot() {
|
|
382
|
+
try {
|
|
383
|
+
return (await git(["rev-parse", "--show-toplevel"], { cwd: directory })).stdout;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (isMissingGitRepositoryError(error.message || "")) {
|
|
386
|
+
throw new Error("This command must run inside a git repository. Initialize a repository first or run it from an existing repo root.");
|
|
387
|
+
}
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function loadWorkflowConfig(repoRoot) {
|
|
392
|
+
const [projectConfig, projectConfigC, sidecarConfig] = await Promise.all([
|
|
393
|
+
readJsonFile(path.join(repoRoot, "opencode.json")),
|
|
394
|
+
readJsonFile(path.join(repoRoot, "opencode.jsonc")),
|
|
395
|
+
readJsonFile(path.join(repoRoot, ".opencode", "worktree-workflow.json")),
|
|
396
|
+
]);
|
|
397
|
+
const merged = { ...DEFAULTS, ...(projectConfig?.worktreeWorkflow ?? {}), ...(projectConfigC?.worktreeWorkflow ?? {}), ...(sidecarConfig ?? {}) };
|
|
398
|
+
return {
|
|
399
|
+
branchPrefix: normalizeBranchPrefix(merged.branchPrefix ?? DEFAULTS.branchPrefix),
|
|
400
|
+
remote: merged.remote || DEFAULTS.remote,
|
|
401
|
+
baseBranch: typeof merged.baseBranch === "string" && merged.baseBranch.trim() ? merged.baseBranch.trim() : null,
|
|
402
|
+
cleanupMode: merged.cleanupMode === "apply" ? "apply" : DEFAULTS.cleanupMode,
|
|
403
|
+
protectedBranches: Array.isArray(merged.protectedBranches) ? merged.protectedBranches.filter((value) => typeof value === "string") : [],
|
|
404
|
+
worktreeRoot: path.resolve(repoRoot, formatRootTemplate(merged.worktreeRoot || DEFAULTS.worktreeRoot, repoRoot)),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
async function getDefaultBranch(repoRoot, remote) {
|
|
408
|
+
const remoteHead = await git(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`], { cwd: repoRoot, allowFailure: true });
|
|
409
|
+
if (remoteHead.exitCode === 0 && remoteHead.stdout.startsWith(`${remote}/`)) return remoteHead.stdout.slice(remote.length + 1);
|
|
410
|
+
const remoteShow = await git(["remote", "show", remote], { cwd: repoRoot, allowFailure: true });
|
|
411
|
+
if (remoteShow.exitCode === 0) {
|
|
412
|
+
const match = remoteShow.stdout.match(/HEAD branch: (.+)/);
|
|
413
|
+
if (match?.[1]) return match[1].trim();
|
|
414
|
+
}
|
|
415
|
+
for (const candidate of ["main", "master", "trunk", "develop"]) {
|
|
416
|
+
if ((await git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], { cwd: repoRoot, allowFailure: true })).exitCode === 0) return candidate;
|
|
417
|
+
if ((await git(["show-ref", "--verify", "--quiet", `refs/remotes/${remote}/${candidate}`], { cwd: repoRoot, allowFailure: true })).exitCode === 0) return candidate;
|
|
418
|
+
}
|
|
419
|
+
const currentBranch = await git(["branch", "--show-current"], { cwd: repoRoot, allowFailure: true });
|
|
420
|
+
if (currentBranch.stdout) return currentBranch.stdout;
|
|
421
|
+
throw new Error("Could not determine the default branch for this repository.");
|
|
422
|
+
}
|
|
423
|
+
async function resolveBaseTarget(repoRoot, config) {
|
|
424
|
+
const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
|
|
425
|
+
const baseBranch = config.baseBranch || defaultBranch;
|
|
426
|
+
try {
|
|
427
|
+
await git(["fetch", "--prune", config.remote, baseBranch], { cwd: repoRoot });
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (isMissingRemoteError(error.message || "", config.remote)) {
|
|
430
|
+
throw new Error(`Could not fetch base branch information from remote \"${config.remote}\". Configure the expected remote in .opencode/worktree-workflow.json or add that remote to this repository.`);
|
|
431
|
+
}
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
const remoteRef = `refs/remotes/${config.remote}/${baseBranch}`;
|
|
435
|
+
const remoteExists = await git(["show-ref", "--verify", "--quiet", remoteRef], { cwd: repoRoot, allowFailure: true });
|
|
436
|
+
const baseRef = remoteExists.exitCode === 0 ? `${config.remote}/${baseBranch}` : baseBranch;
|
|
437
|
+
return { defaultBranch, baseBranch, baseRef };
|
|
438
|
+
}
|
|
439
|
+
async function ensureBranchDoesNotExist(repoRoot, branchName) {
|
|
440
|
+
const exists = await git(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd: repoRoot, allowFailure: true });
|
|
441
|
+
if (exists.exitCode === 0) throw new Error(`Local branch already exists: ${branchName}`);
|
|
442
|
+
}
|
|
443
|
+
async function updateStateForPrepare(repoRoot, sessionID, prepared, createdBy = "manual", workspaceRole = "linear-flow") {
|
|
444
|
+
if (!sessionID || !stateStore) return;
|
|
445
|
+
const state = await stateStore.loadSessionState(repoRoot, sessionID);
|
|
446
|
+
const next = stateStore.setActiveTask(
|
|
447
|
+
stateStore.upsertTask(state, {
|
|
448
|
+
task_id: prepared.branch,
|
|
449
|
+
title: prepared.title,
|
|
450
|
+
branch: prepared.branch,
|
|
451
|
+
worktree_path: prepared.worktree_path,
|
|
452
|
+
created_by: createdBy,
|
|
453
|
+
workspace_role: workspaceRole,
|
|
454
|
+
status: inferTaskLifecycleTransition({ explicitSignal: "activate" }),
|
|
455
|
+
}),
|
|
456
|
+
prepared.branch,
|
|
457
|
+
);
|
|
458
|
+
await stateStore.saveSessionState(repoRoot, sessionID, next);
|
|
459
|
+
}
|
|
460
|
+
async function updateStateForCleanup(repoRoot, sessionID, removed) {
|
|
461
|
+
if (!sessionID || !stateStore || removed.length === 0) return;
|
|
462
|
+
let state = await stateStore.loadSessionState(repoRoot, sessionID);
|
|
463
|
+
for (const item of removed) {
|
|
464
|
+
const existingByID = stateStore.findTaskByID(state, item.branch);
|
|
465
|
+
const existingByPath = stateStore.findTaskByWorktreePath(state, item.path);
|
|
466
|
+
const existing = existingByID || existingByPath;
|
|
467
|
+
const taskID = existing?.task_id || item.branch || item.path;
|
|
468
|
+
state = stateStore.upsertTask(state, {
|
|
469
|
+
task_id: taskID,
|
|
470
|
+
branch: item.branch,
|
|
471
|
+
worktree_path: item.path,
|
|
472
|
+
created_by: existing?.created_by || "manual",
|
|
473
|
+
status: inferTaskLifecycleTransition({ explicitSignal: "complete" }),
|
|
474
|
+
});
|
|
475
|
+
if (stateStore.getActiveTask(state) === taskID) {
|
|
476
|
+
state = stateStore.setActiveTask(state, null);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
await stateStore.saveSessionState(repoRoot, sessionID, state);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function prepare({ title, sessionID, createdBy = "manual" }) {
|
|
483
|
+
const repoRoot = await getRepoRoot();
|
|
484
|
+
const config = await loadWorkflowConfig(repoRoot);
|
|
485
|
+
const { defaultBranch, baseBranch, baseRef } = await resolveBaseTarget(repoRoot, config);
|
|
486
|
+
const baseCommit = (await git(["rev-parse", baseRef], { cwd: repoRoot })).stdout;
|
|
487
|
+
const slug = slugifyTitle(title);
|
|
488
|
+
if (!slug) throw new Error("Could not derive a branch name from the provided title.");
|
|
489
|
+
const branchName = `${config.branchPrefix}${slug}`;
|
|
490
|
+
const worktreePath = path.join(config.worktreeRoot, slug);
|
|
491
|
+
await ensureBranchDoesNotExist(repoRoot, branchName);
|
|
492
|
+
if (await pathExists(worktreePath)) throw new Error(`Worktree path already exists: ${worktreePath}`);
|
|
493
|
+
await fs.mkdir(config.worktreeRoot, { recursive: true });
|
|
494
|
+
await git(["worktree", "add", "-b", branchName, worktreePath, baseRef], { cwd: repoRoot });
|
|
495
|
+
const branchCommit = (await git(["rev-parse", branchName], { cwd: repoRoot })).stdout;
|
|
496
|
+
if (branchCommit !== baseCommit) throw new Error(`New branch ${branchName} does not match ${baseBranch} at ${baseCommit}. Found ${branchCommit} instead.`);
|
|
497
|
+
const result = buildPrepareResult({ title, branch: branchName, worktreePath, defaultBranch, baseBranch, baseRef, baseCommit });
|
|
498
|
+
await updateStateForPrepare(repoRoot, sessionID, result, createdBy);
|
|
499
|
+
return result;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function getSessionBinding({ repoRoot, sessionID }) {
|
|
503
|
+
if (!stateStore || !sessionID) return { state: null, activeTask: null };
|
|
504
|
+
const state = await stateStore.loadSessionState(repoRoot, sessionID);
|
|
505
|
+
const activeTask = stateStore.getActiveTaskRecord(state);
|
|
506
|
+
return { state, activeTask };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function ensureActiveWorktree({ sessionID, title, workspaceRole = "linear-flow" }) {
|
|
510
|
+
const repoRoot = await getRepoRoot();
|
|
511
|
+
const { state, activeTask } = await getSessionBinding({ repoRoot, sessionID });
|
|
512
|
+
if (activeTask?.worktree_path) {
|
|
513
|
+
if ((!activeTask.title && title) || !activeTask.workspace_role) {
|
|
514
|
+
const next = stateStore.setActiveTask(
|
|
515
|
+
stateStore.upsertTask(state, {
|
|
516
|
+
task_id: activeTask.task_id,
|
|
517
|
+
title: activeTask.title || title,
|
|
518
|
+
workspace_role: activeTask.workspace_role || workspaceRole,
|
|
519
|
+
}),
|
|
520
|
+
activeTask.task_id,
|
|
521
|
+
);
|
|
522
|
+
await stateStore.saveSessionState(repoRoot, sessionID, next);
|
|
523
|
+
const refreshed = stateStore.getActiveTaskRecord(next);
|
|
524
|
+
return { repoRoot, task: refreshed };
|
|
525
|
+
}
|
|
526
|
+
return { repoRoot, task: activeTask };
|
|
527
|
+
}
|
|
528
|
+
const prepared = await prepare({ title, sessionID, createdBy: "harness" });
|
|
529
|
+
await updateStateForPrepare(repoRoot, sessionID, prepared, "harness", workspaceRole);
|
|
530
|
+
return {
|
|
531
|
+
repoRoot,
|
|
532
|
+
task: {
|
|
533
|
+
task_id: prepared.branch,
|
|
534
|
+
title: prepared.title,
|
|
535
|
+
branch: prepared.branch,
|
|
536
|
+
worktree_path: prepared.worktree_path,
|
|
537
|
+
created_by: "harness",
|
|
538
|
+
workspace_role: workspaceRole,
|
|
539
|
+
status: "active",
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function recordToolUsage({ sessionID }) {
|
|
545
|
+
if (!stateStore || !sessionID) return;
|
|
546
|
+
const repoRoot = await getRepoRoot();
|
|
547
|
+
const state = await stateStore.loadSessionState(repoRoot, sessionID);
|
|
548
|
+
const activeTaskID = stateStore.getActiveTask(state);
|
|
549
|
+
if (!activeTaskID) return;
|
|
550
|
+
const next = stateStore.touchTask(state, activeTaskID);
|
|
551
|
+
await stateStore.saveSessionState(repoRoot, sessionID, next);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function recordTaskLifecycleSignal({ repoRoot, sessionID, taskID, worktreePath, signal }) {
|
|
555
|
+
if (!stateStore || !sessionID || !repoRoot) return null;
|
|
556
|
+
const nextStatus = inferTaskLifecycleTransition({ explicitSignal: signal });
|
|
557
|
+
if (nextStatus !== "completed" && nextStatus !== "blocked") return null;
|
|
558
|
+
let state = await stateStore.loadSessionState(repoRoot, sessionID);
|
|
559
|
+
const byID = taskID ? stateStore.findTaskByID(state, taskID) : null;
|
|
560
|
+
const byPath = !byID && worktreePath ? stateStore.findTaskByWorktreePath(state, worktreePath) : null;
|
|
561
|
+
const current = byID || byPath;
|
|
562
|
+
if (!current) return null;
|
|
563
|
+
state = stateStore.upsertTask(state, {
|
|
564
|
+
task_id: current.task_id,
|
|
565
|
+
branch: current.branch,
|
|
566
|
+
worktree_path: current.worktree_path,
|
|
567
|
+
created_by: current.created_by || "manual",
|
|
568
|
+
status: nextStatus,
|
|
569
|
+
});
|
|
570
|
+
if (stateStore.getActiveTask(state) === current.task_id) {
|
|
571
|
+
state = stateStore.setActiveTask(state, null);
|
|
572
|
+
}
|
|
573
|
+
await stateStore.saveSessionState(repoRoot, sessionID, state);
|
|
574
|
+
return { task_id: current.task_id, status: nextStatus };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function buildCleanupAdvisoryPreview({ repoRoot, activeWorktree }) {
|
|
578
|
+
const preview = await computeCleanupPreview({ repoRoot, activeWorktree });
|
|
579
|
+
const repoTasks = stateStore ? await stateStore.listRepoTasks(repoRoot) : [];
|
|
580
|
+
return buildCleanupAdvisoryPreviewResult({ ...preview, repoTasks });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function cleanup({ mode, raw, selectors = [], worktree, sessionID }) {
|
|
584
|
+
const repoRoot = await getRepoRoot();
|
|
585
|
+
const config = await loadWorkflowConfig(repoRoot);
|
|
586
|
+
const normalizedArgs = normalizeCleanupArgs({ mode, raw, selectors }, config);
|
|
587
|
+
const activeWorktree = path.resolve(worktree || repoRoot);
|
|
588
|
+
const { defaultBranch, baseBranch, baseRef, grouped } = await computeCleanupPreview({ repoRoot, activeWorktree });
|
|
589
|
+
if (normalizedArgs.mode !== "apply") return buildCleanupPreviewResult({ defaultBranch, baseBranch, baseRef, grouped });
|
|
590
|
+
const requestedSelectors = [...new Set(normalizedArgs.selectors || [])];
|
|
591
|
+
const selected = [];
|
|
592
|
+
const failed = [];
|
|
593
|
+
for (const selector of requestedSelectors) {
|
|
594
|
+
const match = [...grouped.safe, ...grouped.review, ...grouped.blocked].find((item) => selectorMatches(item, selector));
|
|
595
|
+
if (!match) {
|
|
596
|
+
failed.push({ selector, reason: "selector did not match any connected worktree" });
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (!match.selectable) {
|
|
600
|
+
failed.push({ ...match, selector, reason: `cannot remove via selector: ${match.reason}` });
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
selected.push({ ...match, selector });
|
|
604
|
+
}
|
|
605
|
+
const targets = [...grouped.safe];
|
|
606
|
+
for (const item of selected) {
|
|
607
|
+
if (!targets.some((target) => target.path === item.path)) targets.push({ ...item, selector: item.selector ?? null, selected: true });
|
|
608
|
+
}
|
|
609
|
+
const removed = [];
|
|
610
|
+
for (const candidate of targets) {
|
|
611
|
+
const removeWorktree = await git(["worktree", "remove", candidate.path], { cwd: repoRoot, allowFailure: true });
|
|
612
|
+
if (removeWorktree.exitCode !== 0) {
|
|
613
|
+
failed.push({ ...candidate, reason: removeWorktree.stderr || removeWorktree.stdout || "worktree remove failed" });
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const deleteBranch = await git(["branch", candidate.status === "safe" ? "-d" : "-D", candidate.branch], { cwd: repoRoot, allowFailure: true });
|
|
617
|
+
if (deleteBranch.exitCode !== 0) {
|
|
618
|
+
failed.push({ ...candidate, reason: deleteBranch.stderr || deleteBranch.stdout || "branch delete failed" });
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
removed.push(candidate);
|
|
622
|
+
}
|
|
623
|
+
await git(["worktree", "prune"], { cwd: repoRoot, allowFailure: true });
|
|
624
|
+
const result = buildCleanupApplyResult({ defaultBranch, baseBranch, baseRef, removed, failed, requestedSelectors });
|
|
625
|
+
await updateStateForCleanup(repoRoot, sessionID, removed);
|
|
626
|
+
return result;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
prepare,
|
|
631
|
+
cleanup,
|
|
632
|
+
getRepoRoot,
|
|
633
|
+
getSessionBinding,
|
|
634
|
+
ensureActiveWorktree,
|
|
635
|
+
recordToolUsage,
|
|
636
|
+
recordTaskLifecycleSignal,
|
|
637
|
+
buildCleanupAdvisoryPreview,
|
|
638
|
+
updateStateForPrepare,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export const __internalService = {
|
|
643
|
+
RESULT_SCHEMA_VERSION,
|
|
644
|
+
buildCleanupApplyResult,
|
|
645
|
+
buildCleanupAdvisoryPreviewResult,
|
|
646
|
+
buildCleanupPreviewResult,
|
|
647
|
+
buildPrepareResult,
|
|
648
|
+
classifyEntry,
|
|
649
|
+
normalizeCleanupArgs,
|
|
650
|
+
parseCleanupRawArguments,
|
|
651
|
+
toStructuredCleanupFailure,
|
|
652
|
+
toStructuredCleanupItem,
|
|
653
|
+
};
|