@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.
Files changed (3) hide show
  1. package/README.md +21 -10
  2. package/package.json +4 -1
  3. 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 default-branch commit
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
- You can override the defaults in `opencode.json`, `opencode.jsonc`, or `.opencode/worktree-workflow.json`:
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
- "worktreeWorkflow": {
92
- "branchPrefix": "wt/",
93
- "remote": "origin",
94
- "worktreeRoot": ".worktrees/$REPO",
95
- "cleanupMode": "preview",
96
- "protectedBranches": ["release"]
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.4.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 default branch by git ancestry",
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 default branch by git ancestry",
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 getBaseRef(repoRoot, remote, defaultBranch) {
366
- await git(["fetch", "--prune", remote, defaultBranch], { cwd: repoRoot });
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}/${defaultBranch}`;
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}/${defaultBranch}` : defaultBranch;
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 getDefaultBranch(repoRoot, config.remote);
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 ${defaultBranch} at ${baseCommit}. Found ${branchCommit} instead.`,
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
- .enum(["preview", "apply"])
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 defaultBranch = await getDefaultBranch(repoRoot, config.remote);
458
- const baseRef = await getBaseRef(repoRoot, config.remote, defaultBranch);
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
- const requestedMode = args.mode || config.cleanupMode;
494
- if (requestedMode !== "apply") {
495
- return formatPreview(grouped, defaultBranch);
595
+ if (normalizedArgs.mode !== "apply") {
596
+ return formatPreview(grouped, baseBranch);
496
597
  }
497
598
 
498
- const requestedSelectors = [...new Set(args.selectors || [])];
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(defaultBranch, removed, failed, requestedSelectors);
679
+ return formatCleanupSummary(baseBranch, removed, failed, requestedSelectors);
579
680
  },
580
681
  }),
581
682
  },