@sven1103/opencode-worktree-workflow 0.1.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/LICENSE +21 -0
- package/README.md +69 -0
- package/package.json +39 -0
- package/src/index.js +433 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sven Fillinger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# OpenCode Worktree Workflow
|
|
2
|
+
|
|
3
|
+
`@sven1103/opencode-worktree-workflow` is an OpenCode plugin that adds git worktree helpers for creating synced feature worktrees and cleaning up merged ones.
|
|
4
|
+
|
|
5
|
+
## Install in an OpenCode project
|
|
6
|
+
|
|
7
|
+
Add the plugin package to your project config:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"$schema": "https://opencode.ai/config.json",
|
|
12
|
+
"plugin": ["@sven1103/opencode-worktree-workflow"]
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
OpenCode installs npm plugins with Bun when it starts.
|
|
17
|
+
|
|
18
|
+
## What the plugin provides
|
|
19
|
+
|
|
20
|
+
- `worktree_prepare`: create a worktree and matching branch from the latest default-branch commit
|
|
21
|
+
- `worktree_cleanup`: preview or remove merged worktrees
|
|
22
|
+
|
|
23
|
+
This package currently focuses on plugin distribution. Slash command packaging can be layered on later.
|
|
24
|
+
|
|
25
|
+
## Optional project configuration
|
|
26
|
+
|
|
27
|
+
You can override the defaults in `opencode.json`, `opencode.jsonc`, or `.opencode/worktree-workflow.json`:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"worktreeWorkflow": {
|
|
32
|
+
"branchPrefix": "wt/",
|
|
33
|
+
"remote": "origin",
|
|
34
|
+
"worktreeRoot": "../.worktrees/$REPO",
|
|
35
|
+
"cleanupMode": "preview",
|
|
36
|
+
"protectedBranches": ["release"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Supported settings:
|
|
42
|
+
|
|
43
|
+
- `branchPrefix`: prefix for generated worktree branches
|
|
44
|
+
- `remote`: remote used to detect the default branch and fetch updates
|
|
45
|
+
- `worktreeRoot`: destination root for new worktrees; supports `$REPO`, `$ROOT`, and `$ROOT_PARENT`
|
|
46
|
+
- `cleanupMode`: default cleanup behavior, either `preview` or `apply`
|
|
47
|
+
- `protectedBranches`: branches that should never be auto-cleaned
|
|
48
|
+
|
|
49
|
+
## Publish workflow
|
|
50
|
+
|
|
51
|
+
This repo is prepared for npm publishing from GitHub Actions using npm trusted publishing.
|
|
52
|
+
|
|
53
|
+
Typical release flow:
|
|
54
|
+
|
|
55
|
+
1. Publish the package once manually to create it on npm.
|
|
56
|
+
2. Configure the package's trusted publisher on npm for `.github/workflows/publish.yml`.
|
|
57
|
+
3. Tag a release like `v0.1.0` and push the tag.
|
|
58
|
+
|
|
59
|
+
The GitHub Actions workflow then runs `npm publish` using OIDC, without storing an `NPM_TOKEN` secret.
|
|
60
|
+
|
|
61
|
+
## Local development
|
|
62
|
+
|
|
63
|
+
The repo still contains a project-local `.opencode/` setup for development and testing:
|
|
64
|
+
|
|
65
|
+
- `.opencode/plugins/worktree.js` re-exports the plugin from `src/index.js`
|
|
66
|
+
- `.opencode/commands/` contains local slash command wrappers for manual testing
|
|
67
|
+
- `.opencode/worktree-workflow.md` documents the local workflow
|
|
68
|
+
|
|
69
|
+
The publishable npm artifact is limited to `src/` via the root `package.json` `files` field. Install dependencies at the repo root with `npm install` for local development.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sven1103/opencode-worktree-workflow",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin for creating and cleaning up git worktrees.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"opencode",
|
|
15
|
+
"opencode-plugin",
|
|
16
|
+
"git",
|
|
17
|
+
"worktree"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Sven Fillinger",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/sven1103-agent/opencode-worktree-plugin.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/sven1103-agent/opencode-worktree-plugin#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/sven1103-agent/opencode-worktree-plugin/issues"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@opencode-ai/plugin": "1.3.0",
|
|
37
|
+
"jsonc-parser": "^3.3.1"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parse } from "jsonc-parser";
|
|
5
|
+
import { tool } from "@opencode-ai/plugin";
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
branchPrefix: "wt/",
|
|
9
|
+
remote: "origin",
|
|
10
|
+
worktreeRoot: "../.worktrees/$REPO",
|
|
11
|
+
cleanupMode: "preview",
|
|
12
|
+
protectedBranches: [],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
async function pathExists(targetPath) {
|
|
16
|
+
try {
|
|
17
|
+
await fs.access(targetPath);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function readJsonFile(filePath) {
|
|
25
|
+
if (!(await pathExists(filePath))) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const source = await fs.readFile(filePath, "utf8");
|
|
30
|
+
const data = parse(source);
|
|
31
|
+
return data && typeof data === "object" ? data : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeBranchPrefix(prefix) {
|
|
35
|
+
if (!prefix) {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function slugifyTitle(title) {
|
|
43
|
+
return title
|
|
44
|
+
.normalize("NFKD")
|
|
45
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
48
|
+
.replace(/^-+|-+$/g, "")
|
|
49
|
+
.replace(/-{2,}/g, "-");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatRootTemplate(template, repoRoot) {
|
|
53
|
+
const repoName = path.basename(repoRoot);
|
|
54
|
+
return template
|
|
55
|
+
.replaceAll("$REPO", repoName)
|
|
56
|
+
.replaceAll("$ROOT", repoRoot)
|
|
57
|
+
.replaceAll("$ROOT_PARENT", path.dirname(repoRoot));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseShortBranch(branchRef) {
|
|
61
|
+
const prefix = "refs/heads/";
|
|
62
|
+
return branchRef.startsWith(prefix) ? branchRef.slice(prefix.length) : branchRef;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseWorktreeList(output) {
|
|
66
|
+
const entries = [];
|
|
67
|
+
let current = null;
|
|
68
|
+
|
|
69
|
+
for (const rawLine of output.split("\n")) {
|
|
70
|
+
const line = rawLine.trim();
|
|
71
|
+
if (!line) {
|
|
72
|
+
if (current) {
|
|
73
|
+
entries.push(current);
|
|
74
|
+
current = null;
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (line.startsWith("worktree ")) {
|
|
80
|
+
if (current) {
|
|
81
|
+
entries.push(current);
|
|
82
|
+
}
|
|
83
|
+
current = { path: line.slice("worktree ".length) };
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!current) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (line.startsWith("HEAD ")) {
|
|
92
|
+
current.head = line.slice("HEAD ".length);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (line.startsWith("branch ")) {
|
|
97
|
+
current.branchRef = line.slice("branch ".length);
|
|
98
|
+
current.branch = parseShortBranch(current.branchRef);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (line === "detached") {
|
|
103
|
+
current.detached = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (current) {
|
|
108
|
+
entries.push(current);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return entries;
|
|
112
|
+
}
|
|
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");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
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
|
+
),
|
|
129
|
+
"",
|
|
130
|
+
"Run `/wt-clean apply` to remove these worktrees and delete their local branches.",
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatCleanupSummary(defaultBranch, removed, failed) {
|
|
135
|
+
const lines = [`Cleaned worktrees merged into ${defaultBranch}:`];
|
|
136
|
+
|
|
137
|
+
if (removed.length === 0) {
|
|
138
|
+
lines.push("- none removed");
|
|
139
|
+
} else {
|
|
140
|
+
for (const item of removed) {
|
|
141
|
+
lines.push(`- removed ${item.branch} -> ${item.path}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (failed.length > 0) {
|
|
146
|
+
lines.push("");
|
|
147
|
+
lines.push("Cleanup skipped for:");
|
|
148
|
+
for (const item of failed) {
|
|
149
|
+
lines.push(`- ${item.branch} -> ${item.path}: ${item.reason}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const WorktreeWorkflowPlugin = async ({ $, directory }) => {
|
|
157
|
+
async function git(args, options = {}) {
|
|
158
|
+
const cwd = options.cwd ?? directory;
|
|
159
|
+
const command = `git ${args.map((arg) => $.escape(String(arg))).join(" ")}`;
|
|
160
|
+
const result = await $`${{ raw: command }}`.cwd(cwd).quiet().nothrow();
|
|
161
|
+
const stdout = result.text().trim();
|
|
162
|
+
const stderr = result.stderr.toString("utf8").trim();
|
|
163
|
+
|
|
164
|
+
if (!options.allowFailure && result.exitCode !== 0) {
|
|
165
|
+
throw new Error(stderr || stdout || `Git command failed: ${command}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
stdout,
|
|
170
|
+
stderr,
|
|
171
|
+
exitCode: result.exitCode,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function getRepoRoot() {
|
|
176
|
+
const result = await git(["rev-parse", "--show-toplevel"]);
|
|
177
|
+
return result.stdout;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function loadWorkflowConfig(repoRoot) {
|
|
181
|
+
const [projectConfig, projectConfigC, sidecarConfig] = await Promise.all([
|
|
182
|
+
readJsonFile(path.join(repoRoot, "opencode.json")),
|
|
183
|
+
readJsonFile(path.join(repoRoot, "opencode.jsonc")),
|
|
184
|
+
readJsonFile(path.join(repoRoot, ".opencode", "worktree-workflow.json")),
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
const merged = {
|
|
188
|
+
...DEFAULTS,
|
|
189
|
+
...(projectConfig?.worktreeWorkflow ?? {}),
|
|
190
|
+
...(projectConfigC?.worktreeWorkflow ?? {}),
|
|
191
|
+
...(sidecarConfig ?? {}),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
branchPrefix: normalizeBranchPrefix(merged.branchPrefix ?? DEFAULTS.branchPrefix),
|
|
196
|
+
remote: merged.remote || DEFAULTS.remote,
|
|
197
|
+
cleanupMode: merged.cleanupMode === "apply" ? "apply" : DEFAULTS.cleanupMode,
|
|
198
|
+
protectedBranches: Array.isArray(merged.protectedBranches)
|
|
199
|
+
? merged.protectedBranches.filter((value) => typeof value === "string")
|
|
200
|
+
: [],
|
|
201
|
+
worktreeRoot: path.resolve(repoRoot, formatRootTemplate(merged.worktreeRoot || DEFAULTS.worktreeRoot, repoRoot)),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function getDefaultBranch(repoRoot, remote) {
|
|
206
|
+
const remoteHead = await git(
|
|
207
|
+
["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`],
|
|
208
|
+
{ cwd: repoRoot, allowFailure: true },
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (remoteHead.exitCode === 0 && remoteHead.stdout.startsWith(`${remote}/`)) {
|
|
212
|
+
return remoteHead.stdout.slice(remote.length + 1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const remoteShow = await git(["remote", "show", remote], {
|
|
216
|
+
cwd: repoRoot,
|
|
217
|
+
allowFailure: true,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (remoteShow.exitCode === 0) {
|
|
221
|
+
const match = remoteShow.stdout.match(/HEAD branch: (.+)/);
|
|
222
|
+
if (match?.[1]) {
|
|
223
|
+
return match[1].trim();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const candidate of ["main", "master", "trunk", "develop"]) {
|
|
228
|
+
const hasLocal = await git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], {
|
|
229
|
+
cwd: repoRoot,
|
|
230
|
+
allowFailure: true,
|
|
231
|
+
});
|
|
232
|
+
if (hasLocal.exitCode === 0) {
|
|
233
|
+
return candidate;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const hasRemote = await git(["show-ref", "--verify", "--quiet", `refs/remotes/${remote}/${candidate}`], {
|
|
237
|
+
cwd: repoRoot,
|
|
238
|
+
allowFailure: true,
|
|
239
|
+
});
|
|
240
|
+
if (hasRemote.exitCode === 0) {
|
|
241
|
+
return candidate;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const currentBranch = await git(["branch", "--show-current"], {
|
|
246
|
+
cwd: repoRoot,
|
|
247
|
+
allowFailure: true,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (currentBranch.stdout) {
|
|
251
|
+
return currentBranch.stdout;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
throw new Error("Could not determine the default branch for this repository.");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function getBaseRef(repoRoot, remote, defaultBranch) {
|
|
258
|
+
await git(["fetch", "--prune", remote, defaultBranch], { cwd: repoRoot });
|
|
259
|
+
|
|
260
|
+
const remoteRef = `refs/remotes/${remote}/${defaultBranch}`;
|
|
261
|
+
const remoteExists = await git(["show-ref", "--verify", "--quiet", remoteRef], {
|
|
262
|
+
cwd: repoRoot,
|
|
263
|
+
allowFailure: true,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return remoteExists.exitCode === 0 ? `${remote}/${defaultBranch}` : defaultBranch;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function ensureBranchDoesNotExist(repoRoot, branchName) {
|
|
270
|
+
const exists = await git(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
|
271
|
+
cwd: repoRoot,
|
|
272
|
+
allowFailure: true,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (exists.exitCode === 0) {
|
|
276
|
+
throw new Error(`Local branch already exists: ${branchName}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
tool: {
|
|
282
|
+
worktree_prepare: tool({
|
|
283
|
+
description: "Create a synced git worktree from a descriptive title",
|
|
284
|
+
args: {
|
|
285
|
+
title: tool.schema.string().min(3).describe("Descriptive working title for the new worktree"),
|
|
286
|
+
},
|
|
287
|
+
async execute(args, context) {
|
|
288
|
+
context.metadata({ title: `Create worktree: ${args.title}` });
|
|
289
|
+
|
|
290
|
+
const repoRoot = await getRepoRoot();
|
|
291
|
+
const config = await loadWorkflowConfig(repoRoot);
|
|
292
|
+
const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
|
|
293
|
+
const baseRef = await getBaseRef(repoRoot, config.remote, defaultBranch);
|
|
294
|
+
const baseCommit = (await git(["rev-parse", baseRef], { cwd: repoRoot })).stdout;
|
|
295
|
+
const slug = slugifyTitle(args.title);
|
|
296
|
+
|
|
297
|
+
if (!slug) {
|
|
298
|
+
throw new Error("Could not derive a branch name from the provided title.");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const branchName = `${config.branchPrefix}${slug}`;
|
|
302
|
+
const worktreePath = path.join(config.worktreeRoot, slug);
|
|
303
|
+
|
|
304
|
+
await ensureBranchDoesNotExist(repoRoot, branchName);
|
|
305
|
+
|
|
306
|
+
if (await pathExists(worktreePath)) {
|
|
307
|
+
throw new Error(`Worktree path already exists: ${worktreePath}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
await fs.mkdir(config.worktreeRoot, { recursive: true });
|
|
311
|
+
await git(["worktree", "add", "-b", branchName, worktreePath, baseRef], {
|
|
312
|
+
cwd: repoRoot,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const branchCommit = (await git(["rev-parse", branchName], { cwd: repoRoot })).stdout;
|
|
316
|
+
if (branchCommit !== baseCommit) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`New branch ${branchName} does not match ${defaultBranch} at ${baseCommit}. Found ${branchCommit} instead.`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return [
|
|
323
|
+
`Created worktree for \"${args.title}\".`,
|
|
324
|
+
`- branch: ${branchName}`,
|
|
325
|
+
`- worktree: ${worktreePath}`,
|
|
326
|
+
`- default branch: ${defaultBranch}`,
|
|
327
|
+
`- base ref: ${baseRef}`,
|
|
328
|
+
`- base commit: ${baseCommit}`,
|
|
329
|
+
].join("\n");
|
|
330
|
+
},
|
|
331
|
+
}),
|
|
332
|
+
worktree_cleanup: tool({
|
|
333
|
+
description: "Preview or clean merged git worktrees",
|
|
334
|
+
args: {
|
|
335
|
+
mode: tool.schema
|
|
336
|
+
.enum(["preview", "apply"])
|
|
337
|
+
.default("preview")
|
|
338
|
+
.describe("Preview cleanup candidates or remove them"),
|
|
339
|
+
},
|
|
340
|
+
async execute(args, context) {
|
|
341
|
+
context.metadata({ title: `Clean worktrees (${args.mode})` });
|
|
342
|
+
|
|
343
|
+
const repoRoot = await getRepoRoot();
|
|
344
|
+
const config = await loadWorkflowConfig(repoRoot);
|
|
345
|
+
const defaultBranch = await getDefaultBranch(repoRoot, config.remote);
|
|
346
|
+
const baseRef = await getBaseRef(repoRoot, config.remote, defaultBranch);
|
|
347
|
+
const activeWorktree = path.resolve(context.worktree || repoRoot);
|
|
348
|
+
const worktreeList = await git(["worktree", "list", "--porcelain"], { cwd: repoRoot });
|
|
349
|
+
const entries = parseWorktreeList(worktreeList.stdout);
|
|
350
|
+
const protectedBranches = new Set([defaultBranch, ...config.protectedBranches]);
|
|
351
|
+
const candidates = [];
|
|
352
|
+
|
|
353
|
+
for (const entry of entries) {
|
|
354
|
+
const entryPath = path.resolve(entry.path);
|
|
355
|
+
const branchName = entry.branch;
|
|
356
|
+
|
|
357
|
+
if (!branchName || entry.detached) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (entryPath === path.resolve(repoRoot) || entryPath === activeWorktree) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (protectedBranches.has(branchName)) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const merged = await git(["merge-base", "--is-ancestor", branchName, baseRef], {
|
|
370
|
+
cwd: repoRoot,
|
|
371
|
+
allowFailure: true,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (merged.exitCode === 0) {
|
|
375
|
+
candidates.push({
|
|
376
|
+
branch: branchName,
|
|
377
|
+
path: entryPath,
|
|
378
|
+
head: entry.head,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const requestedMode = args.mode || config.cleanupMode;
|
|
384
|
+
if (requestedMode !== "apply") {
|
|
385
|
+
return formatPreview(candidates, defaultBranch);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const removed = [];
|
|
389
|
+
const failed = [];
|
|
390
|
+
|
|
391
|
+
for (const candidate of candidates) {
|
|
392
|
+
const removeWorktree = await git(["worktree", "remove", candidate.path], {
|
|
393
|
+
cwd: repoRoot,
|
|
394
|
+
allowFailure: true,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (removeWorktree.exitCode !== 0) {
|
|
398
|
+
failed.push({
|
|
399
|
+
...candidate,
|
|
400
|
+
reason: removeWorktree.stderr || removeWorktree.stdout || "worktree remove failed",
|
|
401
|
+
});
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const deleteBranch = await git(["branch", "-d", candidate.branch], {
|
|
406
|
+
cwd: repoRoot,
|
|
407
|
+
allowFailure: true,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
if (deleteBranch.exitCode !== 0) {
|
|
411
|
+
failed.push({
|
|
412
|
+
...candidate,
|
|
413
|
+
reason: deleteBranch.stderr || deleteBranch.stdout || "branch delete failed",
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
removed.push(candidate);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
await git(["worktree", "prune"], {
|
|
422
|
+
cwd: repoRoot,
|
|
423
|
+
allowFailure: true,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
return formatCleanupSummary(defaultBranch, removed, failed);
|
|
427
|
+
},
|
|
428
|
+
}),
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
export default WorktreeWorkflowPlugin;
|