@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.
- package/README.md +24 -5
- package/package.json +1 -1
- 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
|
|
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
|
-
|
|
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
|
|
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": "
|
|
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
|
|
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
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: "
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
`
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
358
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
allowFailure: true,
|
|
372
|
-
});
|
|
524
|
+
selected.push(match);
|
|
525
|
+
}
|
|
373
526
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
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(
|
|
406
|
-
|
|
407
|
-
|
|
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
|
},
|