main
1// =============================================================================
2// Git Worktree Management
3// Uses lazyworktree when available, falls back to raw git commands.
4// =============================================================================
5
6import { execSync, spawn } from "child_process";
7import * as path from "path";
8import * as fs from "fs";
9import type { WorktreeInfo, WorktreeCreateOptions, WorktreeRemoveOptions, LazyworktreeInfo } from "./types.js";
10import {
11 findRepoRoot,
12 getCurrentBranch,
13 getCommitHash,
14 sanitizeBranchName,
15 branchExists,
16 remoteBranchExists,
17 hasUncommittedChanges,
18 getWorktreeDir,
19 ensureWorktreeDir,
20} from "./utils.js";
21
22// =============================================================================
23// Lazyworktree Detection
24// =============================================================================
25
26let _hasLazyworktree: boolean | null = null;
27
28function hasLazyworktree(): boolean {
29 if (_hasLazyworktree === null) {
30 try {
31 execSync("command -v lazyworktree", {
32 encoding: "utf-8",
33 stdio: "pipe",
34 });
35 _hasLazyworktree = true;
36 } catch {
37 _hasLazyworktree = false;
38 }
39 }
40 return _hasLazyworktree;
41}
42
43// =============================================================================
44// List Worktrees
45// =============================================================================
46
47export function listWorktrees(cwd: string = process.cwd()): WorktreeInfo[] {
48 const repoRoot = findRepoRoot(cwd);
49 if (!repoRoot) {
50 throw new Error("Not in a git repository");
51 }
52
53 // Use lazyworktree list --json for richer info
54 if (hasLazyworktree()) {
55 try {
56 return listWithLazyworktree(repoRoot);
57 } catch {
58 // Fall through to git
59 }
60 }
61
62 try {
63 const output = execSync("git worktree list --porcelain", {
64 cwd: repoRoot,
65 encoding: "utf-8",
66 });
67
68 return parseWorktreeList(output);
69 } catch (error) {
70 throw new Error(`Failed to list worktrees: ${error}`);
71 }
72}
73
74function listWithLazyworktree(cwd: string): WorktreeInfo[] {
75 const output = execSync("lazyworktree list --json", {
76 cwd,
77 encoding: "utf-8",
78 stdio: "pipe",
79 }).trim();
80
81 const items: LazyworktreeInfo[] = JSON.parse(output);
82
83 return items.map((item) => ({
84 path: item.path,
85 branch: item.branch,
86 commit: "", // lazyworktree doesn't expose commit hash
87 bare: false,
88 detached: false,
89 locked: false,
90 prunable: false,
91 // Extra fields from lazyworktree
92 dirty: item.dirty,
93 isMain: item.is_main,
94 ahead: item.ahead,
95 behind: item.behind,
96 lastActive: item.last_active,
97 }));
98}
99
100function parseWorktreeList(output: string): WorktreeInfo[] {
101 const worktrees: WorktreeInfo[] = [];
102 const lines = output.trim().split("\n");
103
104 let current: Partial<WorktreeInfo> = {};
105
106 for (const line of lines) {
107 if (line === "") {
108 if (current.path) {
109 worktrees.push(current as WorktreeInfo);
110 current = {};
111 }
112 continue;
113 }
114
115 const [key, ...valueParts] = line.split(" ");
116 const value = valueParts.join(" ");
117
118 switch (key) {
119 case "worktree":
120 current.path = value;
121 break;
122 case "HEAD":
123 current.commit = value;
124 break;
125 case "branch":
126 // Remove refs/heads/ prefix
127 current.branch = value.replace("refs/heads/", "");
128 break;
129 case "bare":
130 current.bare = true;
131 break;
132 case "detached":
133 current.detached = true;
134 break;
135 case "locked":
136 current.locked = true;
137 break;
138 case "prunable":
139 current.prunable = true;
140 break;
141 }
142 }
143
144 // Add last worktree if exists
145 if (current.path) {
146 worktrees.push(current as WorktreeInfo);
147 }
148
149 // Set defaults
150 return worktrees.map((wt) => ({
151 ...wt,
152 bare: wt.bare ?? false,
153 detached: wt.detached ?? false,
154 locked: wt.locked ?? false,
155 prunable: wt.prunable ?? false,
156 branch: wt.branch ?? "(detached)",
157 }));
158}
159
160// =============================================================================
161// Create Worktree
162// =============================================================================
163
164export function createWorktree(options: WorktreeCreateOptions, cwd: string = process.cwd()): string {
165 const repoRoot = findRepoRoot(cwd);
166 if (!repoRoot) {
167 throw new Error("Not in a git repository");
168 }
169
170 // If a custom path is provided, skip lazyworktree and use raw git
171 if (!options.path && hasLazyworktree()) {
172 return createWithLazyworktree(options, repoRoot);
173 }
174
175 return createWithGit(options, repoRoot, cwd);
176}
177
178function createWithLazyworktree(options: WorktreeCreateOptions, repoRoot: string): string {
179 try {
180 let cmd = `lazyworktree create --from-branch ${options.branch} --silent`;
181
182 // Post-create hook: run a command inside the new worktree
183 if (options.exec) {
184 cmd += ` --exec "${options.exec.replace(/"/g, '\\"')}"`;
185 }
186
187 const output = execSync(cmd, {
188 cwd: repoRoot,
189 encoding: "utf-8",
190 stdio: "pipe",
191 }).trim();
192
193 // lazyworktree prints the worktree path to stdout
194 if (!output) {
195 throw new Error("lazyworktree returned empty output");
196 }
197
198 return output;
199 } catch (error: any) {
200 // If lazyworktree fails, fall back to raw git
201 if (error.message?.includes("lazyworktree returned empty output")) {
202 throw error;
203 }
204 return createWithGit({ ...options }, repoRoot, repoRoot);
205 }
206}
207
208function createWithGit(options: WorktreeCreateOptions, repoRoot: string, cwd: string): string {
209 // Ensure worktree directory exists
210 ensureWorktreeDir(cwd);
211
212 // Determine worktree path
213 const worktreeDir = getWorktreeDir(cwd);
214 if (!worktreeDir) {
215 throw new Error("Could not determine org/repo from git remote");
216 }
217
218 const worktreePath =
219 options.path ?? path.join(worktreeDir, sanitizeBranchName(options.branch));
220
221 // Check if path already exists
222 if (fs.existsSync(worktreePath)) {
223 throw new Error(`Worktree path already exists: ${worktreePath}`);
224 }
225
226 // Build command
227 let cmd = `git worktree add`;
228
229 // Check if we should create a new branch
230 const localExists = branchExists(options.branch, repoRoot);
231 const remoteExists = remoteBranchExists("origin", options.branch, repoRoot);
232
233 if (options.createBranch || (!localExists && !remoteExists)) {
234 // Create new branch
235 cmd += ` -b ${options.branch}`;
236 }
237
238 cmd += ` "${worktreePath}"`;
239
240 if (localExists) {
241 // Use existing local branch
242 cmd += ` ${options.branch}`;
243 } else if (remoteExists && options.fromRemote) {
244 // Create from remote branch
245 cmd += ` origin/${options.branch}`;
246 }
247
248 // Execute command
249 try {
250 execSync(cmd, {
251 cwd: repoRoot,
252 encoding: "utf-8",
253 stdio: "pipe",
254 });
255
256 // Run post-create command if specified (git fallback)
257 if (options.exec) {
258 try {
259 execSync(options.exec, {
260 cwd: worktreePath,
261 encoding: "utf-8",
262 stdio: "pipe",
263 });
264 } catch {
265 // Don't fail creation if exec fails
266 }
267 }
268
269 return worktreePath;
270 } catch (error) {
271 throw new Error(`Failed to create worktree: ${error}`);
272 }
273}
274
275// =============================================================================
276// Execute in Worktree
277// =============================================================================
278
279export async function execInWorktree(nameOrPath: string, command: string, cwd: string = process.cwd()): Promise<string> {
280 const repoRoot = findRepoRoot(cwd);
281 if (!repoRoot) {
282 throw new Error("Not in a git repository");
283 }
284
285 // Resolve worktree path
286 let worktreePath: string;
287
288 if (hasLazyworktree()) {
289 try {
290 // Use lazyworktree to resolve the worktree path
291 const output = execSync(`lazyworktree list --json`, {
292 cwd: repoRoot,
293 encoding: "utf-8",
294 stdio: "pipe",
295 }).trim();
296 const items: LazyworktreeInfo[] = JSON.parse(output);
297 const match = items.find(
298 (wt) => wt.name === nameOrPath || wt.branch === nameOrPath || wt.path === nameOrPath || wt.path.endsWith(`/${nameOrPath}`)
299 );
300 if (match) {
301 worktreePath = match.path;
302 } else {
303 throw new Error(`Worktree '${nameOrPath}' not found`);
304 }
305 } catch (error: any) {
306 if (error.message?.includes("not found")) throw error;
307 // Fall through to git worktree list
308 const worktrees = listWorktrees(cwd);
309 const worktree = worktrees.find(
310 (wt) => wt.branch === nameOrPath || wt.path === nameOrPath || wt.path.endsWith(`/${nameOrPath}`)
311 );
312 if (!worktree) throw new Error(`Worktree '${nameOrPath}' not found`);
313 worktreePath = worktree.path;
314 }
315 } else {
316 const worktrees = listWorktrees(cwd);
317 const worktree = worktrees.find(
318 (wt) => wt.branch === nameOrPath || wt.path === nameOrPath || wt.path.endsWith(`/${nameOrPath}`)
319 );
320 if (!worktree) throw new Error(`Worktree '${nameOrPath}' not found`);
321 worktreePath = worktree.path;
322 }
323
324 // Execute command asynchronously to avoid blocking the event loop
325 return new Promise<string>((resolve, reject) => {
326 const child = spawn("sh", ["-c", command], {
327 cwd: worktreePath,
328 stdio: ["ignore", "pipe", "pipe"],
329 });
330
331 const stdoutChunks: Buffer[] = [];
332 const stderrChunks: Buffer[] = [];
333
334 child.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
335 child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
336
337 child.on("error", (err) => {
338 reject(new Error(`Failed to exec in worktree: ${err.message}`));
339 });
340
341 child.on("close", (code) => {
342 const stdout = Buffer.concat(stdoutChunks).toString("utf-8").trim();
343 const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
344
345 if (code !== 0) {
346 // Include both stdout and stderr in output for failed commands
347 const output = [stdout, stderr].filter(Boolean).join("\n");
348 reject(new Error(`Command failed (exit ${code}): ${output.substring(0, 2000)}`));
349 return;
350 }
351
352 // Return stdout if available, otherwise stderr (git writes to stderr)
353 resolve(stdout || stderr);
354 });
355 });
356}
357
358// =============================================================================
359// Remove Worktree
360// =============================================================================
361
362export function removeWorktree(options: WorktreeRemoveOptions, cwd: string = process.cwd()): void {
363 const repoRoot = findRepoRoot(cwd);
364 if (!repoRoot) {
365 throw new Error("Not in a git repository");
366 }
367
368 // Find worktree by branch name
369 const worktrees = listWorktrees(repoRoot);
370 const worktree = worktrees.find((wt) => wt.branch === options.branch);
371
372 if (!worktree) {
373 throw new Error(`Worktree for branch '${options.branch}' not found`);
374 }
375
376 // Check for uncommitted changes (unless force)
377 if (!options.force && hasUncommittedChanges(worktree.path)) {
378 throw new Error(
379 `Worktree has uncommitted changes. Use force option to remove anyway.`
380 );
381 }
382
383 // Try lazyworktree first for removal (handles branch cleanup too)
384 if (hasLazyworktree()) {
385 try {
386 execSync(`lazyworktree delete --silent "${worktree.path}"`, {
387 cwd: repoRoot,
388 encoding: "utf-8",
389 stdio: "pipe",
390 });
391 return;
392 } catch {
393 // Fall through to raw git
394 }
395 }
396
397 // Fall back to raw git
398 let cmd = `git worktree remove "${worktree.path}"`;
399 if (options.force) {
400 cmd += " --force";
401 }
402
403 try {
404 execSync(cmd, {
405 cwd: repoRoot,
406 encoding: "utf-8",
407 stdio: "pipe",
408 });
409 } catch (error) {
410 throw new Error(`Failed to remove worktree: ${error}`);
411 }
412}
413
414// =============================================================================
415// Prune Worktrees
416// =============================================================================
417
418export function pruneWorktrees(cwd: string = process.cwd()): void {
419 const repoRoot = findRepoRoot(cwd);
420 if (!repoRoot) {
421 throw new Error("Not in a git repository");
422 }
423
424 try {
425 execSync("git worktree prune", {
426 cwd: repoRoot,
427 encoding: "utf-8",
428 stdio: "pipe",
429 });
430 } catch (error) {
431 throw new Error(`Failed to prune worktrees: ${error}`);
432 }
433}
434
435// =============================================================================
436// Get Current Worktree Info
437// =============================================================================
438
439export function getCurrentWorktreeInfo(cwd: string = process.cwd()): WorktreeInfo | null {
440 const worktrees = listWorktrees(cwd);
441 const currentPath = path.resolve(cwd);
442
443 return worktrees.find((wt) => path.resolve(wt.path) === currentPath) ?? null;
444}
445
446// =============================================================================
447// Worktree Notes (requires lazyworktree)
448// =============================================================================
449
450/**
451 * Get the note for a worktree by name.
452 * Returns the note content or null if no note or lazyworktree unavailable.
453 */
454export function getWorktreeNote(worktreeName: string, cwd: string = process.cwd()): string | null {
455 if (!hasLazyworktree()) return null;
456
457 const repoRoot = findRepoRoot(cwd);
458 if (!repoRoot) return null;
459
460 try {
461 const output = execSync(`lazyworktree note show "${worktreeName}"`, {
462 cwd: repoRoot,
463 encoding: "utf-8",
464 stdio: "pipe",
465 }).trim();
466 return output || null;
467 } catch {
468 return null;
469 }
470}
471
472/**
473 * Set the note for a worktree by name.
474 * Writes content via stdin to lazyworktree note edit.
475 */
476export function setWorktreeNote(worktreeName: string, content: string, cwd: string = process.cwd()): boolean {
477 if (!hasLazyworktree()) return false;
478
479 const repoRoot = findRepoRoot(cwd);
480 if (!repoRoot) return false;
481
482 try {
483 execSync(`lazyworktree note edit --input - "${worktreeName}"`, {
484 cwd: repoRoot,
485 encoding: "utf-8",
486 input: content,
487 stdio: ["pipe", "pipe", "pipe"],
488 });
489 return true;
490 } catch {
491 return false;
492 }
493}
494
495/**
496 * Resolve the current directory to a worktree name and its note.
497 * Returns null if not in a lazyworktree-managed worktree or lazyworktree unavailable.
498 */
499export function getCurrentWorktreeContext(cwd: string = process.cwd()): {
500 name: string;
501 branch: string;
502 note: string | null;
503 isMain: boolean;
504} | null {
505 if (!hasLazyworktree()) return null;
506
507 const repoRoot = findRepoRoot(cwd);
508 if (!repoRoot) return null;
509
510 try {
511 const output = execSync("lazyworktree list --json", {
512 cwd: repoRoot,
513 encoding: "utf-8",
514 stdio: "pipe",
515 }).trim();
516
517 const items: LazyworktreeInfo[] = JSON.parse(output);
518 const resolvedCwd = path.resolve(cwd);
519 const match = items.find((wt) => path.resolve(wt.path) === resolvedCwd);
520
521 if (!match) return null;
522
523 const note = getWorktreeNote(match.name, cwd);
524
525 return {
526 name: match.name,
527 branch: match.branch,
528 note,
529 isMain: match.is_main,
530 };
531 } catch {
532 return null;
533 }
534}