@sven1103/opencode-worktree-workflow 0.2.0 → 0.4.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 +24 -5
  2. package/package.json +1 -1
  3. package/src/index.js +198 -46
package/README.md CHANGED
@@ -4,7 +4,17 @@
4
4
 
5
5
  ## Install in an OpenCode project
6
6
 
7
- Add the plugin package to your project config:
7
+ Add the package as a project dependency, following the official docs style:
8
+
9
+ ```json
10
+ {
11
+ "dependencies": {
12
+ "@sven1103/opencode-worktree-workflow": "^0.2.0"
13
+ }
14
+ }
15
+ ```
16
+
17
+ Then reference the installed package from your OpenCode config:
8
18
 
9
19
  ```json
10
20
  {
@@ -13,13 +23,20 @@ Add the plugin package to your project config:
13
23
  }
14
24
  ```
15
25
 
16
- OpenCode installs npm plugins with Bun when it starts.
26
+ Keeping the npm dependency in `package.json` makes the installation more durable even if shared `opencode.json` bundles overwrite plugin entries.
27
+
28
+ If you do not already install dependencies in your project, you can add the package directly with npm:
29
+
30
+ ```sh
31
+ npm install @sven1103/opencode-worktree-workflow
32
+ ```
17
33
 
18
34
  ## Install slash commands
19
35
 
20
36
  OpenCode loads custom commands from either `.opencode/commands/` (project) or `~/.config/opencode/commands/` (global).
21
37
 
22
38
  This repo publishes `wt-new.md` and `wt-clean.md` as GitHub Release assets so you can install them without browsing the repository.
39
+ For a plain-language explanation of what each release contains, how it is produced, and how to verify it before installing, see `docs/releases.md`.
23
40
 
24
41
  Project install (latest release):
25
42
 
@@ -61,7 +78,7 @@ curl -fsSL "https://github.com/sven1103-agent/opencode-worktree-plugin/releases/
61
78
  ## What the plugin provides
62
79
 
63
80
  - `worktree_prepare`: create a worktree and matching branch from the latest default-branch commit
64
- - `worktree_cleanup`: preview or remove merged worktrees
81
+ - `worktree_cleanup`: preview all connected worktrees, auto-clean safe ones, and optionally remove selected review items
65
82
 
66
83
  This package currently focuses on plugin distribution. Slash command packaging can be layered on later.
67
84
 
