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}