/** * Git worktree operations for parallel development. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import * as path from "node:path"; const exec = promisify(execFile); async function git(args: string[], cwd: string): Promise { const { stdout } = await exec("git", args, { cwd }); return stdout.trim(); } export interface WorktreeInfo { path: string; branch: string; head: string; isBare: boolean; } export async function listWorktrees(cwd: string): Promise { const output = await git(["worktree", "list", "--porcelain"], cwd); const worktrees: WorktreeInfo[] = []; let current: Partial = {}; for (const line of output.split("\n")) { if (line.startsWith("worktree ")) { if (current.path) worktrees.push(current as WorktreeInfo); current = { path: line.slice(9), isBare: false }; } else if (line.startsWith("HEAD ")) { current.head = line.slice(5); } else if (line.startsWith("branch ")) { current.branch = line.slice(7).replace("refs/heads/", ""); } else if (line === "bare") { current.isBare = true; } } if (current.path) worktrees.push(current as WorktreeInfo); return worktrees; } export async function getCurrentBranch(cwd: string): Promise { return git(["branch", "--show-current"], cwd); } export async function createWorktree( projectDir: string, wtName: string, ): Promise<{ path: string; branch: string }> { const parentDir = path.dirname(projectDir); const wtPath = path.join(parentDir, wtName); const branch = wtName; await git(["worktree", "add", "-b", branch, wtPath, "HEAD"], projectDir); return { path: wtPath, branch }; } export async function removeWorktree(projectDir: string, wtPath: string): Promise { await git(["worktree", "remove", wtPath], projectDir); } export async function testMerge( cwd: string, branch: string, ): Promise<{ clean: boolean; conflicts: string[] }> { try { await git(["merge", "--no-commit", "--no-ff", branch], cwd); // Clean merge — abort it (we were just testing) await git(["merge", "--abort"], cwd); return { clean: true, conflicts: [] }; } catch (err: any) { // Conflict — get conflicting files, then abort const conflicts: string[] = []; try { const status = await git(["diff", "--name-only", "--diff-filter=U"], cwd); conflicts.push(...status.split("\n").filter(Boolean)); } catch { // fallback } try { await git(["merge", "--abort"], cwd); } catch { // already aborted or no merge in progress } return { clean: false, conflicts }; } } export async function executeMerge( cwd: string, branch: string, message: string, ): Promise { await git(["merge", "--no-ff", branch, "-m", message], cwd); } export async function deleteBranch(cwd: string, branch: string): Promise { await git(["branch", "-d", branch], cwd); } export async function hasUncommittedChanges(cwd: string): Promise { const status = await git(["status", "--porcelain"], cwd); return status.length > 0; } export async function commitAll(cwd: string, message: string): Promise { await git(["add", "-A"], cwd); await git(["commit", "-m", message], cwd); } export async function getDiffStats(cwd: string, branch: string): Promise { try { return await git(["diff", "--stat", `${branch}~1..${branch}`], cwd); } catch { return ""; } } export function getProjectName(projectDir: string): string { return path.basename(projectDir); }