@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.
Files changed (3) hide show
  1. package/README.md +21 -10
  2. package/package.json +4 -1
  3. 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 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.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 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,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 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;
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 baseRef = await getBaseRef(repoRoot, config.remote, defaultBranch);
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 ${defaultBranch} at ${baseCommit}. Found ${branchCommit} instead.`,
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
- .enum(["preview", "apply"])
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 baseRef = await getBaseRef(repoRoot, config.remote, defaultBranch);
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
- const requestedMode = args.mode || config.cleanupMode;
494
- if (requestedMode !== "apply") {
495
- return formatPreview(grouped, defaultBranch);
587
+ if (normalizedArgs.mode !== "apply") {
588
+ return formatPreview(grouped, baseBranch);
496
589
  }
497
590
 
498
- const requestedSelectors = [...new Set(args.selectors || [])];
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(defaultBranch, removed, failed, requestedSelectors);
671
+ return formatCleanupSummary(baseBranch, removed, failed, requestedSelectors);
579
672
  },
580
673
  }),
581
674
  },