@sven1103/opencode-worktree-workflow 0.4.0 → 0.5.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 +21 -10
- package/package.json +4 -1
- package/src/index.js +124 -23
package/README.md
CHANGED
|
@@ -77,24 +77,34 @@ curl -fsSL "https://github.com/sven1103-agent/opencode-worktree-plugin/releases/
|
|
|
77
77
|
|
|
78
78
|
## What the plugin provides
|
|
79
79
|
|
|
80
|
-
- `worktree_prepare`: create a worktree and matching branch from the latest
|
|
81
|
-
- `worktree_cleanup`: preview all connected worktrees, auto-clean safe ones, and optionally remove selected review items
|
|
80
|
+
- `worktree_prepare`: create a worktree and matching branch from the latest configured base-branch commit, or the default branch when no base branch is configured
|
|
81
|
+
- `worktree_cleanup`: preview all connected worktrees against the configured base branch, auto-clean safe ones, and optionally remove selected review items
|
|
82
82
|
|
|
83
83
|
This package currently focuses on plugin distribution. Slash command packaging can be layered on later.
|
|
84
84
|
|
|
85
85
|
## Optional project configuration
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
OpenCode's native `opencode.json` and `opencode.jsonc` files are schema-validated, so they can load this plugin through the standard `plugin` key but they cannot store a custom `worktreeWorkflow` block.
|
|
88
|
+
|
|
89
|
+
To override this plugin's defaults, put a sidecar config file at `.opencode/worktree-workflow.json`:
|
|
88
90
|
|
|
89
91
|
```json
|
|
90
92
|
{
|
|
91
|
-
"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
"branchPrefix": "wt/",
|
|
94
|
+
"remote": "origin",
|
|
95
|
+
"baseBranch": "release/v0.4.0",
|
|
96
|
+
"worktreeRoot": ".worktrees/$REPO",
|
|
97
|
+
"cleanupMode": "preview",
|
|
98
|
+
"protectedBranches": ["release"]
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Use `opencode.json` only to load the npm plugin itself:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"$schema": "https://opencode.ai/config.json",
|
|
107
|
+
"plugin": ["@sven1103/opencode-worktree-workflow"]
|
|
98
108
|
}
|
|
99
109
|
```
|
|
100
110
|
|
|
@@ -102,6 +112,7 @@ Supported settings:
|
|
|
102
112
|
|
|
103
113
|
- `branchPrefix`: prefix for generated worktree branches
|
|
104
114
|
- `remote`: remote used to detect the default branch and fetch updates
|
|
115
|
+
- `baseBranch`: optional branch name used as the creation and cleanup base; defaults to the remote's default branch
|
|
105
116
|
- `worktreeRoot`: destination root for new worktrees; supports `$REPO`, `$ROOT`, and `$ROOT_PARENT`
|
|
106
117
|
- `cleanupMode`: default cleanup behavior, either `preview` or `apply`
|
|
107
118
|
- `protectedBranches`: branches that should never be auto-cleaned
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sven1103/opencode-worktree-workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "OpenCode plugin for creating and cleaning up git worktrees.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test"
|
|
37
|
+
},
|
|
35
38
|
"dependencies": {
|
|
36
39
|
"@opencode-ai/plugin": "1.3.0",
|
|
37
40
|
"jsonc-parser": "^3.3.1"
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { tool } from "@opencode-ai/plugin";
|
|
|
7
7
|
const DEFAULTS = {
|
|
8
8
|
branchPrefix: "wt/",
|
|
9
9
|
remote: "origin",
|
|
10
|
+
baseBranch: null,
|
|
10
11
|
worktreeRoot: ".worktrees/$REPO",
|
|
11
12
|
cleanupMode: "preview",
|
|
12
13
|
protectedBranches: [],
|
|
@@ -193,6 +194,91 @@ function formatCleanupSummary(defaultBranch, removed, failed, requestedSelectors
|
|
|
193
194
|
return lines.join("\n");
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
function splitCleanupToken(value) {
|
|
198
|
+
if (typeof value !== "string") {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return value
|
|
203
|
+
.trim()
|
|
204
|
+
.split(/\s+/)
|
|
205
|
+
.filter(Boolean);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parseCleanupRawArguments(raw) {
|
|
209
|
+
const tokens = splitCleanupToken(raw);
|
|
210
|
+
|
|
211
|
+
if (tokens[0] === "apply") {
|
|
212
|
+
return {
|
|
213
|
+
mode: "apply",
|
|
214
|
+
selectors: tokens.slice(1),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (tokens[0] === "preview") {
|
|
219
|
+
return {
|
|
220
|
+
mode: "preview",
|
|
221
|
+
selectors: tokens.slice(1),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
mode: null,
|
|
227
|
+
selectors: tokens,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function normalizeCleanupArgs(args, config) {
|
|
232
|
+
const selectors = Array.isArray(args.selectors) ? [...args.selectors] : [];
|
|
233
|
+
const normalizedSelectors = [];
|
|
234
|
+
const rawArgs = parseCleanupRawArguments(args.raw);
|
|
235
|
+
let explicitMode = rawArgs.mode;
|
|
236
|
+
|
|
237
|
+
if (rawArgs.selectors.length > 0) {
|
|
238
|
+
selectors.unshift(...rawArgs.selectors);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (typeof args.mode === "string" && args.mode.trim()) {
|
|
242
|
+
const modeValue = args.mode.trim();
|
|
243
|
+
if (modeValue === "apply" || modeValue === "preview") {
|
|
244
|
+
explicitMode = modeValue;
|
|
245
|
+
} else {
|
|
246
|
+
selectors.unshift(...splitCleanupToken(modeValue));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const selector of selectors) {
|
|
251
|
+
if (typeof selector !== "string") {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (selector.includes(" ")) {
|
|
256
|
+
normalizedSelectors.push(...splitCleanupToken(selector));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
normalizedSelectors.push(selector);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const inlineApply = normalizedSelectors[0] === "apply";
|
|
264
|
+
|
|
265
|
+
if (inlineApply) {
|
|
266
|
+
normalizedSelectors.shift();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const mode = explicitMode === "apply" || inlineApply ? "apply" : explicitMode || config.cleanupMode;
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
mode,
|
|
273
|
+
selectors: normalizedSelectors,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const __internal = {
|
|
278
|
+
parseCleanupRawArguments,
|
|
279
|
+
normalizeCleanupArgs,
|
|
280
|
+
};
|
|
281
|
+
|
|
196
282
|
function selectorMatches(item, selector) {
|
|
197
283
|
const normalized = path.resolve(selector);
|
|
198
284
|
return item.branch === selector || item.path === normalized;
|
|
@@ -248,7 +334,7 @@ function classifyEntry(entry, repoRoot, activeWorktree, protectedBranches, merge
|
|
|
248
334
|
return {
|
|
249
335
|
...item,
|
|
250
336
|
status: "safe",
|
|
251
|
-
reason: "merged into
|
|
337
|
+
reason: "merged into base branch by git ancestry",
|
|
252
338
|
selectable: true,
|
|
253
339
|
};
|
|
254
340
|
}
|
|
@@ -256,7 +342,7 @@ function classifyEntry(entry, repoRoot, activeWorktree, protectedBranches, merge
|
|
|
256
342
|
return {
|
|
257
343
|
...item,
|
|
258
344
|
status: "review",
|
|
259
|
-
reason: "not merged into
|
|
345
|
+
reason: "not merged into base branch by git ancestry",
|
|
260
346
|
selectable: true,
|
|
261
347
|
};
|
|
262
348
|
}
|
|
@@ -302,6 +388,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
302
388
|
return {
|
|
303
389
|
branchPrefix: normalizeBranchPrefix(merged.branchPrefix ?? DEFAULTS.branchPrefix),
|
|
304
390
|
remote: merged.remote || DEFAULTS.remote,
|
|
391
|
+
baseBranch: typeof merged.baseBranch === "string" && merged.baseBranch.trim() ? merged.baseBranch.trim() : null,
|
|
305
392
|
cleanupMode: merged.cleanupMode === "apply" ? "apply" : DEFAULTS.cleanupMode,
|
|
306
393
|
protectedBranches: Array.isArray(merged.protectedBranches)
|
|
307
394
|
? merged.protectedBranches.filter((value) => typeof value === "string")
|
|
@@ -362,16 +449,32 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
362
449
|
throw new Error("Could not determine the default branch for this repository.");
|
|
363
450
|
}
|
|
364
451
|
|
|
365
|
-
async function
|
|
366
|
-
|
|
452
|
+
async function resolveBaseBranch(repoRoot, remote, configuredBaseBranch) {
|
|
453
|
+
return configuredBaseBranch || getDefaultBranch(repoRoot, remote);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function getBaseRef(repoRoot, remote, baseBranch) {
|
|
457
|
+
await git(["fetch", "--prune", remote, baseBranch], { cwd: repoRoot });
|
|
367
458
|
|
|
368
|
-
const remoteRef = `refs/remotes/${remote}/${
|
|
459
|
+
const remoteRef = `refs/remotes/${remote}/${baseBranch}`;
|
|
369
460
|
const remoteExists = await git(["show-ref", "--verify", "--quiet", remoteRef], {
|
|
370
461
|
cwd: repoRoot,
|
|
371
462
|
allowFailure: true,
|
|
372
463
|
});
|
|
373
464
|
|
|
374
|
-
return remoteExists.exitCode === 0 ? `${remote}/${
|
|
465
|
+
return remoteExists.exitCode === 0 ? `${remote}/${baseBranch}` : baseBranch;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function resolveBaseTarget(repoRoot, config) {
|
|
469
|
+
const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
|
|
470
|
+
const baseBranch = await resolveBaseBranch(repoRoot, config.remote, config.baseBranch);
|
|
471
|
+
const baseRef = await getBaseRef(repoRoot, config.remote, baseBranch);
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
defaultBranch,
|
|
475
|
+
baseBranch,
|
|
476
|
+
baseRef,
|
|
477
|
+
};
|
|
375
478
|
}
|
|
376
479
|
|
|
377
480
|
async function ensureBranchDoesNotExist(repoRoot, branchName) {
|
|
@@ -397,8 +500,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
397
500
|
|
|
398
501
|
const repoRoot = await getRepoRoot();
|
|
399
502
|
const config = await loadWorkflowConfig(repoRoot);
|
|
400
|
-
const defaultBranch = await
|
|
401
|
-
const baseRef = await getBaseRef(repoRoot, config.remote, defaultBranch);
|
|
503
|
+
const { defaultBranch, baseBranch, baseRef } = await resolveBaseTarget(repoRoot, config);
|
|
402
504
|
const baseCommit = (await git(["rev-parse", baseRef], { cwd: repoRoot })).stdout;
|
|
403
505
|
const slug = slugifyTitle(args.title);
|
|
404
506
|
|
|
@@ -423,7 +525,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
423
525
|
const branchCommit = (await git(["rev-parse", branchName], { cwd: repoRoot })).stdout;
|
|
424
526
|
if (branchCommit !== baseCommit) {
|
|
425
527
|
throw new Error(
|
|
426
|
-
`New branch ${branchName} does not match ${
|
|
528
|
+
`New branch ${branchName} does not match ${baseBranch} at ${baseCommit}. Found ${branchCommit} instead.`,
|
|
427
529
|
);
|
|
428
530
|
}
|
|
429
531
|
|
|
@@ -432,6 +534,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
432
534
|
`- branch: ${branchName}`,
|
|
433
535
|
`- worktree: ${worktreePath}`,
|
|
434
536
|
`- default branch: ${defaultBranch}`,
|
|
537
|
+
`- base branch: ${baseBranch}`,
|
|
435
538
|
`- base ref: ${baseRef}`,
|
|
436
539
|
`- base commit: ${baseCommit}`,
|
|
437
540
|
].join("\n");
|
|
@@ -440,26 +543,25 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
440
543
|
worktree_cleanup: tool({
|
|
441
544
|
description: "Preview or clean git worktrees",
|
|
442
545
|
args: {
|
|
443
|
-
mode: tool.schema
|
|
444
|
-
|
|
445
|
-
.default("preview")
|
|
446
|
-
.describe("Preview cleanup candidates or remove them"),
|
|
546
|
+
mode: tool.schema.string().optional().describe("Preview cleanup candidates or remove them"),
|
|
547
|
+
raw: tool.schema.string().optional().describe("Raw cleanup arguments from slash commands"),
|
|
447
548
|
selectors: tool.schema
|
|
448
549
|
.array(tool.schema.string())
|
|
449
550
|
.default([])
|
|
450
551
|
.describe("Optional branch names or worktree paths to remove explicitly"),
|
|
451
552
|
},
|
|
452
553
|
async execute(args, context) {
|
|
453
|
-
context.metadata({ title: `Clean worktrees (${args.mode})` });
|
|
454
|
-
|
|
455
554
|
const repoRoot = await getRepoRoot();
|
|
456
555
|
const config = await loadWorkflowConfig(repoRoot);
|
|
457
|
-
const
|
|
458
|
-
|
|
556
|
+
const normalizedArgs = normalizeCleanupArgs(args, config);
|
|
557
|
+
|
|
558
|
+
context.metadata({ title: `Clean worktrees (${normalizedArgs.mode})` });
|
|
559
|
+
|
|
560
|
+
const { defaultBranch, baseBranch, baseRef } = await resolveBaseTarget(repoRoot, config);
|
|
459
561
|
const activeWorktree = path.resolve(context.worktree || repoRoot);
|
|
460
562
|
const worktreeList = await git(["worktree", "list", "--porcelain"], { cwd: repoRoot });
|
|
461
563
|
const entries = parseWorktreeList(worktreeList.stdout);
|
|
462
|
-
const protectedBranches = new Set([defaultBranch, ...config.protectedBranches]);
|
|
564
|
+
const protectedBranches = new Set([defaultBranch, baseBranch, ...config.protectedBranches]);
|
|
463
565
|
const grouped = {
|
|
464
566
|
safe: [],
|
|
465
567
|
review: [],
|
|
@@ -490,12 +592,11 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
490
592
|
grouped[classified.status].push(classified);
|
|
491
593
|
}
|
|
492
594
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return formatPreview(grouped, defaultBranch);
|
|
595
|
+
if (normalizedArgs.mode !== "apply") {
|
|
596
|
+
return formatPreview(grouped, baseBranch);
|
|
496
597
|
}
|
|
497
598
|
|
|
498
|
-
const requestedSelectors = [...new Set(
|
|
599
|
+
const requestedSelectors = [...new Set(normalizedArgs.selectors || [])];
|
|
499
600
|
const selected = [];
|
|
500
601
|
const failed = [];
|
|
501
602
|
|
|
@@ -575,7 +676,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
575
676
|
allowFailure: true,
|
|
576
677
|
});
|
|
577
678
|
|
|
578
|
-
return formatCleanupSummary(
|
|
679
|
+
return formatCleanupSummary(baseBranch, removed, failed, requestedSelectors);
|
|
579
680
|
},
|
|
580
681
|
}),
|
|
581
682
|
},
|