@@ -74,7 +91,7 @@ You can override the defaults in `opencode.json`, `opencode.jsonc`, or `.opencod
74
91
  "worktreeWorkflow": {
75
92
  "branchPrefix": "wt/",
76
93
  "remote": "origin",
77
- "worktreeRoot": "../.worktrees/$REPO",
94
+ "worktreeRoot": ".worktrees/$REPO",
78
95
  "cleanupMode": "preview",
79
96
  "protectedBranches": ["release"]
80
97
  }
@@ -93,6 +110,8 @@ Supported settings:
93
110
 
94
111
  This repo is prepared for npm publishing from GitHub Actions using npm trusted publishing.
95
112
 
113
+ If you consume releases instead of contributing to the repo, `docs/releases.md` is the end-user guide for understanding what the published npm package and GitHub Release assets include.
114
+
96
115
  Typical release flow:
97
116
 
98
117
  1. Publish the package once manually to create it on npm.
@@ -101,7 +120,7 @@ Typical release flow:
101
120
 
102
121
  The release workflow creates a `release/v<version>` branch from `main`, updates `package.json` and `package-lock.json`, commits the version bump there, creates a matching `v<version>` tag, and pushes the branch and tag.
103
122
 
104
- The release workflow then explicitly starts the publish workflow for that tag, which verifies the tag matches `package.json` before running `npm publish` using OIDC, without storing an `NPM_TOKEN` secret. Merge the release branch back to `main` afterward if you want the version bump recorded on the default branch.
123
+ The release workflow then explicitly starts the publish workflow for that tag, which verifies the tag matches `package.json`, runs `npm publish` using OIDC without storing an `NPM_TOKEN` secret, and creates or updates the GitHub Release with generated release notes plus the command assets. Merge the release branch back to `main` afterward if you want the version bump recorded on the default branch.
105
124
 
106
125
  ## Local development
107
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sven1103/opencode-worktree-workflow",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "OpenCode plugin for creating and cleaning up git worktrees.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -7,7 +7,7 @@ import { tool } from "@opencode-ai/plugin";
7
7
  const DEFAULTS = {
8
8
  branchPrefix: "wt/",
9
9
  remote: "origin",
10
- worktreeRoot: "../.worktrees/$REPO",
10
+ worktreeRoot: ".worktrees/$REPO",
11
11
  cleanupMode: "preview",
12
12
  protectedBranches: [],
13
13
  };
@@ -111,34 +111,74 @@ function parseWorktreeList(output) {
111
111
  return entries;
112
112
  }
113
113
 
114
- function formatPreview(candidates, defaultBranch) {
115
- if (candidates.length === 0) {
116
- return [
117
- `No merged worktrees are ready for cleanup against ${defaultBranch}.`,
118
- "",
119
- "Use `/wt-clean apply` after merged branches appear here.",
120
- ].join("\n");
114
+ function shellQuote(value) {
115
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
116
+ }
117
+
118
+ function formatWorktreeSummary(item) {
119
+ return `${item.branch || "(detached)"} -> ${item.path}${item.head ? ` (${item.head.slice(0, 12)})` : ""}`;
120
+ }
121
+
122
+ function formatCopyPasteCommands(item) {
123
+ const selector = item.branch || item.path;
124
+ const branchFlag = item.status === "safe" ? "-d" : "-D";
125
+
126
+ return [
127
+ ` copy: /wt-clean apply ${selector}`,
128
+ ` git: git worktree remove ${shellQuote(item.path)} && git branch ${branchFlag} ${shellQuote(item.branch)}`,
129
+ ];
130
+ }
131
+
132
+ function formatPreviewSection(title, items, { includeCommands = false } = {}) {
133
+ if (items.length === 0) {
134
+ return [title, "- none"];
121
135
  }
122
136
 
137
+ const lines = [title];
138
+
139
+ for (const item of items) {
140
+ lines.push(`- ${formatWorktreeSummary(item)}: ${item.reason}`);
141
+
142
+ if (includeCommands && item.branch) {
143
+ lines.push(...formatCopyPasteCommands(item));
144
+ }
145
+ }
146
+
147
+ return lines;
148
+ }
149
+
150
+ function formatPreview(grouped, defaultBranch) {
123
151
  return [
124
- `Merged worktrees ready for cleanup against ${defaultBranch}:`,
125
- ...candidates.map(
126
- (candidate) =>
127
- `- ${candidate.branch} -> ${candidate.path}${candidate.head ? ` (${candidate.head.slice(0, 12)})` : ""}`,
128
- ),
152
+ `Worktrees connected to this repository against ${defaultBranch}:`,
153
+ "",
154
+ ...formatPreviewSection("Safe to clean automatically:", grouped.safe, { includeCommands: true }),
129
155
  "",
130
- "Run `/wt-clean apply` to remove these worktrees and delete their local branches.",
156
+ ...formatPreviewSection("Needs review before cleanup:", grouped.review, { includeCommands: true }),
157
+ "",
158
+ ...formatPreviewSection("Not cleanable here:", grouped.blocked),
159
+ "",
160
+ "Run `/wt-clean apply` to remove only the safe group.",
161
+ "Run `/wt-clean apply <branch-or-path>` to also remove selected review items.",
131
162
  ].join("\n");
132
163
  }
133
164
 
134
- function formatCleanupSummary(defaultBranch, removed, failed) {
135
- const lines = [`Cleaned worktrees merged into ${defaultBranch}:`];
165
+ function formatCleanupSummary(defaultBranch, removed, failed, requestedSelectors) {
166
+ const lines = [`Cleaned worktrees relative to ${defaultBranch}:`];
136
167
 
137
168
  if (removed.length === 0) {
138
169
  lines.push("- none removed");
139
170
  } else {
140
171
  for (const item of removed) {
141
- lines.push(`- removed ${item.branch} -> ${item.path}`);
172
+ const modeLabel = item.selected ? "selected" : "auto";
173
+ lines.push(`- removed (${modeLabel}) ${item.branch} -> ${item.path}`);
174
+ }
175
+ }
176
+
177
+ if (requestedSelectors.length > 0) {
178
+ lines.push("");
179
+ lines.push("Requested selectors:");
180
+ for (const selector of requestedSelectors) {
181
+ lines.push(`- ${selector}`);
142
182
  }
143
183
  }
144
184
 
@@ -146,13 +186,81 @@ function formatCleanupSummary(defaultBranch, removed, failed) {
146
186
  lines.push("");
147
187
  lines.push("Cleanup skipped for:");
148
188
  for (const item of failed) {
149
- lines.push(`- ${item.branch} -> ${item.path}: ${item.reason}`);
189
+ lines.push(`- ${item.branch || item.selector} -> ${item.path || "(no path)"}: ${item.reason}`);
150
190
  }
151
191
  }
152
192
 
153
193
  return lines.join("\n");
154
194
  }
155
195
 
196
+ function selectorMatches(item, selector) {
197
+ const normalized = path.resolve(selector);
198
+ return item.branch === selector || item.path === normalized;
199
+ }
200
+
201
+ function classifyEntry(entry, repoRoot, activeWorktree, protectedBranches, mergedIntoBase) {
202
+ const entryPath = path.resolve(entry.path);
203
+ const branchName = entry.branch;
204
+ const item = {
205
+ branch: branchName,
206
+ path: entryPath,
207
+ head: entry.head,
208
+ detached: Boolean(entry.detached),
209
+ };
210
+
211
+ if (!branchName || entry.detached) {
212
+ return {
213
+ ...item,
214
+ status: "blocked",
215
+ reason: !branchName ? "no branch" : "detached HEAD",
216
+ selectable: false,
217
+ };
218
+ }
219
+
220
+ if (entryPath === path.resolve(repoRoot)) {
221
+ return {
222
+ ...item,
223
+ status: "blocked",
224
+ reason: entryPath === activeWorktree ? "repository root, current worktree, protected branch" : "repository root",
225
+ selectable: false,
226
+ };
227
+ }
228
+
229
+ if (entryPath === activeWorktree) {
230
+ return {
231
+ ...item,
232
+ status: "blocked",
233
+ reason: "current worktree",
234
+ selectable: false,
235
+ };
236
+ }
237
+
238
+ if (protectedBranches.has(branchName)) {
239
+ return {
240
+ ...item,
241
+ status: "blocked",
242
+ reason: "protected branch",
243
+ selectable: false,
244
+ };
245
+ }
246
+
247
+ if (mergedIntoBase) {
248
+ return {
249
+ ...item,
250
+ status: "safe",
251
+ reason: "merged into default branch by git ancestry",
252
+ selectable: true,
253
+ };
254
+ }
255
+
256
+ return {
257
+ ...item,
258
+ status: "review",
259
+ reason: "not merged into default branch by git ancestry",
260
+ selectable: true,
261
+ };
262
+ }
263
+
156
264
  export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
157
265
  async function git(args, options = {}) {
158
266
  const cwd = options.cwd ?? directory;
@@ -330,12 +438,16 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
330
438
  },
331
439
  }),
332
440
  worktree_cleanup: tool({
333
- description: "Preview or clean merged git worktrees",
441
+ description: "Preview or clean git worktrees",
334
442
  args: {
335
443
  mode: tool.schema
336
444
  .enum(["preview", "apply"])
337
445
  .default("preview")
338
446
  .describe("Preview cleanup candidates or remove them"),
447
+ selectors: tool.schema
448
+ .array(tool.schema.string())
449
+ .default([])
450
+ .describe("Optional branch names or worktree paths to remove explicitly"),
339
451
  },
340
452
  async execute(args, context) {
341
453
  context.metadata({ title: `Clean worktrees (${args.mode})` });
@@ -348,47 +460,84 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
348
460
  const worktreeList = await git(["worktree", "list", "--porcelain"], { cwd: repoRoot });
349
461
  const entries = parseWorktreeList(worktreeList.stdout);
350
462
  const protectedBranches = new Set([defaultBranch, ...config.protectedBranches]);
351
- const candidates = [];
463
+ const grouped = {
464
+ safe: [],
465
+ review: [],
466
+ blocked: [],
467
+ };
352
468
 
353
469
  for (const entry of entries) {
354
- const entryPath = path.resolve(entry.path);
355
470
  const branchName = entry.branch;
471
+ let mergedIntoBase = false;
356
472
 
357
- if (!branchName || entry.detached) {
358
- continue;
473
+ if (branchName && !entry.detached) {
474
+ const merged = await git(["merge-base", "--is-ancestor", branchName, baseRef], {
475
+ cwd: repoRoot,
476
+ allowFailure: true,
477
+ });
478
+
479
+ mergedIntoBase = merged.exitCode === 0;
359
480
  }
360
481
 
361
- if (entryPath === path.resolve(repoRoot) || entryPath === activeWorktree) {
482
+ const classified = classifyEntry(
483
+ entry,
484
+ repoRoot,
485
+ activeWorktree,
486
+ protectedBranches,
487
+ mergedIntoBase,
488
+ );
489
+
490
+ grouped[classified.status].push(classified);
491
+ }
492
+
493
+ const requestedMode = args.mode || config.cleanupMode;
494
+ if (requestedMode !== "apply") {
495
+ return formatPreview(grouped, defaultBranch);
496
+ }
497
+
498
+ const requestedSelectors = [...new Set(args.selectors || [])];
499
+ const selected = [];
500
+ const failed = [];
501
+
502
+ for (const selector of requestedSelectors) {
503
+ const match = [...grouped.safe, ...grouped.review, ...grouped.blocked].find((item) =>
504
+ selectorMatches(item, selector),
505
+ );
506
+
507
+ if (!match) {
508
+ failed.push({
509
+ selector,
510
+ reason: "selector did not match any connected worktree",
511
+ });
362
512
  continue;
363
513
  }
364
514
 
365
- if (protectedBranches.has(branchName)) {
515
+ if (!match.selectable) {
516
+ failed.push({
517
+ ...match,
518
+ selector,
519
+ reason: `cannot remove via selector: ${match.reason}`,
520
+ });
366
521
  continue;
367
522
  }
368
523
 
369
- const merged = await git(["merge-base", "--is-ancestor", branchName, baseRef], {
370
- cwd: repoRoot,
371
- allowFailure: true,
372
- });
524
+ selected.push(match);
525
+ }
373
526
 
374
- if (merged.exitCode === 0) {
375
- candidates.push({
376
- branch: branchName,
377
- path: entryPath,
378
- head: entry.head,
527
+ const targets = [...grouped.safe];
528
+
529
+ for (const item of selected) {
530
+ if (!targets.some((target) => target.path === item.path)) {
531
+ targets.push({
532
+ ...item,
533
+ selected: true,
379
534
  });
380
535
  }
381
536
  }
382
537
 
383
- const requestedMode = args.mode || config.cleanupMode;
384
- if (requestedMode !== "apply") {
385
- return formatPreview(candidates, defaultBranch);
386
- }
387
-
388
538
  const removed = [];
389
- const failed = [];
390
539
 
391
- for (const candidate of candidates) {
540
+ for (const candidate of targets) {
392
541
  const removeWorktree = await git(["worktree", "remove", candidate.path], {
393
542
  cwd: repoRoot,
394
543
  allowFailure: true,
@@ -402,10 +551,13 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
402
551
  continue;
403
552
  }
404
553
 
405
- const deleteBranch = await git(["branch", "-d", candidate.branch], {
406
- cwd: repoRoot,
407
- allowFailure: true,
408
- });
554
+ const deleteBranch = await git(
555
+ ["branch", candidate.status === "safe" ? "-d" : "-D", candidate.branch],
556
+ {
557
+ cwd: repoRoot,
558
+ allowFailure: true,
559
+ },
560
+ );
409
561
 
410
562
  if (deleteBranch.exitCode !== 0) {
411
563
  failed.push({
@@ -423,7 +575,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
423
575
  allowFailure: true,
424
576
  });
425
577
 
426
- return formatCleanupSummary(defaultBranch, removed, failed);
578
+ return formatCleanupSummary(defaultBranch, removed, failed, requestedSelectors);
427
579
  },
428
580
  }),
429
581
  },