flake-update-20260505
  1// =============================================================================
  2// Git Worktree Management
  3// Uses lazyworktree when available, falls back to raw git commands.
  4// =============================================================================
  5
  6import { execSync } 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 function execInWorktree(nameOrPath: string, command: string, cwd: string = process.cwd()): string {
280	const repoRoot = findRepoRoot(cwd);
281	if (!repoRoot) {
282		throw new Error("Not in a git repository");
283	}
284
285	if (hasLazyworktree()) {
286		try {
287			return execSync(
288				`lazyworktree exec -w "${nameOrPath}" "${command.replace(/"/g, '\\"')}"`,
289				{
290					cwd: repoRoot,
291					encoding: "utf-8",
292					stdio: "pipe",
293				}
294			).trim();
295		} catch (error: any) {
296			// If lazyworktree exec fails, try direct execution
297			if (error.stdout) return error.stdout.toString().trim();
298			throw new Error(`Failed to exec in worktree: ${error.message}`);
299		}
300	}
301
302	// Fallback: find worktree path and exec directly
303	const worktrees = listWorktrees(cwd);
304	const worktree = worktrees.find(
305		(wt) => wt.branch === nameOrPath || wt.path === nameOrPath || wt.path.endsWith(`/${nameOrPath}`)
306	);
307
308	if (!worktree) {
309		throw new Error(`Worktree '${nameOrPath}' not found`);
310	}
311
312	try {
313		return execSync(command, {
314			cwd: worktree.path,
315			encoding: "utf-8",
316			stdio: "pipe",
317		}).trim();
318	} catch (error) {
319		throw new Error(`Failed to exec in worktree: ${error}`);
320	}
321}
322
323// =============================================================================
324// Remove Worktree
325// =============================================================================
326
327export function removeWorktree(options: WorktreeRemoveOptions, cwd: string = process.cwd()): void {
328	const repoRoot = findRepoRoot(cwd);
329	if (!repoRoot) {
330		throw new Error("Not in a git repository");
331	}
332
333	// Find worktree by branch name
334	const worktrees = listWorktrees(repoRoot);
335	const worktree = worktrees.find((wt) => wt.branch === options.branch);
336
337	if (!worktree) {
338		throw new Error(`Worktree for branch '${options.branch}' not found`);
339	}
340
341	// Check for uncommitted changes (unless force)
342	if (!options.force && hasUncommittedChanges(worktree.path)) {
343		throw new Error(
344			`Worktree has uncommitted changes. Use force option to remove anyway.`
345		);
346	}
347
348	// Try lazyworktree first for removal (handles branch cleanup too)
349	if (hasLazyworktree()) {
350		try {
351			execSync(`lazyworktree delete --silent "${worktree.path}"`, {
352				cwd: repoRoot,
353				encoding: "utf-8",
354				stdio: "pipe",
355			});
356			return;
357		} catch {
358			// Fall through to raw git
359		}
360	}
361
362	// Fall back to raw git
363	let cmd = `git worktree remove "${worktree.path}"`;
364	if (options.force) {
365		cmd += " --force";
366	}
367
368	try {
369		execSync(cmd, {
370			cwd: repoRoot,
371			encoding: "utf-8",
372			stdio: "pipe",
373		});
374	} catch (error) {
375		throw new Error(`Failed to remove worktree: ${error}`);
376	}
377}
378
379// =============================================================================
380// Prune Worktrees
381// =============================================================================
382
383export function pruneWorktrees(cwd: string = process.cwd()): void {
384	const repoRoot = findRepoRoot(cwd);
385	if (!repoRoot) {
386		throw new Error("Not in a git repository");
387	}
388
389	try {
390		execSync("git worktree prune", {
391			cwd: repoRoot,
392			encoding: "utf-8",
393			stdio: "pipe",
394		});
395	} catch (error) {
396		throw new Error(`Failed to prune worktrees: ${error}`);
397	}
398}
399
400// =============================================================================
401// Get Current Worktree Info
402// =============================================================================
403
404export function getCurrentWorktreeInfo(cwd: string = process.cwd()): WorktreeInfo | null {
405	const worktrees = listWorktrees(cwd);
406	const currentPath = path.resolve(cwd);
407
408	return worktrees.find((wt) => path.resolve(wt.path) === currentPath) ?? null;
409}
410
411// =============================================================================
412// Worktree Notes (requires lazyworktree)
413// =============================================================================
414
415/**
416 * Get the note for a worktree by name.
417 * Returns the note content or null if no note or lazyworktree unavailable.
418 */
419export function getWorktreeNote(worktreeName: string, cwd: string = process.cwd()): string | null {
420	if (!hasLazyworktree()) return null;
421
422	const repoRoot = findRepoRoot(cwd);
423	if (!repoRoot) return null;
424
425	try {
426		const output = execSync(`lazyworktree note show "${worktreeName}"`, {
427			cwd: repoRoot,
428			encoding: "utf-8",
429			stdio: "pipe",
430		}).trim();
431		return output || null;
432	} catch {
433		return null;
434	}
435}
436
437/**
438 * Set the note for a worktree by name.
439 * Writes content via stdin to lazyworktree note edit.
440 */
441export function setWorktreeNote(worktreeName: string, content: string, cwd: string = process.cwd()): boolean {
442	if (!hasLazyworktree()) return false;
443
444	const repoRoot = findRepoRoot(cwd);
445	if (!repoRoot) return false;
446
447	try {
448		execSync(`lazyworktree note edit --input - "${worktreeName}"`, {
449			cwd: repoRoot,
450			encoding: "utf-8",
451			input: content,
452			stdio: ["pipe", "pipe", "pipe"],
453		});
454		return true;
455	} catch {
456		return false;
457	}
458}
459
460/**
461 * Resolve the current directory to a worktree name and its note.
462 * Returns null if not in a lazyworktree-managed worktree or lazyworktree unavailable.
463 */
464export function getCurrentWorktreeContext(cwd: string = process.cwd()): {
465	name: string;
466	branch: string;
467	note: string | null;
468	isMain: boolean;
469} | null {
470	if (!hasLazyworktree()) return null;
471
472	const repoRoot = findRepoRoot(cwd);
473	if (!repoRoot) return null;
474
475	try {
476		const output = execSync("lazyworktree list --json", {
477			cwd: repoRoot,
478			encoding: "utf-8",
479			stdio: "pipe",
480		}).trim();
481
482		const items: LazyworktreeInfo[] = JSON.parse(output);
483		const resolvedCwd = path.resolve(cwd);
484		const match = items.find((wt) => path.resolve(wt.path) === resolvedCwd);
485
486		if (!match) return null;
487
488		const note = getWorktreeNote(match.name, cwd);
489
490		return {
491			name: match.name,
492			branch: match.branch,
493			note,
494			isMain: match.is_main,
495		};
496	} catch {
497		return null;
498	}
499}