@pify/worktree 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pifydev
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,25 @@
1
+ # @pify/worktree
2
+
3
+ Safe git-worktree management for [pi](https://github.com/earendil-works/pi) — isolated workspaces for parallel or risky changes, with safety rails everywhere and a clean merge-back flow. Windows-first: no tmux, no daemons, no shell interpolation.
4
+
5
+ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install worktree`](https://github.com/pifydev/cli) or `pi install npm:@pify/worktree`.
6
+
7
+ ## What it does
8
+
9
+ - **`worktree_create`** — new branch (or existing unoccupied one) checked out under `~/.worktrees/<repo>/<branch>`; reports the base commit and how to open pi there. The main checkout stays untouched.
10
+ - **`worktree_list`** — every worktree with branch, `primary`/`dirty`/`locked`/`prunable` flags.
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
+ - **`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.
14
+
15
+ ## Safety model
16
+
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
+
19
+ ## Where this sits in the suite
20
+
21
+ `@pify/subagent` and `@pify/swarm` run agents in-place (shared files — fine for read-mostly work). Worktrees are the isolation layer for parallel **mutating** work; spawning agents directly into worktrees is planned v0.2 integration across the three packages.
22
+
23
+ ## License
24
+
25
+ MIT © [Pify maintainers](https://github.com/pifydev)
@@ -0,0 +1,281 @@
1
+ /**
2
+ * @pify/worktree — safe git-worktree management for pi.
3
+ *
4
+ * The foundation for parallel MUTATING work: create isolated worktrees the
5
+ * agent (or you) can build in without touching the main checkout. Windows-
6
+ * first and dependency-free — no tmux, no daemons; every git call is an
7
+ * execFile argv (no shell, no interpolation — narumiruna's rule), and
8
+ * removal runs behind safety rails: primary/current/locked refuse outright,
9
+ * dirty needs explicit confirmation (fail-closed when headless).
10
+ *
11
+ * Spawning agents INTO worktrees is deliberately v0.2 integration work with
12
+ * @pify/subagent and @pify/swarm — v0.1 hands off cleanly: every created
13
+ * worktree's result says how to open pi there.
14
+ *
15
+ * Design synthesis: guarded manager + safety checks + path suggestion
16
+ * (@narumitw/pi-worktree), merge-back cleanup flow (rielj/pi-git-worktrees,
17
+ * minus the tmux), worktree-as-concurrency-safety framing (pi-napkin).
18
+ */
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { Type } from "typebox";
21
+
22
+ import {
23
+ createWorktree,
24
+ isDirty,
25
+ listWorktrees,
26
+ mergeBranch,
27
+ pruneWorktrees,
28
+ removeWorktree,
29
+ repoToplevel,
30
+ } from "../src/git.ts";
31
+ import { assessRemoval, formatWorktrees, validBranchName } from "../src/parse.ts";
32
+
33
+ type UiContext = ExtensionContext;
34
+
35
+ export default function worktree(pi: ExtensionAPI) {
36
+ function requireRepo(ctx: UiContext): string {
37
+ const top = repoToplevel(ctx.cwd);
38
+ if (!top) throw new Error("Not inside a git repository.");
39
+ return top;
40
+ }
41
+
42
+ function listText(ctx: UiContext): string {
43
+ const worktrees = listWorktrees(ctx.cwd);
44
+ const dirty = new Set(worktrees.filter((w) => isDirty(w.path)).map((w) => w.path));
45
+ return formatWorktrees(worktrees, dirty);
46
+ }
47
+
48
+ function resolveTarget(ctx: UiContext, target: string) {
49
+ 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),
53
+ );
54
+ }
55
+
56
+ // ── Tools ────────────────────────────────────────────────────────────
57
+
58
+ pi.registerTool({
59
+ name: "worktree_list",
60
+ label: "List worktrees",
61
+ description:
62
+ "List the repository's git worktrees with branch, dirty/locked/prunable state, and which one " +
63
+ "is primary. Use before creating, removing, or merging.",
64
+ parameters: Type.Object({}),
65
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
66
+ requireRepo(ctx as UiContext);
67
+ return { content: [{ type: "text", text: listText(ctx as UiContext) }], details: {} };
68
+ },
69
+ });
70
+
71
+ pi.registerTool({
72
+ name: "worktree_create",
73
+ label: "Create worktree",
74
+ description:
75
+ "Create an isolated git worktree under ~/.worktrees/<repo>/ for parallel work that modifies " +
76
+ "files. branch: a new branch (created from base, default HEAD) or an existing unoccupied local " +
77
+ "branch. The main checkout stays untouched; report the returned path to the user so they can " +
78
+ "open pi there.",
79
+ parameters: Type.Object({
80
+ branch: Type.String({ description: "Branch name (new or existing-unoccupied)" }),
81
+ base: Type.Optional(Type.String({ description: "Base ref for a new branch (default HEAD)" })),
82
+ }),
83
+ async execute(_id, params: { branch: string; base?: string }, _signal, _onUpdate, ctx) {
84
+ requireRepo(ctx as UiContext);
85
+ const branch = params.branch.trim();
86
+ if (!validBranchName(branch)) {
87
+ throw new Error(`Invalid branch name ${JSON.stringify(params.branch)}.`);
88
+ }
89
+ if (params.base !== undefined && !validBranchName(params.base.trim()) && !/^[0-9a-f]{4,40}$/i.test(params.base.trim())) {
90
+ throw new Error(`Invalid base ref ${JSON.stringify(params.base)}.`);
91
+ }
92
+ const result = createWorktree((ctx as UiContext).cwd, branch, params.base?.trim());
93
+ if (!result.ok) throw new Error(result.message);
94
+ return {
95
+ content: [
96
+ {
97
+ type: "text",
98
+ text: [
99
+ `Worktree ready at ${result.path} (${result.message} base: ${result.base}).`,
100
+ `Open a pi session there with: cd "${result.path}" && pi`,
101
+ `When the work is done: worktree_merge branch="${branch}" merges it back and cleans up.`,
102
+ ].join("\n"),
103
+ },
104
+ ],
105
+ details: { path: result.path, branch, createdBranch: result.createdBranch },
106
+ };
107
+ },
108
+ });
109
+
110
+ pi.registerTool({
111
+ name: "worktree_remove",
112
+ label: "Remove worktree",
113
+ description:
114
+ "Remove a worktree by branch name or path. Refuses the primary worktree, the one this session " +
115
+ "runs in, and locked ones; uncommitted changes need the user's confirmation (denied when no UI). " +
116
+ "The branch itself is kept.",
117
+ parameters: Type.Object({
118
+ target: Type.String({ description: "Branch name or worktree path" }),
119
+ }),
120
+ async execute(_id, params: { target: string }, _signal, _onUpdate, ctx) {
121
+ const uiCtx = ctx as UiContext;
122
+ requireRepo(uiCtx);
123
+ const target = resolveTarget(uiCtx, params.target.trim());
124
+ if (!target) throw new Error(`No worktree matches ${JSON.stringify(params.target)}. Use worktree_list.`);
125
+
126
+ const dirty = isDirty(target.path);
127
+ const risk = assessRemoval(target, uiCtx.cwd, dirty);
128
+
129
+ if (!risk.ok && !risk.confirmable) {
130
+ throw new Error(`Refusing to remove ${target.path}: ${risk.reasons.join("; ")}.`);
131
+ }
132
+ if (!risk.ok && risk.confirmable) {
133
+ if (!uiCtx.hasUI) {
134
+ throw new Error(
135
+ `Refusing to remove ${target.path}: ${risk.reasons.join("; ")} (no UI to confirm — fail-closed).`,
136
+ );
137
+ }
138
+ const approved = await uiCtx.ui.confirm(
139
+ "Remove dirty worktree",
140
+ `${target.path} has uncommitted changes that will be LOST. Remove anyway?`,
141
+ );
142
+ if (!approved) {
143
+ return {
144
+ content: [{ type: "text", text: "The user declined. Worktree kept." }],
145
+ details: {},
146
+ };
147
+ }
148
+ }
149
+
150
+ const result = removeWorktree(uiCtx.cwd, target.path, dirty);
151
+ if (!result.ok) throw new Error(result.output);
152
+ return {
153
+ content: [
154
+ { type: "text", text: `Removed worktree ${target.path}. Branch "${target.branch ?? "(detached)"}" was kept.` },
155
+ ],
156
+ details: { path: target.path },
157
+ };
158
+ },
159
+ });
160
+
161
+ pi.registerTool({
162
+ name: "worktree_merge",
163
+ label: "Merge worktree",
164
+ description:
165
+ "Merge a worktree's branch into the primary worktree's current branch (with the user's " +
166
+ "confirmation), then remove the worktree on success. Conflicting merges abort cleanly and " +
167
+ "report — nothing is left half-merged.",
168
+ parameters: Type.Object({
169
+ branch: Type.String({ description: "Branch of the worktree to merge back" }),
170
+ }),
171
+ 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}`;
208
+ return {
209
+ content: [{ type: "text", text: `Merged "${branch}" into ${primary.branch ?? "primary"}.\n${cleanup}` }],
210
+ details: { branch, removed: removal.ok },
211
+ };
212
+ },
213
+ });
214
+
215
+ // ── Command ──────────────────────────────────────────────────────────
216
+
217
+ pi.registerCommand("worktree", {
218
+ description: "Manage git worktrees: /worktree [create <branch> | remove <target> | prune]",
219
+ handler: async (args, ctx) => {
220
+ if (!ctx.hasUI) return;
221
+ const [route, arg] = (args ?? "").trim().split(/\s+/);
222
+ try {
223
+ requireRepo(ctx);
224
+ switch ((route || "list").toLowerCase()) {
225
+ case "list": {
226
+ ctx.ui.notify(listText(ctx), "info");
227
+ return;
228
+ }
229
+ case "create": {
230
+ if (!arg || !validBranchName(arg)) {
231
+ ctx.ui.notify("Usage: /worktree create <branch>", "warning");
232
+ return;
233
+ }
234
+ const result = createWorktree(ctx.cwd, arg);
235
+ ctx.ui.notify(
236
+ result.ok ? `Worktree ready: ${result.path}\nOpen with: cd "${result.path}" && pi` : result.message,
237
+ result.ok ? "info" : "error",
238
+ );
239
+ return;
240
+ }
241
+ case "remove": {
242
+ if (!arg) {
243
+ ctx.ui.notify("Usage: /worktree remove <branch|path>", "warning");
244
+ return;
245
+ }
246
+ const target = resolveTarget(ctx, arg);
247
+ if (!target) {
248
+ ctx.ui.notify(`No worktree matches "${arg}".`, "warning");
249
+ return;
250
+ }
251
+ const dirty = isDirty(target.path);
252
+ const risk = assessRemoval(target, ctx.cwd, dirty);
253
+ if (!risk.ok && !risk.confirmable) {
254
+ ctx.ui.notify(`Cannot remove: ${risk.reasons.join("; ")}.`, "error");
255
+ return;
256
+ }
257
+ if (dirty) {
258
+ const approved = await ctx.ui.confirm(
259
+ "Remove dirty worktree",
260
+ `${target.path} has uncommitted changes that will be LOST. Remove anyway?`,
261
+ );
262
+ if (!approved) return;
263
+ }
264
+ const result = removeWorktree(ctx.cwd, target.path, dirty);
265
+ ctx.ui.notify(result.ok ? `Removed ${target.path}.` : result.output, result.ok ? "info" : "error");
266
+ return;
267
+ }
268
+ case "prune": {
269
+ const result = pruneWorktrees(ctx.cwd);
270
+ ctx.ui.notify(result.output || "Nothing to prune.", result.ok ? "info" : "error");
271
+ return;
272
+ }
273
+ default:
274
+ ctx.ui.notify("Usage: /worktree [create <branch> | remove <target> | prune]", "warning");
275
+ }
276
+ } catch (err) {
277
+ ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
278
+ }
279
+ },
280
+ });
281
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@pify/worktree",
3
+ "version": "0.1.0",
4
+ "description": "Safe git-worktree management for pi: create/list/merge/remove with safety rails, no shell interpolation, Windows-first, zero tmux",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "pify",
10
+ "worktree",
11
+ "git"
12
+ ],
13
+ "homepage": "https://github.com/pifydev/worktree#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/pifydev/worktree/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/pifydev/worktree.git"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Pify maintainers",
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=22.19.0"
26
+ },
27
+ "files": [
28
+ "extensions",
29
+ "src",
30
+ "skills",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "pi": {
35
+ "extensions": [
36
+ "./extensions/worktree.ts"
37
+ ],
38
+ "skills": [
39
+ "./skills"
40
+ ]
41
+ },
42
+ "scripts": {
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "bun test",
45
+ "prepublishOnly": "npm run typecheck && npm test"
46
+ },
47
+ "peerDependencies": {
48
+ "@earendil-works/pi-coding-agent": "*",
49
+ "typebox": "*"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@earendil-works/pi-coding-agent": {
53
+ "optional": true
54
+ },
55
+ "typebox": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@earendil-works/pi-coding-agent": "^0.84.4",
61
+ "@types/node": "^22.10.2",
62
+ "typebox": "^1.1.38",
63
+ "typescript": "^5.7.2"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ }
68
+ }
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: worktree
3
+ description: Use when work should happen in isolation from the main checkout - risky refactors, parallel efforts, or long-running changes - explains the worktree tools, safety rails, and the merge-back flow
4
+ ---
5
+
6
+ # Git worktrees
7
+
8
+ This project has the `@pify/worktree` extension installed: create isolated
9
+ worktrees for work that modifies files without touching the main checkout.
10
+
11
+ ## When to use
12
+
13
+ - A risky or large change the user may want to abandon cleanly.
14
+ - Parallel efforts on the same repo (each gets its own worktree + branch).
15
+ - Keeping the main checkout buildable while something long-running proceeds.
16
+
17
+ Not for read-only exploration (subagents already read in place) or trivial
18
+ edits.
19
+
20
+ ## The flow
21
+
22
+ 1. `worktree_list` — see what exists (primary, dirty, locked flags).
23
+ 2. `worktree_create branch="feature-x"` — new branch from HEAD (or pass
24
+ `base`), checked out under `~/.worktrees/<repo>/`. Tell the user the
25
+ path — they can run pi there (`cd <path> && pi`).
26
+ 3. Work happens in the worktree; commit there.
27
+ 4. `worktree_merge branch="feature-x"` — asks the user, merges into the
28
+ primary branch, removes the worktree. Conflicts abort cleanly; nothing
29
+ is left half-merged.
30
+ 5. `worktree_remove target="feature-x"` — abandon instead; dirty worktrees
31
+ need the user's confirmation, the branch is always kept.
32
+
33
+ ## Rules
34
+
35
+ - Never try to bypass a refusal (primary/current/locked worktrees).
36
+ - Commit inside the worktree before merging — both sides must be clean.
37
+ - One branch per worktree; a branch already checked out elsewhere refuses.
package/src/git.ts ADDED
@@ -0,0 +1,145 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import { branchToDirName, parseWorktreeList, type WorktreeInfo } from "./parse.ts";
6
+
7
+ /**
8
+ * Git operations for @pify/worktree. Every call is execFile with an argv
9
+ * array — no shell, no interpolation, ever (narumiruna's rule). Errors
10
+ * surface git's own stderr so the agent/user sees the real reason.
11
+ */
12
+
13
+ export interface GitResult {
14
+ ok: boolean;
15
+ output: string;
16
+ }
17
+
18
+ export function git(cwd: string, args: string[]): GitResult {
19
+ try {
20
+ const output = execFileSync("git", args, {
21
+ cwd,
22
+ encoding: "utf8",
23
+ timeout: 30_000,
24
+ windowsHide: true,
25
+ stdio: ["ignore", "pipe", "pipe"],
26
+ });
27
+ return { ok: true, output: output.trim() };
28
+ } catch (err) {
29
+ const e = err as { stderr?: string; stdout?: string; message?: string };
30
+ return { ok: false, output: (e.stderr || e.stdout || e.message || "git failed").toString().trim() };
31
+ }
32
+ }
33
+
34
+ export function repoToplevel(cwd: string): string | null {
35
+ const result = git(cwd, ["rev-parse", "--show-toplevel"]);
36
+ return result.ok ? result.output : null;
37
+ }
38
+
39
+ export function listWorktrees(cwd: string): WorktreeInfo[] {
40
+ const result = git(cwd, ["worktree", "list", "--porcelain"]);
41
+ return result.ok ? parseWorktreeList(result.output) : [];
42
+ }
43
+
44
+ export function isDirty(worktreePath: string): boolean {
45
+ const result = git(worktreePath, ["status", "--porcelain"]);
46
+ return result.ok && result.output !== "";
47
+ }
48
+
49
+ export function branchExists(cwd: string, branch: string): boolean {
50
+ return git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]).ok;
51
+ }
52
+
53
+ export function currentShortHead(cwd: string): string {
54
+ const result = git(cwd, ["rev-parse", "--short", "HEAD"]);
55
+ return result.ok ? result.output : "unknown";
56
+ }
57
+
58
+ /** Suggested location: ~/.worktrees/<repo-name>/<branch-dir> (narumiruna). */
59
+ export function suggestPath(cwd: string, branch: string): string {
60
+ const top = repoToplevel(cwd);
61
+ const repo = top ? basename(top) : "repo";
62
+ let candidate = join(homedir(), ".worktrees", repo, branchToDirName(branch));
63
+ let counter = 2;
64
+ while (existsSync(candidate)) {
65
+ candidate = join(homedir(), ".worktrees", repo, `${branchToDirName(branch)}-${counter}`);
66
+ counter++;
67
+ }
68
+ return candidate;
69
+ }
70
+
71
+ export interface CreateResult {
72
+ ok: boolean;
73
+ path: string;
74
+ base: string;
75
+ createdBranch: boolean;
76
+ message: string;
77
+ }
78
+
79
+ /**
80
+ * Create a worktree: a NEW branch from HEAD (default) or an existing,
81
+ * unoccupied local branch checked out into the new worktree.
82
+ */
83
+ export function createWorktree(cwd: string, branch: string, base?: string): CreateResult {
84
+ const path = suggestPath(cwd, branch);
85
+ const exists = branchExists(cwd, branch);
86
+
87
+ if (exists) {
88
+ const occupied = listWorktrees(cwd).find((w) => w.branch === branch);
89
+ if (occupied) {
90
+ return {
91
+ ok: false,
92
+ path: occupied.path,
93
+ base: "",
94
+ createdBranch: false,
95
+ message: `Branch "${branch}" is already checked out at ${occupied.path}.`,
96
+ };
97
+ }
98
+ const result = git(cwd, ["worktree", "add", path, branch]);
99
+ return {
100
+ ok: result.ok,
101
+ path,
102
+ base: branch,
103
+ createdBranch: false,
104
+ message: result.ok ? `Checked out existing branch "${branch}".` : result.output,
105
+ };
106
+ }
107
+
108
+ const baseRef = base ?? "HEAD";
109
+ const result = git(cwd, ["worktree", "add", "-b", branch, path, baseRef]);
110
+ return {
111
+ ok: result.ok,
112
+ path,
113
+ base: baseRef === "HEAD" ? currentShortHead(cwd) : baseRef,
114
+ createdBranch: true,
115
+ message: result.ok ? `Created branch "${branch}".` : result.output,
116
+ };
117
+ }
118
+
119
+ export function removeWorktree(cwd: string, path: string, force: boolean): GitResult {
120
+ const args = ["worktree", "remove", ...(force ? ["--force"] : []), path];
121
+ return git(cwd, args);
122
+ }
123
+
124
+ export function pruneWorktrees(cwd: string): GitResult {
125
+ return git(cwd, ["worktree", "prune", "-v"]);
126
+ }
127
+
128
+ export interface MergeResult {
129
+ ok: boolean;
130
+ message: string;
131
+ }
132
+
133
+ /** Merge a worktree's branch into the primary worktree's current branch. */
134
+ export function mergeBranch(primaryPath: string, branch: string): MergeResult {
135
+ const result = git(primaryPath, ["merge", "--no-edit", branch]);
136
+ if (!result.ok && isDirty(primaryPath)) {
137
+ // A failed merge may leave conflicts — abort to restore a clean state.
138
+ git(primaryPath, ["merge", "--abort"]);
139
+ return {
140
+ ok: false,
141
+ message: `Merge of "${branch}" conflicts — aborted, primary worktree restored. Resolve manually:\n${result.output}`,
142
+ };
143
+ }
144
+ return { ok: result.ok, message: result.output };
145
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Pure parsing/validation for @pify/worktree.
3
+ * No imports from pi packages; no fs/process — fully unit-testable.
4
+ */
5
+
6
+ export interface WorktreeInfo {
7
+ path: string;
8
+ head: string;
9
+ /** Branch name without refs/heads/, or null when detached. */
10
+ branch: string | null;
11
+ detached: boolean;
12
+ locked: boolean;
13
+ prunable: boolean;
14
+ /** The repository's primary worktree (first entry in porcelain output). */
15
+ primary: boolean;
16
+ }
17
+
18
+ /** Parse `git worktree list --porcelain` output. */
19
+ export function parseWorktreeList(porcelain: string): WorktreeInfo[] {
20
+ const worktrees: WorktreeInfo[] = [];
21
+ let current: Partial<WorktreeInfo> | null = null;
22
+
23
+ const flush = () => {
24
+ if (current?.path) {
25
+ worktrees.push({
26
+ path: current.path,
27
+ head: current.head ?? "",
28
+ branch: current.branch ?? null,
29
+ detached: current.detached ?? false,
30
+ locked: current.locked ?? false,
31
+ prunable: current.prunable ?? false,
32
+ primary: worktrees.length === 0,
33
+ });
34
+ }
35
+ current = null;
36
+ };
37
+
38
+ for (const line of porcelain.split("\n")) {
39
+ const trimmed = line.trimEnd();
40
+ if (trimmed === "") {
41
+ flush();
42
+ continue;
43
+ }
44
+ if (trimmed.startsWith("worktree ")) {
45
+ flush();
46
+ current = { path: trimmed.slice("worktree ".length) };
47
+ } else if (!current) {
48
+ continue;
49
+ } else if (trimmed.startsWith("HEAD ")) {
50
+ current.head = trimmed.slice(5);
51
+ } else if (trimmed.startsWith("branch ")) {
52
+ current.branch = trimmed.slice(7).replace(/^refs\/heads\//, "");
53
+ } else if (trimmed === "detached") {
54
+ current.detached = true;
55
+ } else if (trimmed === "locked" || trimmed.startsWith("locked ")) {
56
+ current.locked = true;
57
+ } else if (trimmed === "prunable" || trimmed.startsWith("prunable ")) {
58
+ current.prunable = true;
59
+ }
60
+ }
61
+ flush();
62
+ return worktrees;
63
+ }
64
+
65
+ /**
66
+ * Branch-name safety: git's own rules, restricted further so a name can never
67
+ * smuggle flags or path tricks into an argv (no leading '-', no '..', no
68
+ * control chars, printable subset only).
69
+ */
70
+ export function validBranchName(name: string): boolean {
71
+ if (!name || name.length > 200) return false;
72
+ if (name.startsWith("-") || name.startsWith("/") || name.endsWith("/")) return false;
73
+ if (name.endsWith(".lock") || name.includes("..") || name.includes("//")) return false;
74
+ if (name.includes("@{") || name === "@") return false;
75
+ return /^[A-Za-z0-9._\-/]+$/.test(name);
76
+ }
77
+
78
+ /** Filesystem-safe directory name for a branch (feature/x → feature-x). */
79
+ export function branchToDirName(branch: string): string {
80
+ return branch.replace(/\//g, "-").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 100);
81
+ }
82
+
83
+ export interface RemovalRisk {
84
+ ok: boolean;
85
+ reasons: string[];
86
+ /** Risks a user may explicitly confirm through (dirty). */
87
+ confirmable: boolean;
88
+ }
89
+
90
+ /** Assess whether a worktree can be removed safely (narumiruna's rails). */
91
+ export function assessRemoval(
92
+ target: WorktreeInfo,
93
+ currentCwd: string,
94
+ dirty: boolean,
95
+ ): RemovalRisk {
96
+ const reasons: string[] = [];
97
+ const norm = (p: string) => p.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
98
+
99
+ if (target.primary) reasons.push("it is the primary worktree");
100
+ if (norm(currentCwd).startsWith(norm(target.path))) {
101
+ reasons.push("the current session is running inside it");
102
+ }
103
+ if (target.locked) reasons.push("it is locked (git worktree lock)");
104
+
105
+ const hard = reasons.length > 0;
106
+ if (dirty) reasons.push("it has uncommitted changes");
107
+
108
+ return { ok: reasons.length === 0, reasons, confirmable: !hard && dirty };
109
+ }
110
+
111
+ export function formatWorktrees(worktrees: WorktreeInfo[], dirtyPaths: Set<string>): string {
112
+ if (worktrees.length === 0) return "No worktrees.";
113
+ return worktrees
114
+ .map((w) => {
115
+ const flags = [
116
+ w.primary ? "primary" : "",
117
+ w.detached ? "detached" : "",
118
+ w.locked ? "locked" : "",
119
+ w.prunable ? "prunable" : "",
120
+ dirtyPaths.has(w.path) ? "dirty" : "",
121
+ ]
122
+ .filter(Boolean)
123
+ .join(", ");
124
+ const label = w.branch ?? w.head.slice(0, 8);
125
+ return `${w.path}\n ${label}${flags ? ` (${flags})` : ""}`;
126
+ })
127
+ .join("\n");
128
+ }