@sven1103/opencode-worktree-workflow 0.4.0 → 0.5.0
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 +114 -21
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.0",
|
|
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,20 @@ 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;
|
|
375
466
|
}
|
|
376
467
|
|
|
377
468
|
async function ensureBranchDoesNotExist(repoRoot, branchName) {
|
|
@@ -398,7 +489,8 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
398
489
|
const repoRoot = await getRepoRoot();
|
|
399
490
|
const config = await loadWorkflowConfig(repoRoot);
|
|
400
491
|
const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
|
|
401
|
-
const
|
|
492
|
+
const baseBranch = await resolveBaseBranch(repoRoot, config.remote, config.baseBranch);
|
|
493
|
+
const baseRef = await getBaseRef(repoRoot, config.remote, baseBranch);
|
|
402
494
|
const baseCommit = (await git(["rev-parse", baseRef], { cwd: repoRoot })).stdout;
|
|
403
495
|
const slug = slugifyTitle(args.title);
|
|
404
496
|
|
|
@@ -423,7 +515,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
423
515
|
const branchCommit = (await git(["rev-parse", branchName], { cwd: repoRoot })).stdout;
|
|
424
516
|
if (branchCommit !== baseCommit) {
|
|
425
517
|
throw new Error(
|
|
426
|
-
`New branch ${branchName} does not match ${
|
|
518
|
+
`New branch ${branchName} does not match ${baseBranch} at ${baseCommit}. Found ${branchCommit} instead.`,
|
|
427
519
|
);
|
|
428
520
|
}
|
|
429
521
|
|
|
@@ -432,6 +524,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
432
524
|
`- branch: ${branchName}`,
|
|
433
525
|
`- worktree: ${worktreePath}`,
|
|
434
526
|
`- default branch: ${defaultBranch}`,
|
|
527
|
+
`- base branch: ${baseBranch}`,
|
|
435
528
|
`- base ref: ${baseRef}`,
|
|
436
529
|
`- base commit: ${baseCommit}`,
|
|
437
530
|
].join("\n");
|
|
@@ -440,26 +533,27 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
440
533
|
worktree_cleanup: tool({
|
|
441
534
|
description: "Preview or clean git worktrees",
|
|
442
535
|
args: {
|
|
443
|
-
mode: tool.schema
|
|
444
|
-
|
|
445
|
-
.default("preview")
|
|
446
|
-
.describe("Preview cleanup candidates or remove them"),
|
|
536
|
+
mode: tool.schema.string().optional().describe("Preview cleanup candidates or remove them"),
|
|
537
|
+
raw: tool.schema.string().optional().describe("Raw cleanup arguments from slash commands"),
|
|
447
538
|
selectors: tool.schema
|
|
448
539
|
.array(tool.schema.string())
|
|
449
540
|
.default([])
|
|
450
541
|
.describe("Optional branch names or worktree paths to remove explicitly"),
|
|
451
542
|
},
|
|
452
543
|
async execute(args, context) {
|
|
453
|
-
context.metadata({ title: `Clean worktrees (${args.mode})` });
|
|
454
|
-
|
|
455
544
|
const repoRoot = await getRepoRoot();
|
|
456
545
|
const config = await loadWorkflowConfig(repoRoot);
|
|
546
|
+
const normalizedArgs = normalizeCleanupArgs(args, config);
|
|
547
|
+
|
|
548
|
+
context.metadata({ title: `Clean worktrees (${normalizedArgs.mode})` });
|
|
549
|
+
|
|
457
550
|
const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
|
|
458
|
-
const
|
|
551
|
+
const baseBranch = await resolveBaseBranch(repoRoot, config.remote, config.baseBranch);
|
|
552
|
+
const baseRef = await getBaseRef(repoRoot, config.remote, baseBranch);
|
|
459
553
|
const activeWorktree = path.resolve(context.worktree || repoRoot);
|
|
460
554
|
const worktreeList = await git(["worktree", "list", "--porcelain"], { cwd: repoRoot });
|
|
461
555
|
const entries = parseWorktreeList(worktreeList.stdout);
|
|
462
|
-
const protectedBranches = new Set([defaultBranch, ...config.protectedBranches]);
|
|
556
|
+
const protectedBranches = new Set([defaultBranch, baseBranch, ...config.protectedBranches]);
|
|
463
557
|
const grouped = {
|
|
464
558
|
safe: [],
|
|
465
559
|
review: [],
|
|
@@ -490,12 +584,11 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
490
584
|
grouped[classified.status].push(classified);
|
|
491
585
|
}
|
|
492
586
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return formatPreview(grouped, defaultBranch);
|
|
587
|
+
if (normalizedArgs.mode !== "apply") {
|
|
588
|
+
return formatPreview(grouped, baseBranch);
|
|
496
589
|
}
|
|
497
590
|
|
|
498
|
-
const requestedSelectors = [...new Set(
|
|
591
|
+
const requestedSelectors = [...new Set(normalizedArgs.selectors || [])];
|
|
499
592
|
const selected = [];
|
|
500
593
|
const failed = [];
|
|
501
594
|
|
|
@@ -575,7 +668,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
575
668
|
allowFailure: true,
|
|
576
669
|
});
|
|
577
670
|
|
|
578
|
-
return formatCleanupSummary(
|
|
671
|
+
return formatCleanupSummary(baseBranch, removed, failed, requestedSelectors);
|
|
579
672
|
},
|
|
580
673
|
}),
|
|
581
674
|
},
|