@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 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>` / `remove <target>` / `prune` for humans.
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
 
@@ -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 norm = (p: string) => p.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
51
- return worktrees.find(
52
- (w) => w.branch === target || norm(w.path) === norm(target),
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 uiCtx = ctx as UiContext;
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: `Merged "${branch}" into ${primary.branch ?? "primary"}.\n${cleanup}` }],
210
- details: { branch, removed: removal.ok },
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 [route, arg] = (args ?? "").trim().split(/\s+/);
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 ((route || "list").toLowerCase()) {
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
- if (!arg || !validBranchName(arg)) {
231
- ctx.ui.notify("Usage: /worktree create <branch>", "warning");
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
- const result = createWorktree(ctx.cwd, arg);
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 (!arg) {
264
+ if (!rest) {
243
265
  ctx.ui.notify("Usage: /worktree remove <branch|path>", "warning");
244
266
  return;
245
267
  }
246
- const target = resolveTarget(ctx, arg);
268
+ const target = resolveTarget(ctx, rest);
247
269
  if (!target) {
248
- ctx.ui.notify(`No worktree matches "${arg}".`, "warning");
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("Usage: /worktree [create <branch> | remove <target> | prune]", "warning");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/worktree",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Safe git-worktree management for pi: create/list/merge/remove with safety rails, no shell interpolation, Windows-first, zero tmux",
5
5
  "keywords": [
6
6
  "pi-package",
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 (norm(currentCwd).startsWith(norm(target.path))) {
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)");