@pify/worktree 0.1.0 → 0.2.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 +3 -2
- package/extensions/worktree.ts +86 -52
- package/package.json +1 -1
- package/src/parse.ts +40 -2
package/README.md
CHANGED
|
@@ -10,11 +10,12 @@ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify instal
|
|
|
10
10
|
- **`worktree_list`** — every worktree with branch, `primary`/`dirty`/`locked`/`prunable` flags.
|
|
11
11
|
- **`worktree_merge`** — with your confirmation: merges the worktree's branch into the primary branch, then removes the worktree. **Conflicting merges abort cleanly** — the primary is restored, nothing half-merged.
|
|
12
12
|
- **`worktree_remove`** — refuses the primary worktree, the one the session runs in, and locked ones outright; uncommitted changes need your explicit confirmation (fail-closed without a UI). The branch is always kept.
|
|
13
|
-
- **`/worktree`** — `list` / `create <branch
|
|
13
|
+
- **`/worktree`** — `list` / `create <branch> [base]` / `remove <target>` / `merge <branch>` / `prune` for humans. Everything after the route is taken whole, so paths with spaces work (v0.2).
|
|
14
|
+
- **Targets resolve the way you'd name them** (v0.2): a branch, a path, a directory name, or — for worktrees created by `isolation: "worktree"` in `@pify/subagent`/`swarm`/`workflow` — the agent slug alone (`worker-1` finds branch `agent/worker-1`).
|
|
14
15
|
|
|
15
16
|
## Safety model
|
|
16
17
|
|
|
17
|
-
Every git call is an `execFile` argv — no shell, no string interpolation, ever. Branch names are validated against a restricted grammar (no leading `-`, no `..`, no ref tricks) before reaching git. Removal risk is assessed (primary / current-session / locked / dirty) before anything happens, and integration tests run the whole create→merge→remove and conflict-abort flows against real repositories.
|
|
18
|
+
Every git call is an `execFile` argv — no shell, no string interpolation, ever. Branch names are validated against a restricted grammar (no leading `-`, no `..`, no ref tricks) before reaching git. Removal risk is assessed (primary / current-session / locked / dirty) before anything happens — and containment is checked on directory boundaries (v0.2), so sitting in `feature-2` no longer blocks removing `feature`, and integration tests run the whole create→merge→remove and conflict-abort flows against real repositories.
|
|
18
19
|
|
|
19
20
|
## Where this sits in the suite
|
|
20
21
|
|
package/extensions/worktree.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
removeWorktree,
|
|
29
29
|
repoToplevel,
|
|
30
30
|
} from "../src/git.ts";
|
|
31
|
-
import { assessRemoval, formatWorktrees, validBranchName } from "../src/parse.ts";
|
|
31
|
+
import { assessRemoval, formatWorktrees, resolveWorktree, validBranchName } from "../src/parse.ts";
|
|
32
32
|
|
|
33
33
|
type UiContext = ExtensionContext;
|
|
34
34
|
|
|
@@ -46,11 +46,60 @@ export default function worktree(pi: ExtensionAPI) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function resolveTarget(ctx: UiContext, target: string) {
|
|
49
|
+
return resolveWorktree(listWorktrees(ctx.cwd), target);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Merge a worktree branch back into the primary worktree and remove the
|
|
54
|
+
* worktree. Shared by the tool and the /worktree route so both halves of
|
|
55
|
+
* the isolation loop behave identically. Throws on anything unsafe.
|
|
56
|
+
*/
|
|
57
|
+
async function mergeWorktree(
|
|
58
|
+
ctx: UiContext,
|
|
59
|
+
rawBranch: string,
|
|
60
|
+
): Promise<{ text: string; branch: string; removed: boolean; merged: boolean }> {
|
|
61
|
+
requireRepo(ctx);
|
|
62
|
+
const branch = rawBranch.trim();
|
|
63
|
+
if (!validBranchName(branch)) throw new Error(`Invalid branch name ${JSON.stringify(rawBranch)}.`);
|
|
64
|
+
|
|
49
65
|
const worktrees = listWorktrees(ctx.cwd);
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
const primary = worktrees.find((w) => w.primary);
|
|
67
|
+
const source = resolveWorktree(worktrees, branch);
|
|
68
|
+
if (!primary) throw new Error("Could not locate the primary worktree.");
|
|
69
|
+
if (!source) throw new Error(`No worktree has branch "${branch}". Use worktree_list.`);
|
|
70
|
+
if (source.primary) throw new Error("That is the primary worktree's own branch.");
|
|
71
|
+
if (isDirty(source.path)) {
|
|
72
|
+
throw new Error(`Worktree ${source.path} has uncommitted changes — commit them there first.`);
|
|
73
|
+
}
|
|
74
|
+
if (isDirty(primary.path)) {
|
|
75
|
+
throw new Error(`The primary worktree has uncommitted changes — commit or stash them first.`);
|
|
76
|
+
}
|
|
77
|
+
if (!ctx.hasUI) {
|
|
78
|
+
throw new Error("Merging needs the user's confirmation and no UI is available (fail-closed).");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sourceBranch = source.branch ?? branch;
|
|
82
|
+
const approved = await ctx.ui.confirm(
|
|
83
|
+
"Merge worktree",
|
|
84
|
+
`Merge branch "${sourceBranch}" into "${primary.branch ?? "the primary branch"}" and remove ${source.path}?`,
|
|
53
85
|
);
|
|
86
|
+
if (!approved) {
|
|
87
|
+
return { text: "The user declined the merge.", branch: sourceBranch, removed: false, merged: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const merge = mergeBranch(primary.path, sourceBranch);
|
|
91
|
+
if (!merge.ok) throw new Error(merge.message);
|
|
92
|
+
|
|
93
|
+
const removal = removeWorktree(ctx.cwd, source.path, false);
|
|
94
|
+
const cleanup = removal.ok
|
|
95
|
+
? `Worktree ${source.path} removed (branch kept).`
|
|
96
|
+
: `Merge done, but removing the worktree failed: ${removal.output}`;
|
|
97
|
+
return {
|
|
98
|
+
text: `Merged "${sourceBranch}" into ${primary.branch ?? "primary"}.\n${cleanup}`,
|
|
99
|
+
branch: sourceBranch,
|
|
100
|
+
removed: removal.ok,
|
|
101
|
+
merged: true,
|
|
102
|
+
};
|
|
54
103
|
}
|
|
55
104
|
|
|
56
105
|
// ── Tools ────────────────────────────────────────────────────────────
|
|
@@ -169,45 +218,10 @@ export default function worktree(pi: ExtensionAPI) {
|
|
|
169
218
|
branch: Type.String({ description: "Branch of the worktree to merge back" }),
|
|
170
219
|
}),
|
|
171
220
|
async execute(_id, params: { branch: string }, _signal, _onUpdate, ctx) {
|
|
172
|
-
const
|
|
173
|
-
requireRepo(uiCtx);
|
|
174
|
-
const branch = params.branch.trim();
|
|
175
|
-
if (!validBranchName(branch)) throw new Error(`Invalid branch name ${JSON.stringify(params.branch)}.`);
|
|
176
|
-
|
|
177
|
-
const worktrees = listWorktrees(uiCtx.cwd);
|
|
178
|
-
const primary = worktrees.find((w) => w.primary);
|
|
179
|
-
const source = worktrees.find((w) => w.branch === branch);
|
|
180
|
-
if (!primary) throw new Error("Could not locate the primary worktree.");
|
|
181
|
-
if (!source) throw new Error(`No worktree has branch "${branch}". Use worktree_list.`);
|
|
182
|
-
if (source.primary) throw new Error("That is the primary worktree's own branch.");
|
|
183
|
-
if (isDirty(source.path)) {
|
|
184
|
-
throw new Error(`Worktree ${source.path} has uncommitted changes — commit them there first.`);
|
|
185
|
-
}
|
|
186
|
-
if (isDirty(primary.path)) {
|
|
187
|
-
throw new Error(`The primary worktree has uncommitted changes — commit or stash them first.`);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (!uiCtx.hasUI) {
|
|
191
|
-
throw new Error("Merging needs the user's confirmation and no UI is available (fail-closed).");
|
|
192
|
-
}
|
|
193
|
-
const approved = await uiCtx.ui.confirm(
|
|
194
|
-
"Merge worktree",
|
|
195
|
-
`Merge branch "${branch}" into "${primary.branch ?? "the primary branch"}" and remove ${source.path}?`,
|
|
196
|
-
);
|
|
197
|
-
if (!approved) {
|
|
198
|
-
return { content: [{ type: "text", text: "The user declined the merge." }], details: {} };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const merge = mergeBranch(primary.path, branch);
|
|
202
|
-
if (!merge.ok) throw new Error(merge.message);
|
|
203
|
-
|
|
204
|
-
const removal = removeWorktree(uiCtx.cwd, source.path, false);
|
|
205
|
-
const cleanup = removal.ok
|
|
206
|
-
? `Worktree ${source.path} removed (branch kept).`
|
|
207
|
-
: `Merge done, but removing the worktree failed: ${removal.output}`;
|
|
221
|
+
const result = await mergeWorktree(ctx as UiContext, params.branch);
|
|
208
222
|
return {
|
|
209
|
-
content: [{ type: "text", text:
|
|
210
|
-
details: { branch, removed:
|
|
223
|
+
content: [{ type: "text", text: result.text }],
|
|
224
|
+
details: { branch: result.branch, merged: result.merged, removed: result.removed },
|
|
211
225
|
};
|
|
212
226
|
},
|
|
213
227
|
});
|
|
@@ -215,23 +229,31 @@ export default function worktree(pi: ExtensionAPI) {
|
|
|
215
229
|
// ── Command ──────────────────────────────────────────────────────────
|
|
216
230
|
|
|
217
231
|
pi.registerCommand("worktree", {
|
|
218
|
-
description: "Manage git worktrees: /worktree [create <branch> | remove <target> | prune]",
|
|
232
|
+
description: "Manage git worktrees: /worktree [create <branch> [base] | remove <target> | merge <branch> | prune]",
|
|
219
233
|
handler: async (args, ctx) => {
|
|
220
234
|
if (!ctx.hasUI) return;
|
|
221
|
-
const
|
|
235
|
+
const text = (args ?? "").trim();
|
|
236
|
+
const route = (text.split(/\s+/)[0] ?? "").toLowerCase();
|
|
237
|
+
// Keep the remainder whole: worktree paths contain spaces on Windows.
|
|
238
|
+
const rest = text.slice(route.length).trim();
|
|
222
239
|
try {
|
|
223
240
|
requireRepo(ctx);
|
|
224
|
-
switch (
|
|
241
|
+
switch (route || "list") {
|
|
225
242
|
case "list": {
|
|
226
243
|
ctx.ui.notify(listText(ctx), "info");
|
|
227
244
|
return;
|
|
228
245
|
}
|
|
229
246
|
case "create": {
|
|
230
|
-
|
|
231
|
-
|
|
247
|
+
const [arg, base] = rest.split(/\s+/);
|
|
248
|
+
if (!arg) {
|
|
249
|
+
ctx.ui.notify("Usage: /worktree create <branch> [base]", "warning");
|
|
232
250
|
return;
|
|
233
251
|
}
|
|
234
|
-
|
|
252
|
+
if (!validBranchName(arg)) {
|
|
253
|
+
ctx.ui.notify(`Invalid branch name "${arg}".`, "warning");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const result = createWorktree(ctx.cwd, arg, base);
|
|
235
257
|
ctx.ui.notify(
|
|
236
258
|
result.ok ? `Worktree ready: ${result.path}\nOpen with: cd "${result.path}" && pi` : result.message,
|
|
237
259
|
result.ok ? "info" : "error",
|
|
@@ -239,13 +261,13 @@ export default function worktree(pi: ExtensionAPI) {
|
|
|
239
261
|
return;
|
|
240
262
|
}
|
|
241
263
|
case "remove": {
|
|
242
|
-
if (!
|
|
264
|
+
if (!rest) {
|
|
243
265
|
ctx.ui.notify("Usage: /worktree remove <branch|path>", "warning");
|
|
244
266
|
return;
|
|
245
267
|
}
|
|
246
|
-
const target = resolveTarget(ctx,
|
|
268
|
+
const target = resolveTarget(ctx, rest);
|
|
247
269
|
if (!target) {
|
|
248
|
-
ctx.ui.notify(`No worktree matches "${
|
|
270
|
+
ctx.ui.notify(`No worktree matches "${rest}".`, "warning");
|
|
249
271
|
return;
|
|
250
272
|
}
|
|
251
273
|
const dirty = isDirty(target.path);
|
|
@@ -265,13 +287,25 @@ export default function worktree(pi: ExtensionAPI) {
|
|
|
265
287
|
ctx.ui.notify(result.ok ? `Removed ${target.path}.` : result.output, result.ok ? "info" : "error");
|
|
266
288
|
return;
|
|
267
289
|
}
|
|
290
|
+
case "merge": {
|
|
291
|
+
if (!rest) {
|
|
292
|
+
ctx.ui.notify("Usage: /worktree merge <branch>", "warning");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const result = await mergeWorktree(ctx, rest);
|
|
296
|
+
ctx.ui.notify(result.text, "info");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
268
299
|
case "prune": {
|
|
269
300
|
const result = pruneWorktrees(ctx.cwd);
|
|
270
301
|
ctx.ui.notify(result.output || "Nothing to prune.", result.ok ? "info" : "error");
|
|
271
302
|
return;
|
|
272
303
|
}
|
|
273
304
|
default:
|
|
274
|
-
ctx.ui.notify(
|
|
305
|
+
ctx.ui.notify(
|
|
306
|
+
`Unknown route "${route}". Usage: /worktree [list | create <branch> [base] | remove <target> | merge <branch> | prune]`,
|
|
307
|
+
"warning",
|
|
308
|
+
);
|
|
275
309
|
}
|
|
276
310
|
} catch (err) {
|
|
277
311
|
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
package/package.json
CHANGED
package/src/parse.ts
CHANGED
|
@@ -87,6 +87,45 @@ export interface RemovalRisk {
|
|
|
87
87
|
confirmable: boolean;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Compare paths the way the local filesystem does (Windows-insensitive). */
|
|
91
|
+
export function normalizePath(path: string): string {
|
|
92
|
+
return path.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Is `childPath` the same as, or under, `parentPath`? Plain string prefixing
|
|
97
|
+
* would read `.../feature-2` as living inside `.../feature`, and worktrees are
|
|
98
|
+
* generated as exactly those siblings.
|
|
99
|
+
*/
|
|
100
|
+
export function isInside(childPath: string, parentPath: string): boolean {
|
|
101
|
+
const child = normalizePath(childPath);
|
|
102
|
+
const parent = normalizePath(parentPath);
|
|
103
|
+
return child === parent || child.startsWith(`${parent}/`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Find the worktree a user means: an exact branch, a branch under a known
|
|
108
|
+
* namespace ("worker-1" for "agent/worker-1"), a path, or a directory name.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveWorktree(
|
|
111
|
+
worktrees: WorktreeInfo[],
|
|
112
|
+
target: string,
|
|
113
|
+
prefixes: string[] = ["agent/"],
|
|
114
|
+
): WorktreeInfo | null {
|
|
115
|
+
const wanted = target.trim();
|
|
116
|
+
if (!wanted) return null;
|
|
117
|
+
const byBranch = worktrees.find((w) => w.branch === wanted);
|
|
118
|
+
if (byBranch) return byBranch;
|
|
119
|
+
for (const prefix of prefixes) {
|
|
120
|
+
const namespaced = worktrees.find((w) => w.branch === `${prefix}${wanted}`);
|
|
121
|
+
if (namespaced) return namespaced;
|
|
122
|
+
}
|
|
123
|
+
const normalized = normalizePath(wanted);
|
|
124
|
+
const byPath = worktrees.find((w) => normalizePath(w.path) === normalized);
|
|
125
|
+
if (byPath) return byPath;
|
|
126
|
+
return worktrees.find((w) => normalizePath(w.path).split("/").pop() === normalized) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
90
129
|
/** Assess whether a worktree can be removed safely (narumiruna's rails). */
|
|
91
130
|
export function assessRemoval(
|
|
92
131
|
target: WorktreeInfo,
|
|
@@ -94,10 +133,9 @@ export function assessRemoval(
|
|
|
94
133
|
dirty: boolean,
|
|
95
134
|
): RemovalRisk {
|
|
96
135
|
const reasons: string[] = [];
|
|
97
|
-
const norm = (p: string) => p.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
|
|
98
136
|
|
|
99
137
|
if (target.primary) reasons.push("it is the primary worktree");
|
|
100
|
-
if (
|
|
138
|
+
if (isInside(currentCwd, target.path)) {
|
|
101
139
|
reasons.push("the current session is running inside it");
|
|
102
140
|
}
|
|
103
141
|
if (target.locked) reasons.push("it is locked (git worktree lock)");
|