@sven1103/opencode-worktree-workflow 0.3.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 +25 -11
- package/package.json +4 -1
- package/src/index.js +304 -59
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ npm install @sven1103/opencode-worktree-workflow
|
|
|
36
36
|
OpenCode loads custom commands from either `.opencode/commands/` (project) or `~/.config/opencode/commands/` (global).
|
|
37
37
|
|
|
38
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`.
|
|
39
40
|
|
|
40
41
|
Project install (latest release):
|
|
41
42
|
|
|
@@ -76,24 +77,34 @@ curl -fsSL "https://github.com/sven1103-agent/opencode-worktree-plugin/releases/
|
|
|
76
77
|
|
|
77
78
|
## What the plugin provides
|
|
78
79
|
|
|
79
|
-
- `worktree_prepare`: create a worktree and matching branch from the latest
|
|
80
|
-
- `worktree_cleanup`: preview
|
|
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
|
|
81
82
|
|
|
82
83
|
This package currently focuses on plugin distribution. Slash command packaging can be layered on later.
|
|
83
84
|
|
|
84
85
|
## Optional project configuration
|
|
85
86
|
|
|
86
|
-
|
|
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`:
|
|
87
90
|
|
|
88
91
|
```json
|
|
89
92
|
{
|
|
90
|
-
"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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"]
|
|
97
108
|
}
|
|
98
109
|
```
|
|
99
110
|
|
|
@@ -101,6 +112,7 @@ Supported settings:
|
|
|
101
112
|
|
|
102
113
|
- `branchPrefix`: prefix for generated worktree branches
|
|
103
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
|
|
104
116
|
- `worktreeRoot`: destination root for new worktrees; supports `$REPO`, `$ROOT`, and `$ROOT_PARENT`
|
|
105
117
|
- `cleanupMode`: default cleanup behavior, either `preview` or `apply`
|
|
106
118
|
- `protectedBranches`: branches that should never be auto-cleaned
|
|
@@ -109,6 +121,8 @@ Supported settings:
|
|
|
109
121
|
|
|
110
122
|
This repo is prepared for npm publishing from GitHub Actions using npm trusted publishing.
|
|
111
123
|
|
|
124
|
+
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.
|
|
125
|
+
|
|
112
126
|
Typical release flow:
|
|
113
127
|
|
|
114
128
|
1. Publish the package once manually to create it on npm.
|
|
@@ -117,7 +131,7 @@ Typical release flow:
|
|
|
117
131
|
|
|
118
132
|
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.
|
|
119
133
|
|
|
120
|
-
The release workflow then explicitly starts the publish workflow for that tag, which verifies the tag matches `package.json
|
|
134
|
+
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.
|
|
121
135
|
|
|
122
136
|
## Local development
|
|
123
137
|
|
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: [],
|
|
@@ -111,34 +112,74 @@ function parseWorktreeList(output) {
|
|
|
111
112
|
return entries;
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
function shellQuote(value) {
|
|
116
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function formatWorktreeSummary(item) {
|
|
120
|
+
return `${item.branch || "(detached)"} -> ${item.path}${item.head ? ` (${item.head.slice(0, 12)})` : ""}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatCopyPasteCommands(item) {
|
|
124
|
+
const selector = item.branch || item.path;
|
|
125
|
+
const branchFlag = item.status === "safe" ? "-d" : "-D";
|
|
126
|
+
|
|
127
|
+
return [
|
|
128
|
+
` copy: /wt-clean apply ${selector}`,
|
|
129
|
+
` git: git worktree remove ${shellQuote(item.path)} && git branch ${branchFlag} ${shellQuote(item.branch)}`,
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatPreviewSection(title, items, { includeCommands = false } = {}) {
|
|
134
|
+
if (items.length === 0) {
|
|
135
|
+
return [title, "- none"];
|
|
121
136
|
}
|
|
122
137
|
|
|
138
|
+
const lines = [title];
|
|
139
|
+
|
|
140
|
+
for (const item of items) {
|
|
141
|
+
lines.push(`- ${formatWorktreeSummary(item)}: ${item.reason}`);
|
|
142
|
+
|
|
143
|
+
if (includeCommands && item.branch) {
|
|
144
|
+
lines.push(...formatCopyPasteCommands(item));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatPreview(grouped, defaultBranch) {
|
|
123
152
|
return [
|
|
124
|
-
`
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`- ${candidate.branch} -> ${candidate.path}${candidate.head ? ` (${candidate.head.slice(0, 12)})` : ""}`,
|
|
128
|
-
),
|
|
153
|
+
`Worktrees connected to this repository against ${defaultBranch}:`,
|
|
154
|
+
"",
|
|
155
|
+
...formatPreviewSection("Safe to clean automatically:", grouped.safe, { includeCommands: true }),
|
|
129
156
|
"",
|
|
130
|
-
"
|
|
157
|
+
...formatPreviewSection("Needs review before cleanup:", grouped.review, { includeCommands: true }),
|
|
158
|
+
"",
|
|
159
|
+
...formatPreviewSection("Not cleanable here:", grouped.blocked),
|
|
160
|
+
"",
|
|
161
|
+
"Run `/wt-clean apply` to remove only the safe group.",
|
|
162
|
+
"Run `/wt-clean apply <branch-or-path>` to also remove selected review items.",
|
|
131
163
|
].join("\n");
|
|
132
164
|
}
|
|
133
165
|
|
|
134
|
-
function formatCleanupSummary(defaultBranch, removed, failed) {
|
|
135
|
-
const lines = [`Cleaned worktrees
|
|
166
|
+
function formatCleanupSummary(defaultBranch, removed, failed, requestedSelectors) {
|
|
167
|
+
const lines = [`Cleaned worktrees relative to ${defaultBranch}:`];
|
|
136
168
|
|
|
137
169
|
if (removed.length === 0) {
|
|
138
170
|
lines.push("- none removed");
|
|
139
171
|
} else {
|
|
140
172
|
for (const item of removed) {
|
|
141
|
-
|
|
173
|
+
const modeLabel = item.selected ? "selected" : "auto";
|
|
174
|
+
lines.push(`- removed (${modeLabel}) ${item.branch} -> ${item.path}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (requestedSelectors.length > 0) {
|
|
179
|
+
lines.push("");
|
|
180
|
+
lines.push("Requested selectors:");
|
|
181
|
+
for (const selector of requestedSelectors) {
|
|
182
|
+
lines.push(`- ${selector}`);
|
|
142
183
|
}
|
|
143
184
|
}
|
|
144
185
|
|
|
@@ -146,13 +187,166 @@ function formatCleanupSummary(defaultBranch, removed, failed) {
|
|
|
146
187
|
lines.push("");
|
|
147
188
|
lines.push("Cleanup skipped for:");
|
|
148
189
|
for (const item of failed) {
|
|
149
|
-
lines.push(`- ${item.branch} -> ${item.path}: ${item.reason}`);
|
|
190
|
+
lines.push(`- ${item.branch || item.selector} -> ${item.path || "(no path)"}: ${item.reason}`);
|
|
150
191
|
}
|
|
151
192
|
}
|
|
152
193
|
|
|
153
194
|
return lines.join("\n");
|
|
154
195
|
}
|
|
155
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
|
+
|
|
282
|
+
function selectorMatches(item, selector) {
|
|
283
|
+
const normalized = path.resolve(selector);
|
|
284
|
+
return item.branch === selector || item.path === normalized;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function classifyEntry(entry, repoRoot, activeWorktree, protectedBranches, mergedIntoBase) {
|
|
288
|
+
const entryPath = path.resolve(entry.path);
|
|
289
|
+
const branchName = entry.branch;
|
|
290
|
+
const item = {
|
|
291
|
+
branch: branchName,
|
|
292
|
+
path: entryPath,
|
|
293
|
+
head: entry.head,
|
|
294
|
+
detached: Boolean(entry.detached),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
if (!branchName || entry.detached) {
|
|
298
|
+
return {
|
|
299
|
+
...item,
|
|
300
|
+
status: "blocked",
|
|
301
|
+
reason: !branchName ? "no branch" : "detached HEAD",
|
|
302
|
+
selectable: false,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (entryPath === path.resolve(repoRoot)) {
|
|
307
|
+
return {
|
|
308
|
+
...item,
|
|
309
|
+
status: "blocked",
|
|
310
|
+
reason: entryPath === activeWorktree ? "repository root, current worktree, protected branch" : "repository root",
|
|
311
|
+
selectable: false,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (entryPath === activeWorktree) {
|
|
316
|
+
return {
|
|
317
|
+
...item,
|
|
318
|
+
status: "blocked",
|
|
319
|
+
reason: "current worktree",
|
|
320
|
+
selectable: false,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (protectedBranches.has(branchName)) {
|
|
325
|
+
return {
|
|
326
|
+
...item,
|
|
327
|
+
status: "blocked",
|
|
328
|
+
reason: "protected branch",
|
|
329
|
+
selectable: false,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (mergedIntoBase) {
|
|
334
|
+
return {
|
|
335
|
+
...item,
|
|
336
|
+
status: "safe",
|
|
337
|
+
reason: "merged into base branch by git ancestry",
|
|
338
|
+
selectable: true,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
...item,
|
|
344
|
+
status: "review",
|
|
345
|
+
reason: "not merged into base branch by git ancestry",
|
|
346
|
+
selectable: true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
156
350
|
export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
157
351
|
async function git(args, options = {}) {
|
|
158
352
|
const cwd = options.cwd ?? directory;
|
|
@@ -194,6 +388,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
194
388
|
return {
|
|
195
389
|
branchPrefix: normalizeBranchPrefix(merged.branchPrefix ?? DEFAULTS.branchPrefix),
|
|
196
390
|
remote: merged.remote || DEFAULTS.remote,
|
|
391
|
+
baseBranch: typeof merged.baseBranch === "string" && merged.baseBranch.trim() ? merged.baseBranch.trim() : null,
|
|
197
392
|
cleanupMode: merged.cleanupMode === "apply" ? "apply" : DEFAULTS.cleanupMode,
|
|
198
393
|
protectedBranches: Array.isArray(merged.protectedBranches)
|
|
199
394
|
? merged.protectedBranches.filter((value) => typeof value === "string")
|
|
@@ -254,16 +449,20 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
254
449
|
throw new Error("Could not determine the default branch for this repository.");
|
|
255
450
|
}
|
|
256
451
|
|
|
257
|
-
async function
|
|
258
|
-
|
|
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 });
|
|
259
458
|
|
|
260
|
-
const remoteRef = `refs/remotes/${remote}/${
|
|
459
|
+
const remoteRef = `refs/remotes/${remote}/${baseBranch}`;
|
|
261
460
|
const remoteExists = await git(["show-ref", "--verify", "--quiet", remoteRef], {
|
|
262
461
|
cwd: repoRoot,
|
|
263
462
|
allowFailure: true,
|
|
264
463
|
});
|
|
265
464
|
|
|
266
|
-
return remoteExists.exitCode === 0 ? `${remote}/${
|
|
465
|
+
return remoteExists.exitCode === 0 ? `${remote}/${baseBranch}` : baseBranch;
|
|
267
466
|
}
|
|
268
467
|
|
|
269
468
|
async function ensureBranchDoesNotExist(repoRoot, branchName) {
|
|
@@ -290,7 +489,8 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
290
489
|
const repoRoot = await getRepoRoot();
|
|
291
490
|
const config = await loadWorkflowConfig(repoRoot);
|
|
292
491
|
const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
|
|
293
|
-
const
|
|
492
|
+
const baseBranch = await resolveBaseBranch(repoRoot, config.remote, config.baseBranch);
|
|
493
|
+
const baseRef = await getBaseRef(repoRoot, config.remote, baseBranch);
|
|
294
494
|
const baseCommit = (await git(["rev-parse", baseRef], { cwd: repoRoot })).stdout;
|
|
295
495
|
const slug = slugifyTitle(args.title);
|
|
296
496
|
|
|
@@ -315,7 +515,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
315
515
|
const branchCommit = (await git(["rev-parse", branchName], { cwd: repoRoot })).stdout;
|
|
316
516
|
if (branchCommit !== baseCommit) {
|
|
317
517
|
throw new Error(
|
|
318
|
-
`New branch ${branchName} does not match ${
|
|
518
|
+
`New branch ${branchName} does not match ${baseBranch} at ${baseCommit}. Found ${branchCommit} instead.`,
|
|
319
519
|
);
|
|
320
520
|
}
|
|
321
521
|
|
|
@@ -324,71 +524,113 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
324
524
|
`- branch: ${branchName}`,
|
|
325
525
|
`- worktree: ${worktreePath}`,
|
|
326
526
|
`- default branch: ${defaultBranch}`,
|
|
527
|
+
`- base branch: ${baseBranch}`,
|
|
327
528
|
`- base ref: ${baseRef}`,
|
|
328
529
|
`- base commit: ${baseCommit}`,
|
|
329
530
|
].join("\n");
|
|
330
531
|
},
|
|
331
532
|
}),
|
|
332
533
|
worktree_cleanup: tool({
|
|
333
|
-
description: "Preview or clean
|
|
534
|
+
description: "Preview or clean git worktrees",
|
|
334
535
|
args: {
|
|
335
|
-
mode: tool.schema
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
.
|
|
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"),
|
|
538
|
+
selectors: tool.schema
|
|
539
|
+
.array(tool.schema.string())
|
|
540
|
+
.default([])
|
|
541
|
+
.describe("Optional branch names or worktree paths to remove explicitly"),
|
|
339
542
|
},
|
|
340
543
|
async execute(args, context) {
|
|
341
|
-
context.metadata({ title: `Clean worktrees (${args.mode})` });
|
|
342
|
-
|
|
343
544
|
const repoRoot = await getRepoRoot();
|
|
344
545
|
const config = await loadWorkflowConfig(repoRoot);
|
|
546
|
+
const normalizedArgs = normalizeCleanupArgs(args, config);
|
|
547
|
+
|
|
548
|
+
context.metadata({ title: `Clean worktrees (${normalizedArgs.mode})` });
|
|
549
|
+
|
|
345
550
|
const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
|
|
346
|
-
const
|
|
551
|
+
const baseBranch = await resolveBaseBranch(repoRoot, config.remote, config.baseBranch);
|
|
552
|
+
const baseRef = await getBaseRef(repoRoot, config.remote, baseBranch);
|
|
347
553
|
const activeWorktree = path.resolve(context.worktree || repoRoot);
|
|
348
554
|
const worktreeList = await git(["worktree", "list", "--porcelain"], { cwd: repoRoot });
|
|
349
555
|
const entries = parseWorktreeList(worktreeList.stdout);
|
|
350
|
-
const protectedBranches = new Set([defaultBranch, ...config.protectedBranches]);
|
|
351
|
-
const
|
|
556
|
+
const protectedBranches = new Set([defaultBranch, baseBranch, ...config.protectedBranches]);
|
|
557
|
+
const grouped = {
|
|
558
|
+
safe: [],
|
|
559
|
+
review: [],
|
|
560
|
+
blocked: [],
|
|
561
|
+
};
|
|
352
562
|
|
|
353
563
|
for (const entry of entries) {
|
|
354
|
-
const entryPath = path.resolve(entry.path);
|
|
355
564
|
const branchName = entry.branch;
|
|
565
|
+
let mergedIntoBase = false;
|
|
356
566
|
|
|
357
|
-
if (
|
|
358
|
-
|
|
567
|
+
if (branchName && !entry.detached) {
|
|
568
|
+
const merged = await git(["merge-base", "--is-ancestor", branchName, baseRef], {
|
|
569
|
+
cwd: repoRoot,
|
|
570
|
+
allowFailure: true,
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
mergedIntoBase = merged.exitCode === 0;
|
|
359
574
|
}
|
|
360
575
|
|
|
361
|
-
|
|
576
|
+
const classified = classifyEntry(
|
|
577
|
+
entry,
|
|
578
|
+
repoRoot,
|
|
579
|
+
activeWorktree,
|
|
580
|
+
protectedBranches,
|
|
581
|
+
mergedIntoBase,
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
grouped[classified.status].push(classified);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (normalizedArgs.mode !== "apply") {
|
|
588
|
+
return formatPreview(grouped, baseBranch);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const requestedSelectors = [...new Set(normalizedArgs.selectors || [])];
|
|
592
|
+
const selected = [];
|
|
593
|
+
const failed = [];
|
|
594
|
+
|
|
595
|
+
for (const selector of requestedSelectors) {
|
|
596
|
+
const match = [...grouped.safe, ...grouped.review, ...grouped.blocked].find((item) =>
|
|
597
|
+
selectorMatches(item, selector),
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
if (!match) {
|
|
601
|
+
failed.push({
|
|
602
|
+
selector,
|
|
603
|
+
reason: "selector did not match any connected worktree",
|
|
604
|
+
});
|
|
362
605
|
continue;
|
|
363
606
|
}
|
|
364
607
|
|
|
365
|
-
if (
|
|
608
|
+
if (!match.selectable) {
|
|
609
|
+
failed.push({
|
|
610
|
+
...match,
|
|
611
|
+
selector,
|
|
612
|
+
reason: `cannot remove via selector: ${match.reason}`,
|
|
613
|
+
});
|
|
366
614
|
continue;
|
|
367
615
|
}
|
|
368
616
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
617
|
+
selected.push(match);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const targets = [...grouped.safe];
|
|
373
621
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
622
|
+
for (const item of selected) {
|
|
623
|
+
if (!targets.some((target) => target.path === item.path)) {
|
|
624
|
+
targets.push({
|
|
625
|
+
...item,
|
|
626
|
+
selected: true,
|
|
379
627
|
});
|
|
380
628
|
}
|
|
381
629
|
}
|
|
382
630
|
|
|
383
|
-
const requestedMode = args.mode || config.cleanupMode;
|
|
384
|
-
if (requestedMode !== "apply") {
|
|
385
|
-
return formatPreview(candidates, defaultBranch);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
631
|
const removed = [];
|
|
389
|
-
const failed = [];
|
|
390
632
|
|
|
391
|
-
for (const candidate of
|
|
633
|
+
for (const candidate of targets) {
|
|
392
634
|
const removeWorktree = await git(["worktree", "remove", candidate.path], {
|
|
393
635
|
cwd: repoRoot,
|
|
394
636
|
allowFailure: true,
|
|
@@ -402,10 +644,13 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
402
644
|
continue;
|
|
403
645
|
}
|
|
404
646
|
|
|
405
|
-
const deleteBranch = await git(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
647
|
+
const deleteBranch = await git(
|
|
648
|
+
["branch", candidate.status === "safe" ? "-d" : "-D", candidate.branch],
|
|
649
|
+
{
|
|
650
|
+
cwd: repoRoot,
|
|
651
|
+
allowFailure: true,
|
|
652
|
+
},
|
|
653
|
+
);
|
|
409
654
|
|
|
410
655
|
if (deleteBranch.exitCode !== 0) {
|
|
411
656
|
failed.push({
|
|
@@ -423,7 +668,7 @@ export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
|
423
668
|
allowFailure: true,
|
|
424
669
|
});
|
|
425
670
|
|
|
426
|
-
return formatCleanupSummary(
|
|
671
|
+
return formatCleanupSummary(baseBranch, removed, failed, requestedSelectors);
|
|
427
672
|
},
|
|
428
673
|
}),
|
|
429
674
|
},
|