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}