@pify/worktree 0.1.0 → 0.3.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 +19 -2
- package/extensions/worktree.ts +209 -56
- package/package.json +2 -2
- package/skills/worktree/SKILL.md +10 -5
- package/src/enter.ts +138 -0
- package/src/parse.ts +40 -2
package/README.md
CHANGED
|
@@ -10,11 +10,28 @@ 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`** —
|
|
13
|
+
- **`/worktree enter <target>` / `/worktree exit`** (v0.3) — take the conversation into a worktree and back out again. See below.
|
|
14
|
+
- **`/worktree`** — `list` / `create <branch> [base] [--enter]` / `enter <target>` / `exit` / `remove <target>` / `merge <branch>` / `prune` for humans. Everything after the route is taken whole, so paths with spaces work (v0.2).
|
|
15
|
+
- **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`).
|
|
16
|
+
|
|
17
|
+
## Entering a worktree (v0.3)
|
|
18
|
+
|
|
19
|
+
Creating a worktree used to be half the job. pi binds `read`, `edit`, `bash` and `@` completion to the session's working directory, and a session cannot change its own — so the worktree existed, and everything you had just discussed stayed in the terminal you were in.
|
|
20
|
+
|
|
21
|
+
`/worktree enter <branch|path>` forks the current session into the worktree and switches to it. The conversation comes along, the tools rebind, and the branch you were reading about is the branch you are now in. `/worktree exit` switches back to the session you came from; `/worktree create <branch> --enter` does both in one step.
|
|
22
|
+
|
|
23
|
+
Two things it will refuse, and why:
|
|
24
|
+
|
|
25
|
+
- **`--no-session`** — entering forks a session file, so there has to be one.
|
|
26
|
+
- **A session pi hasn't written yet** — pi keeps a session in memory until the agent has replied, so a brand-new session has nothing on disk to fork. Ask something first, or open the worktree in its own pi.
|
|
27
|
+
|
|
28
|
+
Only you can do this: session switching is a user command, so the agent cannot move itself. `worktree_create` says so in its result rather than implying the tools followed it.
|
|
29
|
+
|
|
30
|
+
The mechanism is [FradSer/pi-packages](https://github.com/FradSer/pi-packages)' — `utils` found it first.
|
|
14
31
|
|
|
15
32
|
## Safety model
|
|
16
33
|
|
|
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.
|
|
34
|
+
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
35
|
|
|
19
36
|
## Where this sits in the suite
|
|
20
37
|
|
package/extensions/worktree.ts
CHANGED
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
* (@narumitw/pi-worktree), merge-back cleanup flow (rielj/pi-git-worktrees,
|
|
17
17
|
* minus the tmux), worktree-as-concurrency-safety framing (pi-napkin).
|
|
18
18
|
*/
|
|
19
|
-
import
|
|
19
|
+
import {
|
|
20
|
+
SessionManager,
|
|
21
|
+
type ExtensionAPI,
|
|
22
|
+
type ExtensionCommandContext,
|
|
23
|
+
type ExtensionContext,
|
|
24
|
+
} from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { existsSync, statSync } from "node:fs";
|
|
26
|
+
|
|
20
27
|
import { Type } from "typebox";
|
|
21
28
|
|
|
22
29
|
import {
|
|
@@ -28,9 +35,18 @@ import {
|
|
|
28
35
|
removeWorktree,
|
|
29
36
|
repoToplevel,
|
|
30
37
|
} from "../src/git.ts";
|
|
31
|
-
import { assessRemoval, formatWorktrees, validBranchName } from "../src/parse.ts";
|
|
38
|
+
import { assessRemoval, formatWorktrees, resolveWorktree, validBranchName } from "../src/parse.ts";
|
|
39
|
+
import {
|
|
40
|
+
WORKTREE_SESSION_ENTRY,
|
|
41
|
+
enteredNote,
|
|
42
|
+
exitNote,
|
|
43
|
+
planEnter,
|
|
44
|
+
readWorktreeSession,
|
|
45
|
+
type WorktreeSession,
|
|
46
|
+
} from "../src/enter.ts";
|
|
32
47
|
|
|
33
48
|
type UiContext = ExtensionContext;
|
|
49
|
+
type CommandContext = ExtensionCommandContext;
|
|
34
50
|
|
|
35
51
|
export default function worktree(pi: ExtensionAPI) {
|
|
36
52
|
function requireRepo(ctx: UiContext): string {
|
|
@@ -46,11 +62,135 @@ export default function worktree(pi: ExtensionAPI) {
|
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
function resolveTarget(ctx: UiContext, target: string) {
|
|
65
|
+
return resolveWorktree(listWorktrees(ctx.cwd), target);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Merge a worktree branch back into the primary worktree and remove the
|
|
70
|
+
* worktree. Shared by the tool and the /worktree route so both halves of
|
|
71
|
+
* the isolation loop behave identically. Throws on anything unsafe.
|
|
72
|
+
*/
|
|
73
|
+
async function mergeWorktree(
|
|
74
|
+
ctx: UiContext,
|
|
75
|
+
rawBranch: string,
|
|
76
|
+
): Promise<{ text: string; branch: string; removed: boolean; merged: boolean }> {
|
|
77
|
+
requireRepo(ctx);
|
|
78
|
+
const branch = rawBranch.trim();
|
|
79
|
+
if (!validBranchName(branch)) throw new Error(`Invalid branch name ${JSON.stringify(rawBranch)}.`);
|
|
80
|
+
|
|
49
81
|
const worktrees = listWorktrees(ctx.cwd);
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
82
|
+
const primary = worktrees.find((w) => w.primary);
|
|
83
|
+
const source = resolveWorktree(worktrees, branch);
|
|
84
|
+
if (!primary) throw new Error("Could not locate the primary worktree.");
|
|
85
|
+
if (!source) throw new Error(`No worktree has branch "${branch}". Use worktree_list.`);
|
|
86
|
+
if (source.primary) throw new Error("That is the primary worktree's own branch.");
|
|
87
|
+
if (isDirty(source.path)) {
|
|
88
|
+
throw new Error(`Worktree ${source.path} has uncommitted changes — commit them there first.`);
|
|
89
|
+
}
|
|
90
|
+
if (isDirty(primary.path)) {
|
|
91
|
+
throw new Error(`The primary worktree has uncommitted changes — commit or stash them first.`);
|
|
92
|
+
}
|
|
93
|
+
if (!ctx.hasUI) {
|
|
94
|
+
throw new Error("Merging needs the user's confirmation and no UI is available (fail-closed).");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sourceBranch = source.branch ?? branch;
|
|
98
|
+
const approved = await ctx.ui.confirm(
|
|
99
|
+
"Merge worktree",
|
|
100
|
+
`Merge branch "${sourceBranch}" into "${primary.branch ?? "the primary branch"}" and remove ${source.path}?`,
|
|
101
|
+
);
|
|
102
|
+
if (!approved) {
|
|
103
|
+
return { text: "The user declined the merge.", branch: sourceBranch, removed: false, merged: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const merge = mergeBranch(primary.path, sourceBranch);
|
|
107
|
+
if (!merge.ok) throw new Error(merge.message);
|
|
108
|
+
|
|
109
|
+
const removal = removeWorktree(ctx.cwd, source.path, false);
|
|
110
|
+
const cleanup = removal.ok
|
|
111
|
+
? `Worktree ${source.path} removed (branch kept).`
|
|
112
|
+
: `Merge done, but removing the worktree failed: ${removal.output}`;
|
|
113
|
+
return {
|
|
114
|
+
text: `Merged "${sourceBranch}" into ${primary.branch ?? "primary"}.\n${cleanup}`,
|
|
115
|
+
branch: sourceBranch,
|
|
116
|
+
removed: removal.ok,
|
|
117
|
+
merged: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Enter a worktree by forking this session into it. pi binds its built-in
|
|
123
|
+
* tools to the session cwd and a session cannot change its own, so the only
|
|
124
|
+
* way to take the conversation along is a replacement session.
|
|
125
|
+
*/
|
|
126
|
+
async function enterWorktree(ctx: CommandContext, wanted: string, created = false): Promise<void> {
|
|
127
|
+
requireRepo(ctx);
|
|
128
|
+
if (typeof ctx.switchSession !== "function") {
|
|
129
|
+
ctx.ui.notify("This pi build cannot switch sessions, so /worktree enter is unavailable.", "error");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const match = resolveWorktree(listWorktrees(ctx.cwd), wanted);
|
|
134
|
+
const file = ctx.sessionManager.getSessionFile() ?? null;
|
|
135
|
+
const plan = planEnter(
|
|
136
|
+
ctx.cwd,
|
|
137
|
+
{ file, onDisk: Boolean(file && existsSync(file) && statSync(file).size > 0) },
|
|
138
|
+
match ? { path: match.path, branch: match.branch } : null,
|
|
53
139
|
);
|
|
140
|
+
if ("kind" in plan) {
|
|
141
|
+
ctx.ui.notify(plan.message, plan.kind === "already-here" ? "info" : "warning");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const state: WorktreeSession = {
|
|
146
|
+
path: plan.target.path,
|
|
147
|
+
branch: plan.target.branch,
|
|
148
|
+
parentSession: plan.parentSession,
|
|
149
|
+
created,
|
|
150
|
+
enteredAt: Date.now(),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
// forkFrom copies the conversation into a session file rooted at the
|
|
155
|
+
// worktree; switching to it is what rebinds read/edit/bash and @.
|
|
156
|
+
const replacement = SessionManager.forkFrom(plan.parentSession, state.path);
|
|
157
|
+
const replacementFile = replacement.getSessionFile();
|
|
158
|
+
if (!replacementFile) throw new Error("the forked session has no file");
|
|
159
|
+
replacement.appendCustomEntry(WORKTREE_SESSION_ENTRY, state);
|
|
160
|
+
const { cancelled } = await ctx.switchSession(replacementFile, {
|
|
161
|
+
withSession: async (next) => {
|
|
162
|
+
next.ui.notify(enteredNote(state), "info");
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
if (cancelled) ctx.ui.notify("Staying put — the session switch was cancelled.", "info");
|
|
166
|
+
} catch (err) {
|
|
167
|
+
ctx.ui.notify(`Could not enter: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Return to the session this one was forked from. */
|
|
172
|
+
async function exitWorktree(ctx: CommandContext): Promise<void> {
|
|
173
|
+
const state = readWorktreeSession(ctx.sessionManager.getBranch() as never);
|
|
174
|
+
if (!state) {
|
|
175
|
+
ctx.ui.notify("This session was not entered with /worktree enter.", "warning");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!state.parentSession || !existsSync(state.parentSession)) {
|
|
179
|
+
ctx.ui.notify(
|
|
180
|
+
"The session this was forked from is gone, so there is nowhere to go back to. This session stays in the worktree.",
|
|
181
|
+
"warning",
|
|
182
|
+
);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
await ctx.switchSession(state.parentSession, {
|
|
187
|
+
withSession: async (next) => {
|
|
188
|
+
next.ui.notify(exitNote(state), "info");
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
} catch (err) {
|
|
192
|
+
ctx.ui.notify(`Could not exit: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
193
|
+
}
|
|
54
194
|
}
|
|
55
195
|
|
|
56
196
|
// ── Tools ────────────────────────────────────────────────────────────
|
|
@@ -97,7 +237,10 @@ export default function worktree(pi: ExtensionAPI) {
|
|
|
97
237
|
type: "text",
|
|
98
238
|
text: [
|
|
99
239
|
`Worktree ready at ${result.path} (${result.message} base: ${result.base}).`,
|
|
100
|
-
|
|
240
|
+
// Only the user can switch sessions, so tell them how rather than
|
|
241
|
+
// implying this session moved.
|
|
242
|
+
`Your tools still point at the main checkout. The user can run /worktree enter ${branch} to ` +
|
|
243
|
+
`bring this conversation into the worktree, or open it separately with: cd "${result.path}" && pi`,
|
|
101
244
|
`When the work is done: worktree_merge branch="${branch}" merges it back and cleans up.`,
|
|
102
245
|
].join("\n"),
|
|
103
246
|
},
|
|
@@ -169,45 +312,10 @@ export default function worktree(pi: ExtensionAPI) {
|
|
|
169
312
|
branch: Type.String({ description: "Branch of the worktree to merge back" }),
|
|
170
313
|
}),
|
|
171
314
|
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}`;
|
|
315
|
+
const result = await mergeWorktree(ctx as UiContext, params.branch);
|
|
208
316
|
return {
|
|
209
|
-
content: [{ type: "text", text:
|
|
210
|
-
details: { branch, removed:
|
|
317
|
+
content: [{ type: "text", text: result.text }],
|
|
318
|
+
details: { branch: result.branch, merged: result.merged, removed: result.removed },
|
|
211
319
|
};
|
|
212
320
|
},
|
|
213
321
|
});
|
|
@@ -215,37 +323,58 @@ export default function worktree(pi: ExtensionAPI) {
|
|
|
215
323
|
// ── Command ──────────────────────────────────────────────────────────
|
|
216
324
|
|
|
217
325
|
pi.registerCommand("worktree", {
|
|
218
|
-
description:
|
|
326
|
+
description:
|
|
327
|
+
"Manage git worktrees: /worktree [create <branch> [base] [--enter] | enter <target> | exit | remove <target> | merge <branch> | prune]",
|
|
219
328
|
handler: async (args, ctx) => {
|
|
220
329
|
if (!ctx.hasUI) return;
|
|
221
|
-
const
|
|
330
|
+
const text = (args ?? "").trim();
|
|
331
|
+
const route = (text.split(/\s+/)[0] ?? "").toLowerCase();
|
|
332
|
+
// Keep the remainder whole: worktree paths contain spaces on Windows.
|
|
333
|
+
const rest = text.slice(route.length).trim();
|
|
222
334
|
try {
|
|
223
335
|
requireRepo(ctx);
|
|
224
|
-
switch (
|
|
336
|
+
switch (route || "list") {
|
|
225
337
|
case "list": {
|
|
226
338
|
ctx.ui.notify(listText(ctx), "info");
|
|
227
339
|
return;
|
|
228
340
|
}
|
|
229
341
|
case "create": {
|
|
230
|
-
|
|
231
|
-
|
|
342
|
+
const words = rest.split(/\s+/).filter(Boolean);
|
|
343
|
+
const enterAfter = words.includes("--enter");
|
|
344
|
+
const [arg, base] = words.filter((w) => w !== "--enter");
|
|
345
|
+
if (!arg) {
|
|
346
|
+
ctx.ui.notify("Usage: /worktree create <branch> [base] [--enter]", "warning");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!validBranchName(arg)) {
|
|
350
|
+
ctx.ui.notify(`Invalid branch name "${arg}".`, "warning");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const result = createWorktree(ctx.cwd, arg, base);
|
|
354
|
+
if (!result.ok) {
|
|
355
|
+
ctx.ui.notify(result.message, "error");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (enterAfter) {
|
|
359
|
+
ctx.ui.notify(`Worktree ready: ${result.path}`, "info");
|
|
360
|
+
await enterWorktree(ctx, arg, true);
|
|
232
361
|
return;
|
|
233
362
|
}
|
|
234
|
-
const result = createWorktree(ctx.cwd, arg);
|
|
235
363
|
ctx.ui.notify(
|
|
236
|
-
|
|
237
|
-
|
|
364
|
+
`Worktree ready: ${result.path}\n` +
|
|
365
|
+
`/worktree enter ${arg} takes this conversation there, or open it separately with: cd "${result.path}" && pi`,
|
|
366
|
+
"info",
|
|
238
367
|
);
|
|
239
368
|
return;
|
|
240
369
|
}
|
|
241
370
|
case "remove": {
|
|
242
|
-
if (!
|
|
371
|
+
if (!rest) {
|
|
243
372
|
ctx.ui.notify("Usage: /worktree remove <branch|path>", "warning");
|
|
244
373
|
return;
|
|
245
374
|
}
|
|
246
|
-
const target = resolveTarget(ctx,
|
|
375
|
+
const target = resolveTarget(ctx, rest);
|
|
247
376
|
if (!target) {
|
|
248
|
-
ctx.ui.notify(`No worktree matches "${
|
|
377
|
+
ctx.ui.notify(`No worktree matches "${rest}".`, "warning");
|
|
249
378
|
return;
|
|
250
379
|
}
|
|
251
380
|
const dirty = isDirty(target.path);
|
|
@@ -265,13 +394,37 @@ export default function worktree(pi: ExtensionAPI) {
|
|
|
265
394
|
ctx.ui.notify(result.ok ? `Removed ${target.path}.` : result.output, result.ok ? "info" : "error");
|
|
266
395
|
return;
|
|
267
396
|
}
|
|
397
|
+
case "enter": {
|
|
398
|
+
if (!rest) {
|
|
399
|
+
ctx.ui.notify("Usage: /worktree enter <branch|path>", "warning");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
await enterWorktree(ctx, rest);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
case "exit": {
|
|
406
|
+
await exitWorktree(ctx);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
case "merge": {
|
|
410
|
+
if (!rest) {
|
|
411
|
+
ctx.ui.notify("Usage: /worktree merge <branch>", "warning");
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const result = await mergeWorktree(ctx, rest);
|
|
415
|
+
ctx.ui.notify(result.text, "info");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
268
418
|
case "prune": {
|
|
269
419
|
const result = pruneWorktrees(ctx.cwd);
|
|
270
420
|
ctx.ui.notify(result.output || "Nothing to prune.", result.ok ? "info" : "error");
|
|
271
421
|
return;
|
|
272
422
|
}
|
|
273
423
|
default:
|
|
274
|
-
ctx.ui.notify(
|
|
424
|
+
ctx.ui.notify(
|
|
425
|
+
`Unknown route "${route}". Usage: /worktree [list | create <branch> [base] [--enter] | enter <target> | exit | remove <target> | merge <branch> | prune]`,
|
|
426
|
+
"warning",
|
|
427
|
+
);
|
|
275
428
|
}
|
|
276
429
|
} catch (err) {
|
|
277
430
|
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.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
60
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
61
61
|
"@types/node": "^22.10.2",
|
|
62
62
|
"typebox": "^1.1.38",
|
|
63
63
|
"typescript": "^5.7.2"
|
package/skills/worktree/SKILL.md
CHANGED
|
@@ -21,13 +21,16 @@ edits.
|
|
|
21
21
|
|
|
22
22
|
1. `worktree_list` — see what exists (primary, dirty, locked flags).
|
|
23
23
|
2. `worktree_create branch="feature-x"` — new branch from HEAD (or pass
|
|
24
|
-
`base`), checked out under `~/.worktrees/<repo>/`.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
`base`), checked out under `~/.worktrees/<repo>/`.
|
|
25
|
+
3. Say where the worktree is and that your tools still point at the main
|
|
26
|
+
checkout. Only the user can move this session: `/worktree enter feature-x`
|
|
27
|
+
brings the conversation into the worktree, `/worktree exit` returns.
|
|
28
|
+
Otherwise they can open it separately with `cd <path> && pi`.
|
|
29
|
+
4. Work happens in the worktree; commit there.
|
|
30
|
+
5. `worktree_merge branch="feature-x"` — asks the user, merges into the
|
|
28
31
|
primary branch, removes the worktree. Conflicts abort cleanly; nothing
|
|
29
32
|
is left half-merged.
|
|
30
|
-
|
|
33
|
+
6. `worktree_remove target="feature-x"` — abandon instead; dirty worktrees
|
|
31
34
|
need the user's confirmation, the branch is always kept.
|
|
32
35
|
|
|
33
36
|
## Rules
|
|
@@ -35,3 +38,5 @@ edits.
|
|
|
35
38
|
- Never try to bypass a refusal (primary/current/locked worktrees).
|
|
36
39
|
- Commit inside the worktree before merging — both sides must be clean.
|
|
37
40
|
- One branch per worktree; a branch already checked out elsewhere refuses.
|
|
41
|
+
- Do not claim you are "now working in" a worktree you created. Until the user
|
|
42
|
+
enters it, every path you read and edit is still the main checkout.
|
package/src/enter.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entering a worktree without losing the conversation.
|
|
3
|
+
*
|
|
4
|
+
* Creating a worktree was only ever half the job: pi binds `read`, `edit`,
|
|
5
|
+
* `bash` and `@` completion to the session's cwd, and a session cannot change
|
|
6
|
+
* its own cwd. So until now this package could hand you a path and a
|
|
7
|
+
* suggestion to open a second terminal — and everything you had discussed
|
|
8
|
+
* stayed in the terminal you left.
|
|
9
|
+
*
|
|
10
|
+
* The way through is a replacement session: fork the current session file
|
|
11
|
+
* into the worktree and switch to it. The conversation comes along, the tools
|
|
12
|
+
* rebind, and the branch you were reading about is the branch you are now in.
|
|
13
|
+
* (Mechanism from FradSer/pi-packages' utils, which found it first.)
|
|
14
|
+
*
|
|
15
|
+
* Pure helpers only — the switch itself needs the host and lives in the
|
|
16
|
+
* extension.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const WORKTREE_SESSION_ENTRY = "pify-worktree-session";
|
|
20
|
+
|
|
21
|
+
export interface WorktreeSession {
|
|
22
|
+
/** Absolute path of the worktree this session is rooted in. */
|
|
23
|
+
path: string;
|
|
24
|
+
branch: string | null;
|
|
25
|
+
/** Session file we forked from, so ExitWorktree knows where to go back. */
|
|
26
|
+
parentSession: string;
|
|
27
|
+
/** True when entering created the worktree, so leaving may offer to remove it. */
|
|
28
|
+
created: boolean;
|
|
29
|
+
enteredAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BranchEntryLike {
|
|
33
|
+
type?: string;
|
|
34
|
+
customType?: string;
|
|
35
|
+
data?: unknown;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The worktree state this session was entered with, if any. */
|
|
40
|
+
export function readWorktreeSession(entries: readonly BranchEntryLike[]): WorktreeSession | null {
|
|
41
|
+
let state: WorktreeSession | null = null;
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (entry.type !== "custom" || entry.customType !== WORKTREE_SESSION_ENTRY) continue;
|
|
44
|
+
const data = entry.data as Partial<WorktreeSession> | null;
|
|
45
|
+
if (!data || typeof data.path !== "string" || !data.path) continue;
|
|
46
|
+
state = {
|
|
47
|
+
path: data.path,
|
|
48
|
+
branch: typeof data.branch === "string" ? data.branch : null,
|
|
49
|
+
parentSession: typeof data.parentSession === "string" ? data.parentSession : "",
|
|
50
|
+
created: data.created === true,
|
|
51
|
+
enteredAt: typeof data.enteredAt === "number" ? data.enteredAt : 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type EnterProblem =
|
|
58
|
+
| { kind: "no-session"; message: string }
|
|
59
|
+
| { kind: "unwritten"; message: string }
|
|
60
|
+
| { kind: "not-found"; message: string }
|
|
61
|
+
| { kind: "already-here"; message: string };
|
|
62
|
+
|
|
63
|
+
export interface EnterPlan {
|
|
64
|
+
target: { path: string; branch: string | null };
|
|
65
|
+
parentSession: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What we know about the session we would be forking. */
|
|
69
|
+
export interface ParentSession {
|
|
70
|
+
file: string | null;
|
|
71
|
+
/**
|
|
72
|
+
* Whether that file exists with entries in it. pi keeps a session in memory
|
|
73
|
+
* until the first assistant message, so a brand-new session has a name on
|
|
74
|
+
* disk and nothing behind it — and forking from it throws.
|
|
75
|
+
*/
|
|
76
|
+
onDisk: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function samePath(a: string, b: string): boolean {
|
|
80
|
+
return a.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase() ===
|
|
81
|
+
b.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Decide whether entering is possible before anything is forked. Each refusal
|
|
86
|
+
* names which of the four things went wrong, because the fixes differ: start a
|
|
87
|
+
* persisted session, say something first, create the worktree, or do nothing
|
|
88
|
+
* at all.
|
|
89
|
+
*/
|
|
90
|
+
export function planEnter(
|
|
91
|
+
cwd: string,
|
|
92
|
+
parent: ParentSession,
|
|
93
|
+
target: { path: string; branch: string | null } | null,
|
|
94
|
+
): EnterPlan | EnterProblem {
|
|
95
|
+
if (!parent.file) {
|
|
96
|
+
return {
|
|
97
|
+
kind: "no-session",
|
|
98
|
+
message:
|
|
99
|
+
"Entering a worktree forks this session, so it needs a persisted one. Start pi without --no-session.",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (!parent.onDisk) {
|
|
103
|
+
return {
|
|
104
|
+
kind: "unwritten",
|
|
105
|
+
message:
|
|
106
|
+
"This session has not been written to disk yet — pi saves it once the agent has replied, and there is " +
|
|
107
|
+
"nothing to carry across until then. Ask something first, or open the worktree in its own pi.",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (!target) {
|
|
111
|
+
return {
|
|
112
|
+
kind: "not-found",
|
|
113
|
+
message: "No worktree matches that. /worktree list shows them; /worktree create <branch> makes one.",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (samePath(cwd, target.path)) {
|
|
117
|
+
return { kind: "already-here", message: "This session is already rooted in that worktree." };
|
|
118
|
+
}
|
|
119
|
+
return { target, parentSession: parent.file };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function enteredNote(session: WorktreeSession): string {
|
|
123
|
+
const branch = session.branch ? ` on ${session.branch}` : "";
|
|
124
|
+
return [
|
|
125
|
+
`Entered worktree ${session.path}${branch}.`,
|
|
126
|
+
"read, edit, bash and @ completion are rooted here now; the conversation came with you.",
|
|
127
|
+
"/worktree exit returns to the session you came from.",
|
|
128
|
+
].join(" ");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function exitNote(session: WorktreeSession): string {
|
|
132
|
+
return [
|
|
133
|
+
`Left the worktree at ${session.path}.`,
|
|
134
|
+
session.created
|
|
135
|
+
? "It was created by entering, and is still there — /worktree remove drops it, worktree_merge merges it back."
|
|
136
|
+
: "It is untouched.",
|
|
137
|
+
].join(" ");
|
|
138
|
+
}
|
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)");
|