/** * Git service for VS Code extension * Uses VS Code's built-in git extension API for repository operations * and raw git commands via child_process for worktree operations */ import * as vscode from 'vscode'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; import { spawn, execFile } from 'child_process'; import { promisify } from 'util'; import type { API as GitAPI, Repository, GitExtension, Status } from './git.d'; let gitApi: GitAPI | null = null; let gitExtensionEnabled = false; type WorktreeBootstrapStatus = { status: 'pending' | 'ready' | 'failed'; phase: 'directory-created' | 'git-ready' | 'setup-ready'; error: string | null; updatedAt: number; }; const worktreeBootstrapState = new Map(); const activeWorktreeBootstrapTasks = new Map>(); const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const; const WORKTREE_BOOTSTRAP_READY = 'ready' as const; const WORKTREE_BOOTSTRAP_FAILED = 'failed' as const; const WORKTREE_PHASE_DIRECTORY_CREATED = 'directory-created' as const; const WORKTREE_PHASE_GIT_READY = 'git-ready' as const; const WORKTREE_PHASE_SETUP_READY = 'setup-ready' as const; const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250; const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750; const GIT_NULL_REF = '0'.repeat(40); const toBootstrapStateKey = (directory: string): string => { const normalized = normalizeDirectoryPath(directory); if (!normalized) { return ''; } return path.resolve(normalized); }; const setWorktreeBootstrapState = ( directory: string, status: WorktreeBootstrapStatus['status'], phase: WorktreeBootstrapStatus['phase'], error: string | null = null, ): WorktreeBootstrapStatus | null => { const key = toBootstrapStateKey(directory); if (!key) { return null; } const state: WorktreeBootstrapStatus = { status, phase, error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null, updatedAt: Date.now(), }; worktreeBootstrapState.set(key, state); return state; }; const setWorktreeBootstrapFailure = (directory: string, error: unknown): void => { const current = worktreeBootstrapState.get(toBootstrapStateKey(directory)); setWorktreeBootstrapState( directory, WORKTREE_BOOTSTRAP_FAILED, current?.phase ?? WORKTREE_PHASE_DIRECTORY_CREATED, error instanceof Error ? error.message : String(error), ); }; const clearWorktreeBootstrapState = (directory: string): void => { const key = toBootstrapStateKey(directory); if (!key) { return; } worktreeBootstrapState.delete(key); }; const trackWorktreeBootstrapTask = (directory: string, task: Promise): void => { const key = toBootstrapStateKey(directory); if (!key) { return; } activeWorktreeBootstrapTasks.set(key, task); const clearTask = () => { if (activeWorktreeBootstrapTasks.get(key) === task) { activeWorktreeBootstrapTasks.delete(key); } }; void task.then(clearTask, clearTask); }; const waitForActiveWorktreeBootstrap = async (directory: string): Promise => { const key = toBootstrapStateKey(directory); if (!key) { return; } while (true) { const task = activeWorktreeBootstrapTasks.get(key); if (!task) { return; } await task.catch(() => undefined); } }; const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; async function isSocketPath(candidate: string): Promise { if (!candidate) { return false; } try { const stat = await fs.promises.stat(candidate); return typeof stat.isSocket === 'function' && stat.isSocket(); } catch { return false; } } async function resolveSshAuthSock(): Promise { const existing = (process.env.SSH_AUTH_SOCK || '').trim(); if (existing) { return existing; } if (process.platform === 'win32') { return undefined; } const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh'); if (await isSocketPath(gpgSock)) { return gpgSock; } const runGpgconf = async (args: string[]): Promise => { for (const candidate of gpgconfCandidates) { try { const { stdout } = await execFileAsync(candidate, args); return String(stdout || ''); } catch { continue; } } return ''; }; const candidate = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim(); if (candidate && await isSocketPath(candidate)) { return candidate; } if (candidate) { await runGpgconf(['--launch', 'gpg-agent']); const retried = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim(); if (retried && await isSocketPath(retried)) { return retried; } } return undefined; } async function buildGitEnv(): Promise { const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' }; if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) { const resolved = await resolveSshAuthSock(); if (resolved) { env.SSH_AUTH_SOCK = resolved; } } return env; } /** * Initialize the git extension API */ async function initGitExtension(): Promise { if (gitApi && gitExtensionEnabled) { return gitApi; } try { const gitExtension = vscode.extensions.getExtension('vscode.git'); if (!gitExtension) { console.warn('[GitService] Git extension not found'); return null; } if (!gitExtension.isActive) { await gitExtension.activate(); } const extension = gitExtension.exports; if (!extension.enabled) { console.warn('[GitService] Git extension is disabled'); return null; } gitApi = extension.getAPI(1); gitExtensionEnabled = true; // Listen for enablement changes extension.onDidChangeEnablement((enabled) => { gitExtensionEnabled = enabled; if (!enabled) { gitApi = null; } }); return gitApi; } catch (error) { console.error('[GitService] Failed to initialize git extension:', error); return null; } } /** * Get the git API, initializing if necessary */ async function getGitApi(): Promise { if (gitApi && gitExtensionEnabled) { return gitApi; } return initGitExtension(); } /** * Get repository for a given directory */ async function getRepository(directory: string): Promise { const api = await getGitApi(); if (!api) return null; const normalizedDir = normalizePath(directory); const uri = vscode.Uri.file(normalizedDir); // Try to find an existing repository let repo = api.getRepository(uri); if (repo) return repo; // Try to open the repository repo = await api.openRepository(uri); return repo; } /** * Normalize a file path for cross-platform compatibility */ function normalizePath(p: string): string { let normalized = p; // Handle tilde expansion first (before converting slashes) if (normalized.startsWith('~')) { normalized = path.join(os.homedir(), normalized.slice(1)); } // Convert backslashes to forward slashes for consistent path handling normalized = normalized.replace(/\\/g, '/'); return normalized; } function normalizeDirectoryPath(value: string): string { if (typeof value !== 'string') { return value; } const trimmed = value.trim(); if (!trimmed) { return trimmed; } if (trimmed === '~') { return os.homedir(); } if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { return path.join(os.homedir(), trimmed.slice(2)); } return trimmed; } function cleanBranchName(branch: string): string { if (!branch) { return branch; } if (branch.startsWith('refs/heads/')) { return branch.substring('refs/heads/'.length); } if (branch.startsWith('heads/')) { return branch.substring('heads/'.length); } if (branch.startsWith('refs/')) { return branch.substring('refs/'.length); } return branch; } /** * Execute a raw git command and return the output */ async function execGit(args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> { return new Promise((resolve) => { const normalizedCwd = normalizePath(cwd); const gitPath = gitApi?.git.path || 'git'; buildGitEnv().then((env) => { const proc = spawn(gitPath, args, { cwd: normalizedCwd, env, windowsHide: true, }); let stdout = ''; let stderr = ''; proc.stdout?.on('data', (data) => { stdout += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); }); proc.on('close', (exitCode) => { resolve({ stdout, stderr, exitCode: exitCode ?? 0 }); }); proc.on('error', (error) => { resolve({ stdout: '', stderr: error.message, exitCode: 1 }); }); }).catch((error) => { resolve({ stdout: '', stderr: error instanceof Error ? error.message : String(error), exitCode: 1 }); }); }); } function isValidCommitHash(hash: string): boolean { return /^[0-9a-fA-F]{7,40}$/.test(hash); } function extractGitStatusPath(status: string, pathPart: string): string { if ((status === 'R' || status === 'C') && pathPart.includes('\t')) { return pathPart.split('\t').pop() || pathPart; } return pathPart; } function extractGitNumstatDestinationPath(filePath: string): string { if (!filePath.includes(' => ')) { return filePath; } const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/); if (braceMatch) { const [, prefix, , destination, suffix] = braceMatch; return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/'); } return filePath.split(' => ').pop()?.trim() || filePath; } // ============== Repository Operations ============== /** * Check if a directory is a git repository */ export async function checkIsGitRepository(directory: string): Promise { const result = await execGit(['rev-parse', '--is-inside-work-tree'], directory); return result.exitCode === 0 && result.stdout.trim() === 'true'; } /** * Check if a directory is a linked worktree (not the main worktree) */ export async function isLinkedWorktree(directory: string): Promise { const gitDir = await execGit(['rev-parse', '--git-dir'], directory); const commonDir = await execGit(['rev-parse', '--git-common-dir'], directory); if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) { return false; } const gitDirPath = path.resolve(directory, gitDir.stdout.trim()); const commonDirPath = path.resolve(directory, commonDir.stdout.trim()); return gitDirPath !== commonDirPath; } // ============== Status Operations ============== interface GitStatusFile { path: string; index: string; working_dir: string; } interface GitMergeInProgress { /** Short SHA of MERGE_HEAD */ head: string; /** First line of MERGE_MSG */ message: string; } interface GitRebaseInProgress { /** Branch name being rebased */ headName: string; /** Short SHA of the onto commit */ onto: string; } export interface GitStatusResult { current: string; tracking: string | null; ahead: number; behind: number; files: GitStatusFile[]; isClean: boolean; diffStats?: Record; /** Present when a merge is in progress with conflicts */ mergeInProgress?: GitMergeInProgress | null; /** Present when a rebase is in progress */ rebaseInProgress?: GitRebaseInProgress | null; } type GitStatusOptions = { mode?: 'light'; }; /** * Map VS Code git status to our status codes */ function mapStatus(status: Status): string { // Status enum values const statusMap: Record = { 0: 'M', // INDEX_MODIFIED 1: 'A', // INDEX_ADDED 2: 'D', // INDEX_DELETED 3: 'R', // INDEX_RENAMED 4: 'C', // INDEX_COPIED 5: 'M', // MODIFIED 6: 'D', // DELETED 7: '?', // UNTRACKED 8: '!', // IGNORED 9: 'A', // INTENT_TO_ADD 10: 'R', // INTENT_TO_RENAME 11: 'T', // TYPE_CHANGED 12: 'U', // ADDED_BY_US 13: 'U', // ADDED_BY_THEM 14: 'U', // DELETED_BY_US 15: 'U', // DELETED_BY_THEM 16: 'U', // BOTH_ADDED 17: 'U', // BOTH_DELETED 18: 'U', // BOTH_MODIFIED }; return statusMap[status] || ' '; } function getRepositoryRelativePath(repo: Repository, uri: vscode.Uri): string { return path.relative(repo.rootUri.fsPath, uri.fsPath).replace(/\\/g, '/'); } /** * Get git status for a directory */ export async function getGitStatus(directory: string, options?: GitStatusOptions): Promise { // The VS Code Git API path does not compute heavyweight diff stats today, // but accepts the shared options contract so callers can rely on parity. void options; const repo = await getRepository(directory); if (!repo) { // Fallback to raw git return getGitStatusRaw(directory); } const state = repo.state; const head = state.HEAD; const files: GitStatusFile[] = []; // Process index changes (staged) for (const change of state.indexChanges) { const relativePath = getRepositoryRelativePath(repo, change.uri); files.push({ path: relativePath, index: mapStatus(change.status), working_dir: ' ', }); } // Process working tree changes (unstaged) for (const change of state.workingTreeChanges) { const relativePath = getRepositoryRelativePath(repo, change.uri); const existing = files.find(f => f.path === relativePath); if (existing) { existing.working_dir = mapStatus(change.status); } else { files.push({ path: relativePath, index: ' ', working_dir: mapStatus(change.status), }); } } // Check for in-progress operations const inProgressState = await checkInProgressOperations(directory); return { current: head?.name || '', tracking: head?.upstream ? `${head.upstream.remote}/${head.upstream.name}` : null, ahead: head?.ahead || 0, behind: head?.behind || 0, files, isClean: files.length === 0, ...inProgressState, }; } /** * Check for in-progress merge/rebase operations */ async function checkInProgressOperations(directory: string): Promise<{ mergeInProgress?: GitMergeInProgress | null; rebaseInProgress?: GitRebaseInProgress | null; }> { const result: { mergeInProgress?: GitMergeInProgress | null; rebaseInProgress?: GitRebaseInProgress | null; } = {}; const gitDir = path.join(directory, '.git'); try { // Check MERGE_HEAD for merge in progress const mergeHeadPath = path.join(gitDir, 'MERGE_HEAD'); const mergeHeadExists = await fs.promises.stat(mergeHeadPath).then(() => true).catch(() => false); if (mergeHeadExists) { const mergeHead = await fs.promises.readFile(mergeHeadPath, 'utf8').catch(() => ''); const headSha = mergeHead.trim().slice(0, 7); // Only set mergeInProgress if we actually have a valid head SHA if (headSha) { const mergeMsg = await fs.promises.readFile(path.join(gitDir, 'MERGE_MSG'), 'utf8').catch(() => ''); result.mergeInProgress = { head: headSha, message: mergeMsg.split('\n')[0] || '', }; } } } catch { // ignore } try { // Check for rebase in progress (.git/rebase-merge or .git/rebase-apply) const rebaseMergeExists = await fs.promises.stat(path.join(gitDir, 'rebase-merge')).then(() => true).catch(() => false); const rebaseApplyExists = await fs.promises.stat(path.join(gitDir, 'rebase-apply')).then(() => true).catch(() => false); if (rebaseMergeExists || rebaseApplyExists) { const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply'; const headName = await fs.promises.readFile(path.join(gitDir, rebaseDir, 'head-name'), 'utf8').catch(() => ''); const onto = await fs.promises.readFile(path.join(gitDir, rebaseDir, 'onto'), 'utf8').catch(() => ''); const headNameTrimmed = headName.trim().replace('refs/heads/', ''); const ontoTrimmed = onto.trim().slice(0, 7); // Only set rebaseInProgress if we have valid data if (headNameTrimmed || ontoTrimmed) { result.rebaseInProgress = { headName: headNameTrimmed, onto: ontoTrimmed, }; } } } catch { // ignore } return result; } /** * Fallback: Get git status using raw git commands */ async function getGitStatusRaw(directory: string): Promise { const statusResult = await execGit(['status', '--porcelain=v1', '-b', '-uall'], directory); if (statusResult.exitCode !== 0) { return { current: '', tracking: null, ahead: 0, behind: 0, files: [], isClean: true, }; } const lines = statusResult.stdout.trim().split('\n').filter(Boolean); const files: GitStatusFile[] = []; let current = ''; let tracking: string | null = null; let ahead = 0; let behind = 0; for (const line of lines) { if (line.startsWith('##')) { // Parse branch info const branchMatch = line.match(/^## (.+?)(?:\.\.\.(.+?))?(?:\s+\[(.+)\])?$/); if (branchMatch) { current = branchMatch[1] || ''; tracking = branchMatch[2] || null; const trackingInfo = branchMatch[3] || ''; const aheadMatch = trackingInfo.match(/ahead (\d+)/); const behindMatch = trackingInfo.match(/behind (\d+)/); ahead = aheadMatch ? parseInt(aheadMatch[1], 10) : 0; behind = behindMatch ? parseInt(behindMatch[1], 10) : 0; } } else { // Parse file status const index = line[0] || ' '; const workingDir = line[1] || ' '; const filePath = line.slice(3).trim(); files.push({ path: filePath, index, working_dir: workingDir, }); } } // Check for in-progress operations const inProgressState = await checkInProgressOperations(directory); return { current, tracking, ahead, behind, files, isClean: files.length === 0, ...inProgressState, }; } // ============== Branch Operations ============== interface GitBranchDetails { current: boolean; name: string; commit: string; label: string; tracking?: string; ahead?: number; behind?: number; } export interface GitBranchResult { all: string[]; current: string; branches: Record; } /** * Get all branches for a directory */ export async function getGitBranches(directory: string): Promise { const repo = await getRepository(directory); if (!repo) { return getGitBranchesRaw(directory); } const state = repo.state; const currentBranch = state.HEAD?.name || ''; const branches: Record = {}; const all: string[] = []; // Get local branches const localRefs = await repo.getBranches({ remote: false }); for (const ref of localRefs) { if (ref.name) { all.push(ref.name); branches[ref.name] = { current: ref.name === currentBranch, name: ref.name, commit: ref.commit || '', label: ref.name, }; } } // Get remote branches const remoteRefs = await repo.getBranches({ remote: true }); for (const ref of remoteRefs) { if (ref.name) { const remoteBranchName = `remotes/${ref.name}`; all.push(remoteBranchName); branches[remoteBranchName] = { current: false, name: remoteBranchName, commit: ref.commit || '', label: ref.name, }; } } // Add upstream info for HEAD if (state.HEAD?.name && state.HEAD?.upstream) { const branchInfo = branches[state.HEAD.name]; if (branchInfo) { branchInfo.tracking = `${state.HEAD.upstream.remote}/${state.HEAD.upstream.name}`; branchInfo.ahead = state.HEAD.ahead; branchInfo.behind = state.HEAD.behind; } } return { all, current: currentBranch, branches }; } /** * Fallback: Get branches using raw git commands */ async function getGitBranchesRaw(directory: string): Promise { const result = await execGit(['branch', '-a', '-v', '--format=%(refname:short)|%(objectname:short)|%(upstream:short)|%(HEAD)'], directory); if (result.exitCode !== 0) { return { all: [], current: '', branches: {} }; } const lines = result.stdout.trim().split('\n').filter(Boolean); const branches: Record = {}; const all: string[] = []; let current = ''; for (const line of lines) { const [name, commit, tracking, head] = line.split('|'); if (name) { all.push(name); const isCurrent = head === '*'; if (isCurrent) current = name; branches[name] = { current: isCurrent, name, commit: commit || '', label: name.replace(/^remotes\//, ''), tracking: tracking || undefined, }; } } return { all, current, branches }; } /** * Checkout a branch */ export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> { const repo = await getRepository(directory); if (repo) { try { await repo.checkout(branch); return { success: true, branch }; } catch (error) { console.error('[GitService] Failed to checkout branch:', error); } } // Fallback to raw git const result = await execGit(['checkout', branch], directory); return { success: result.exitCode === 0, branch }; } /** * Create a new branch */ export async function createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }> { const repo = await getRepository(directory); if (repo) { try { await repo.createBranch(name, false, startPoint); return { success: true, branch: name }; } catch (error) { console.error('[GitService] Failed to create branch:', error); } } // Fallback to raw git const args = ['branch', name]; if (startPoint) args.push(startPoint); const result = await execGit(args, directory); return { success: result.exitCode === 0, branch: name }; } /** * Delete a local branch */ export async function deleteGitBranch(directory: string, branch: string, force = false): Promise<{ success: boolean }> { const repo = await getRepository(directory); if (repo) { try { await repo.deleteBranch(branch, force); return { success: true }; } catch (error) { console.error('[GitService] Failed to delete branch:', error); } } // Fallback to raw git const flag = force ? '-D' : '-d'; const result = await execGit(['branch', flag, branch], directory); return { success: result.exitCode === 0 }; } /** * Delete a remote branch */ export async function deleteRemoteBranch(directory: string, branch: string, remote = 'origin'): Promise<{ success: boolean }> { const result = await execGit(['push', remote, '--delete', branch], directory); return { success: result.exitCode === 0 }; } // ============== Worktree Operations ============== export interface GitWorktreeInfo { head: string; name: string; branch: string; path: string; directoryCreated?: true; bootstrapStatus?: WorktreeBootstrapStatus; } type WorktreeListEntry = { worktree: string; head?: string; branchRef?: string; branch?: string; }; interface GitWorktreeValidationError { code: string; message: string; } export interface GitWorktreeValidationResult { ok: boolean; errors: GitWorktreeValidationError[]; resolved?: { mode?: 'new' | 'existing'; localBranch?: string | null; }; } export interface CreateGitWorktreePayload { mode?: 'new' | 'existing'; worktreeName?: string; name?: string; branchName?: string; existingBranch?: string; startRef?: string; startCommand?: string; setUpstream?: boolean; upstreamRemote?: string; upstreamBranch?: string; ensureRemoteName?: string; ensureRemoteUrl?: string; returnAfterDirectoryCreated?: boolean; } export interface RemoveGitWorktreePayload { directory: string; deleteLocalBranch?: boolean; } const OPENCODE_ADJECTIVES = [ 'brave', 'calm', 'clever', 'cosmic', 'crisp', 'curious', 'eager', 'gentle', 'glowing', 'happy', 'hidden', 'jolly', 'kind', 'lucky', 'mighty', 'misty', 'neon', 'nimble', 'playful', 'proud', 'quick', 'quiet', 'shiny', 'silent', 'stellar', 'sunny', 'swift', 'tidy', 'witty', ]; const OPENCODE_NOUNS = [ 'cabin', 'cactus', 'canyon', 'circuit', 'comet', 'eagle', 'engine', 'falcon', 'forest', 'garden', 'harbor', 'island', 'knight', 'lagoon', 'meadow', 'moon', 'mountain', 'nebula', 'orchid', 'otter', 'panda', 'pixel', 'planet', 'river', 'rocket', 'sailor', 'squid', 'star', 'tiger', 'wizard', 'wolf', ]; const OPENCODE_WORKTREE_ATTEMPTS = 26; const getOpenCodeDataPath = () => { const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); return path.join(xdgDataHome, 'opencode'); }; const pickRandom = (values: string[]) => values[Math.floor(Math.random() * values.length)]; const generateOpenCodeRandomName = () => `${pickRandom(OPENCODE_ADJECTIVES)}-${pickRandom(OPENCODE_NOUNS)}`; const slugWorktreeName = (value: string) => { return String(value || '') .trim() .replace(/^refs\/heads\//, '') .replace(/^heads\//, '') .replace(/\s+/g, '-') .replace(/^\/+|\/+$/g, '') .split('/').join('-') .replace(/[^A-Za-z0-9._-]+/g, '-') .replace(/-+/g, '-') .replace(/^-+/, '') .replace(/-+$/, '') .slice(0, 80); }; const parseWorktreePorcelain = (raw: string): WorktreeListEntry[] => { const lines = String(raw || '').split('\n').map((line) => line.trim()); const entries: WorktreeListEntry[] = []; let current: WorktreeListEntry | null = null; for (const line of lines) { if (!line) { if (current?.worktree) { entries.push(current); } current = null; continue; } if (line.startsWith('worktree ')) { if (current?.worktree) { entries.push(current); } current = { worktree: line.substring('worktree '.length).trim() }; continue; } if (!current) { continue; } if (line.startsWith('HEAD ')) { current.head = line.substring('HEAD '.length).trim(); continue; } if (line.startsWith('branch ')) { const branchRef = line.substring('branch '.length).trim(); current.branchRef = branchRef; current.branch = cleanBranchName(branchRef); } } if (current?.worktree) { entries.push(current); } return entries; }; const canonicalPath = async (input: string): Promise => { const absolutePath = path.resolve(input); const realPath = await fs.promises.realpath(absolutePath).catch(() => absolutePath); const normalized = path.normalize(realPath); return process.platform === 'win32' ? normalized.toLowerCase() : normalized; }; const checkPathExists = async (targetPath: string): Promise => { try { await fs.promises.stat(targetPath); return true; } catch { return false; } }; const normalizeStartRef = (value: string | undefined): string => { const trimmed = String(value || '').trim(); return trimmed || 'HEAD'; }; const parseRemoteBranchRef = (value: string) => { const trimmed = String(value || '').trim(); if (!trimmed) { return null; } if (trimmed.startsWith('refs/remotes/')) { const rest = trimmed.substring('refs/remotes/'.length); const slashIndex = rest.indexOf('/'); if (slashIndex <= 0 || slashIndex === rest.length - 1) { return null; } return { remote: rest.slice(0, slashIndex), branch: rest.slice(slashIndex + 1), remoteRef: rest, fullRef: `refs/remotes/${rest}`, }; } if (trimmed.startsWith('remotes/')) { return parseRemoteBranchRef(`refs/${trimmed}`); } const slashIndex = trimmed.indexOf('/'); if (slashIndex <= 0 || slashIndex === trimmed.length - 1) { return null; } return { remote: trimmed.slice(0, slashIndex), branch: trimmed.slice(slashIndex + 1), remoteRef: trimmed, fullRef: `refs/remotes/${trimmed}`, }; }; const resolveRemoteBranchRef = async (primaryWorktree: string, value: string) => { const raw = String(value || '').trim(); const parsed = parseRemoteBranchRef(raw); if (!parsed) { return null; } if (raw.startsWith('refs/remotes/') || raw.startsWith('remotes/')) { return parsed; } const localRef = `refs/heads/${raw}`; const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]); if (localExists.success) { return null; } return parsed; }; const normalizeUpstreamTarget = (remote: string | undefined, branch: string | undefined) => { const remoteName = String(remote || '').trim(); const branchName = String(branch || '').trim(); if (!remoteName || !branchName) { return null; } return { remote: remoteName, branch: branchName, full: `${remoteName}/${branchName}`, }; }; type GitCommandResult = { success: boolean; exitCode: number; stdout: string; stderr: string; message?: string; }; const runGitCommand = async (cwd: string, args: string[]): Promise => { const result = await execGit(args, cwd); const message = [result.stderr, result.stdout].map((value) => String(value || '').trim()).filter(Boolean).join('\n').trim(); return { success: result.exitCode === 0, exitCode: result.exitCode, stdout: String(result.stdout || ''), stderr: String(result.stderr || ''), message, }; }; const runGitCommandOrThrow = async (cwd: string, args: string[], fallbackMessage: string) => { const result = await runGitCommand(cwd, args); if (!result.success) { throw new Error(result.message || fallbackMessage || 'Git command failed'); } return result; }; const wait = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); const isIndexLockError = (result: GitCommandResult): boolean => { const message = [result?.message, result?.stderr, result?.stdout].filter(Boolean).join('\n'); return /index\.lock['"]?: File exists|another git process seems to be running/i.test(message); }; const getWorktreeIndexLockPath = async (directory: string): Promise => { const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'index.lock']); if (!result.success) { return null; } const value = String(result.stdout || '').trim(); return value ? (path.isAbsolute(value) ? value : path.resolve(directory, value)) : null; }; const getFileIdentity = async (filePath: string): Promise => { try { const stat = await fs.promises.stat(filePath); return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { return null; } throw error; } }; // OpenChamber places managed worktrees under a deep data-dir path // (`/opencode/worktree/<40-char project id>//`). On // Windows that prefix plus a deeply nested repo file routinely exceeds // MAX_PATH (260). Git can check those paths out when core.longpaths is // enabled; without it, `git reset --hard` during bootstrap fails with // "Filename too long" and leaves a half-populated worktree (issue #2746). const WORKTREE_POPULATE_RESET_ARGS = ['-c', 'core.longpaths=true', 'reset', '--hard'] as const; const isFilenameTooLongError = (message: string | null | undefined): boolean => /file ?name too long/i.test(String(message || '')); const formatWorktreePopulateError = (message: string | null | undefined): string => { const text = String(message || '').trim() || 'Failed to populate worktree'; if (!isFilenameTooLongError(text)) { return text; } return [ text, 'The worktree checkout path exceeds this system\'s path-length limit.', 'OpenChamber enables Git `core.longpaths` for worktree population; if this still fails on Windows, enable OS long paths (LongPathsEnabled) or open the repository from a shorter absolute path.', ].join('\n'); }; const ensureWorktreeLongpaths = async (directory: string): Promise => { const current = await runGitCommand(directory, ['config', '--get', 'core.longpaths']); if (String(current.stdout || '').trim().toLowerCase() === 'true') { return; } // Local config is shared across linked worktrees via the common git dir, so // subsequent OpenChamber and CLI git operations in this repo also get long // path support. Failures here are non-fatal: populate still passes // `-c core.longpaths=true` on reset. await runGitCommand(directory, ['config', 'core.longpaths', 'true']); }; const populateWorktreeWithLockRecovery = async (directory: string): Promise => { await ensureWorktreeLongpaths(directory); let result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]); if (result.success) { return; } if (!isIndexLockError(result)) { throw new Error(formatWorktreePopulateError(result.message)); } await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS); result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]); if (result.success) { return; } if (!isIndexLockError(result)) { throw new Error(formatWorktreePopulateError(result.message)); } const lockPath = await getWorktreeIndexLockPath(directory); const identity = lockPath ? await getFileIdentity(lockPath) : null; await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS); result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]); if (result.success) { return; } if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) { throw new Error(formatWorktreePopulateError(result.message)); } await fs.promises.unlink(lockPath).catch((error) => { if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') { throw error; } }); const finalResult = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]); if (!finalResult.success) { throw new Error(formatWorktreePopulateError(finalResult.message || 'Failed to populate worktree')); } }; // Worktrees are created with `git worktree add --no-checkout` and populated // with `git reset --hard`, neither of which runs git's post-checkout hook — // git only runs it for checkouts, clone, and worktree add *without* // --no-checkout. Invoke the hook explicitly after population to restore git's // checkout semantics: git passes the previous HEAD (null ref for a brand-new // worktree), the new HEAD, and flag 1 for a branch checkout, and runs the hook // from the worktree top-level. const runPostCheckoutHook = async (directory: string): Promise => { let hookDirectory: string | null = null; try { const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'hooks']); if (!result.success) return; hookDirectory = normalizeDirectoryPath(String(result.stdout || '').trim()); } catch { return; } if (!hookDirectory) return; const hookPath = path.join(hookDirectory, 'post-checkout'); try { const stat = await fs.promises.stat(hookPath); if (!stat.isFile()) return; if (process.platform !== 'win32') { await fs.promises.access(hookPath, fs.constants.X_OK); } } catch { // Missing or non-executable hooks are skipped, matching git. return; } const [headResult, gitDirResult] = await Promise.all([ runGitCommand(directory, ['rev-parse', 'HEAD']), runGitCommand(directory, ['rev-parse', '--absolute-git-dir']), ]); if (!headResult.success || !gitDirResult.success) return; const head = String(headResult.stdout || '').trim(); const gitDir = String(gitDirResult.stdout || '').trim(); if (!head || !gitDir) return; try { await execFileAsync(hookPath, [GIT_NULL_REF, head, '1'], { cwd: directory, env: { ...(await buildGitEnv()), GIT_DIR: gitDir, GIT_WORK_TREE: path.resolve(directory), }, windowsHide: true, }); } catch (error) { // A failing hook must not fail worktree creation or session bootstrap: // warn and continue. console.warn('[GitService] post-checkout hook failed in worktree:', error instanceof Error ? error.message : String(error)); } }; const ensureOpenCodeProjectId = async (primaryWorktree: string): Promise => { const gitDir = path.join(primaryWorktree, '.git'); const idFile = path.join(gitDir, 'opencode'); const existing = await fs.promises.readFile(idFile, 'utf8').then((value) => value.trim()).catch(() => ''); if (existing) { return existing; } const rootsResult = await runGitCommandOrThrow( primaryWorktree, ['rev-list', '--max-parents=0', '--all'], 'Failed to resolve repository roots' ); const roots = rootsResult.stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) .sort((a, b) => a.localeCompare(b)); const projectId = roots[0] || ''; if (!projectId) { throw new Error('Failed to derive OpenCode project ID'); } await fs.promises.mkdir(gitDir, { recursive: true }).catch(() => undefined); await fs.promises.writeFile(idFile, projectId, 'utf8').catch(() => undefined); return projectId; }; const resolveWorktreeProjectContext = async (directory: string) => { const directoryPath = normalizeDirectoryPath(directory); if (!directoryPath) { throw new Error('Directory is required'); } const topResult = await runGitCommandOrThrow( directoryPath, ['rev-parse', '--show-toplevel'], 'Failed to resolve git top-level directory' ); const sandbox = path.resolve(directoryPath, topResult.stdout.trim()); const commonResult = await runGitCommandOrThrow( sandbox, ['rev-parse', '--git-common-dir'], 'Failed to resolve git common directory' ); const commonDir = path.resolve(sandbox, commonResult.stdout.trim()); const primaryWorktree = path.dirname(commonDir); const projectID = await ensureOpenCodeProjectId(primaryWorktree); const worktreeRoot = path.join(getOpenCodeDataPath(), 'worktree', projectID); return { projectID, sandbox, primaryWorktree, worktreeRoot }; }; const listWorktreeEntries = async (directory: string): Promise => { const rawResult = await runGitCommandOrThrow(directory, ['worktree', 'list', '--porcelain'], 'Failed to list git worktrees'); return parseWorktreePorcelain(rawResult.stdout); }; const resolveWorktreeNameCandidates = (baseName: string): string[] => { const normalizedBase = slugWorktreeName(baseName || ''); if (!normalizedBase) { return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, () => generateOpenCodeRandomName()); } return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, (_, index) => { if (index === 0) { return normalizedBase; } return `${normalizedBase}-${generateOpenCodeRandomName()}`; }); }; const resolveCandidateDirectory = async ( worktreeRoot: string, preferredName: string, explicitBranchName: string, primaryWorktree: string ) => { const candidates = resolveWorktreeNameCandidates(preferredName); for (const name of candidates) { const directory = path.join(worktreeRoot, name); if (await checkPathExists(directory)) { continue; } if (explicitBranchName) { return { name, directory, branch: explicitBranchName }; } const branch = `openchamber/${name}`; const branchRef = `refs/heads/${branch}`; const branchExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', branchRef]); if (branchExists.success) { continue; } return { name, directory, branch }; } throw new Error('Failed to generate a unique worktree name'); }; const fetchRemoteBranchRef = async (primaryWorktree: string, remoteName: string, branchName: string) => { const remote = String(remoteName || '').trim(); const branch = String(branchName || '').trim(); if (!remote || !branch) { return; } const refspec = `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`; await runGitCommandOrThrow(primaryWorktree, ['fetch', remote, refspec], `Failed to fetch ${remote}/${branch}`); }; const resolveBranchForExistingMode = async (primaryWorktree: string, existingBranch: string, preferredBranchName: string) => { const requested = String(existingBranch || '').trim(); if (!requested) { throw new Error('existingBranch is required in existing mode'); } const normalizedLocal = cleanBranchName(requested); const localRef = `refs/heads/${normalizedLocal}`; const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]); if (localExists.success) { return { localBranch: normalizedLocal, checkoutRef: normalizedLocal, createLocalBranch: false, remoteRef: null as ReturnType, }; } const remoteRef = parseRemoteBranchRef(requested); if (!remoteRef) { throw new Error(`Branch not found: ${requested}`); } const remoteExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]); if (!remoteExists.success) { await fetchRemoteBranchRef(primaryWorktree, remoteRef.remote, remoteRef.branch).catch(() => undefined); const recheck = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]); if (!recheck.success) { throw new Error(`Remote branch not found: ${requested}`); } } const localBranch = cleanBranchName(preferredBranchName || remoteRef.branch || requested); if (!localBranch) { throw new Error('Failed to resolve local branch name for existing branch worktree'); } return { localBranch, checkoutRef: remoteRef.remoteRef, createLocalBranch: true, remoteRef, }; }; const findBranchInUse = async (primaryWorktree: string, localBranchName: string) => { if (!localBranchName) { return null; } const entries = await listWorktreeEntries(primaryWorktree); const targetRef = `refs/heads/${localBranchName}`; const targetClean = cleanBranchName(targetRef); return entries.find((entry) => { const entryRef = String(entry.branchRef || '').trim(); const entryClean = cleanBranchName(entryRef || entry.branch || ''); return entryRef === targetRef || entryClean === targetClean; }) || null; }; const runWorktreeStartCommand = async (directory: string, command: string): Promise<{ success: boolean; message?: string; stdout?: string; stderr?: string }> => { const text = String(command || '').trim(); if (!text) { return { success: true }; } const env = await buildGitEnv(); if (process.platform === 'win32') { try { const { stdout, stderr } = await execFileAsync('cmd', ['/c', text], { cwd: directory, env, maxBuffer: 20 * 1024 * 1024, }); return { success: true, stdout: String(stdout || ''), stderr: String(stderr || '') }; } catch (error) { const err = error as { stdout?: string; stderr?: string; message?: string }; return { success: false, stdout: err.stdout, stderr: err.stderr, message: String(err.message || err.stderr || err.stdout || 'Failed to run start command').trim(), }; } } try { const { stdout, stderr } = await execFileAsync('bash', ['-lc', text], { cwd: directory, env, maxBuffer: 20 * 1024 * 1024, }); return { success: true, stdout: String(stdout || ''), stderr: String(stderr || '') }; } catch (error) { const err = error as { stdout?: string; stderr?: string; message?: string }; return { success: false, stdout: err.stdout, stderr: err.stderr, message: String(err.message || err.stderr || err.stdout || 'Failed to run start command').trim(), }; } }; const loadProjectStartCommand = async (projectID: string): Promise => { const storagePath = path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`); try { const raw = await fs.promises.readFile(storagePath, 'utf8'); const parsed = JSON.parse(raw) as { commands?: { start?: string } }; const start = typeof parsed?.commands?.start === 'string' ? parsed.commands.start.trim() : ''; return start || ''; } catch { return ''; } }; // OpenCode owns its own project/sandbox registry and records a worktree as a // sandbox itself when an instance boots for that directory. OpenChamber used to // write that state into OpenCode's storage JSON directly, behind the back of the // running process — and since OpenCode v2 reads sandboxes from its database, the // JSON write did not even reach it. Registration is not ours to perform. const isInsideOrSameDirectory = (root: string, target: string): boolean => { const relative = path.relative(root, target); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); }; const isAttachedGitWorktreeDirectory = async (directory: string): Promise => { try { const result = await runGitCommand(directory, ['rev-parse', '--is-inside-work-tree']); return result.success && String(result.stdout || '').trim() === 'true'; } catch { return false; } }; const cleanupFailedFastWorktreeCreate = async ( context: Awaited>, candidate: { directory: string } ): Promise => { const candidateDirectory = path.resolve(candidate.directory); const worktreeRoot = path.resolve(context.worktreeRoot); const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot; const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory); if (!isInsideWorktreeRoot || isAttached) { return; } try { const entries = await fs.promises.readdir(candidateDirectory); if (entries.length === 0) { await fs.promises.rmdir(candidateDirectory); } } catch (error) { const code = (error as NodeJS.ErrnoException | undefined)?.code; if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(String(code || ''))) { console.warn('[GitService] Failed to clean up empty worktree directory after creation failure:', error instanceof Error ? error.message : String(error)); } } }; const runWorktreeStartScripts = async (directory: string, projectID: string, startCommand: string | undefined) => { const projectStart = await loadProjectStartCommand(projectID); if (projectStart) { const projectResult = await runWorktreeStartCommand(directory, projectStart); if (!projectResult.success) { console.warn('[GitService] Worktree project start command failed:', projectResult.message || projectResult.stderr || projectResult.stdout); return; } } const extraCommand = String(startCommand || '').trim(); if (!extraCommand) { return; } const extraResult = await runWorktreeStartCommand(directory, extraCommand); if (!extraResult.success) { console.warn('[GitService] Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout); } }; const queueWorktreeBootstrap = (args: { directory: string; projectID: string; primaryWorktree: string; localBranch: string; setUpstream: boolean; upstreamRemote: string; upstreamBranch: string; ensureRemoteName: string; ensureRemoteUrl: string; startCommand: string | undefined; }) => { const { directory, projectID, primaryWorktree, localBranch, setUpstream, upstreamRemote, upstreamBranch, ensureRemoteName, ensureRemoteUrl, startCommand, } = args; const task = new Promise((resolve) => setTimeout(resolve, 0)) .then(async () => { await populateWorktreeWithLockRecovery(directory); await runPostCheckoutHook(directory); if (setUpstream) { await applyUpstreamConfiguration({ primaryWorktree, worktreeDirectory: directory, localBranch, setUpstream, upstreamRemote, upstreamBranch, ensureRemoteName, ensureRemoteUrl, }).catch((error) => { console.warn('[GitService] Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error)); }); } setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_PHASE_GIT_READY); await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => { console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error)); }); setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY, WORKTREE_PHASE_SETUP_READY); }) .catch((error) => { setWorktreeBootstrapFailure(directory, error); console.warn('[GitService] Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error)); }); trackWorktreeBootstrapTask(directory, task); }; const ensureRemoteWithUrl = async (primaryWorktree: string, remoteName: string, remoteUrl: string) => { const name = String(remoteName || '').trim(); const url = String(remoteUrl || '').trim(); if (!name || !url) { return; } const getUrl = await runGitCommand(primaryWorktree, ['remote', 'get-url', name]); if (getUrl.success) { const currentUrl = String(getUrl.stdout || '').trim(); if (currentUrl !== url) { await runGitCommandOrThrow(primaryWorktree, ['remote', 'set-url', name, url], 'Failed to update git remote URL'); } return; } await runGitCommandOrThrow(primaryWorktree, ['remote', 'add', name, url], 'Failed to add git remote'); }; const checkRemoteBranchExists = async (primaryWorktree: string, remoteName: string, branchName: string, remoteUrl = '') => { const remote = String(remoteName || '').trim(); const branch = String(branchName || '').trim(); const url = String(remoteUrl || '').trim(); if (!remote || !branch) { return { success: false, found: false }; } const target = url || remote; const lsRemote = await runGitCommand(primaryWorktree, ['ls-remote', '--heads', target, `refs/heads/${branch}`]); if (!lsRemote.success) { return { success: false, found: false }; } return { success: true, found: Boolean(String(lsRemote.stdout || '').trim()), }; }; const setBranchTrackingFallback = async (worktreeDirectory: string, localBranch: string, upstream: { remote: string; branch: string }) => { await runGitCommandOrThrow( worktreeDirectory, ['config', `branch.${localBranch}.remote`, upstream.remote], `Failed to set branch.${localBranch}.remote` ); await runGitCommandOrThrow( worktreeDirectory, ['config', `branch.${localBranch}.merge`, `refs/heads/${upstream.branch}`], `Failed to set branch.${localBranch}.merge` ); }; const applyUpstreamConfiguration = async (args: { primaryWorktree: string; worktreeDirectory: string; localBranch: string; setUpstream: boolean; upstreamRemote?: string; upstreamBranch?: string; ensureRemoteName?: string; ensureRemoteUrl?: string; }) => { const { primaryWorktree, worktreeDirectory, localBranch, setUpstream, upstreamRemote, upstreamBranch, ensureRemoteName, ensureRemoteUrl, } = args; if (!setUpstream) { return; } if (ensureRemoteName && ensureRemoteUrl) { await ensureRemoteWithUrl(primaryWorktree, ensureRemoteName, ensureRemoteUrl); } const upstream = normalizeUpstreamTarget(upstreamRemote, upstreamBranch); if (!upstream || !localBranch) { return; } let fetched = true; try { await fetchRemoteBranchRef(primaryWorktree, upstream.remote, upstream.branch); } catch { fetched = false; } if (fetched) { await runGitCommandOrThrow( worktreeDirectory, ['branch', `--set-upstream-to=${upstream.full}`, localBranch], `Failed to set upstream to ${upstream.full}` ); return; } await setBranchTrackingFallback(worktreeDirectory, localBranch, upstream); }; /** * List all worktrees for a repository */ export async function listGitWorktrees(directory: string): Promise { const directoryPath = normalizeDirectoryPath(directory); if (!directoryPath || !fs.existsSync(directoryPath) || !fs.existsSync(path.join(directoryPath, '.git'))) { return []; } try { const result = await runGitCommandOrThrow(directoryPath, ['worktree', 'list', '--porcelain'], 'Failed to list git worktrees'); return parseWorktreePorcelain(result.stdout).map((entry) => ({ head: entry.head || '', name: path.basename(entry.worktree || ''), branch: entry.branch || '', path: entry.worktree, })); } catch (error) { console.warn('[GitService] Failed to list worktrees, returning empty list:', error instanceof Error ? error.message : String(error)); return []; } } export async function validateWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const errors: GitWorktreeValidationError[] = []; try { const context = await resolveWorktreeProjectContext(directory); const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); const startRef = normalizeStartRef(input?.startRef); const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); let localBranch = ''; let inferredUpstream: { remote: string; branch: string } | null = null; if (mode === 'existing') { try { const requestedExistingBranch = String(input?.existingBranch || '').trim(); const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch); if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) { const lsRemote = await runGitCommand( context.primaryWorktree, ['ls-remote', '--heads', ensureRemoteUrl, `refs/heads/${parsedExistingRemote.branch}`] ); if (!lsRemote.success) { throw new Error(`Unable to query remote ${ensureRemoteName}`); } if (!String(lsRemote.stdout || '').trim()) { throw new Error(`Remote branch not found: ${parsedExistingRemote.remoteRef}`); } localBranch = cleanBranchName(preferredBranchName || parsedExistingRemote.branch); inferredUpstream = { remote: parsedExistingRemote.remote, branch: parsedExistingRemote.branch, }; } else { const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName); localBranch = resolved.localBranch || ''; if (resolved.remoteRef) { inferredUpstream = { remote: resolved.remoteRef.remote, branch: resolved.remoteRef.branch, }; } } } catch (error) { errors.push({ code: 'branch_not_found', message: error instanceof Error ? error.message : 'Existing branch not found', }); } } else { if (preferredBranchName) { const exists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${preferredBranchName}`]); if (exists.success) { errors.push({ code: 'branch_exists', message: `Branch already exists: ${preferredBranchName}` }); } localBranch = preferredBranchName; } const parsedRemoteRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); if (startRef && startRef !== 'HEAD') { if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) { const remoteCheck = await checkRemoteBranchExists( context.primaryWorktree, parsedRemoteRef.remote, parsedRemoteRef.branch, ensureRemoteUrl ); if (!remoteCheck.success) { errors.push({ code: 'remote_unreachable', message: `Unable to query remote ${ensureRemoteName}` }); } else if (!remoteCheck.found) { errors.push({ code: 'start_ref_not_found', message: `Remote branch not found: ${parsedRemoteRef.remoteRef}` }); } } else if (parsedRemoteRef) { const remoteCheck = await checkRemoteBranchExists(context.primaryWorktree, parsedRemoteRef.remote, parsedRemoteRef.branch); if (!remoteCheck.success) { errors.push({ code: 'remote_unreachable', message: `Unable to query remote ${parsedRemoteRef.remote}` }); } else if (!remoteCheck.found) { errors.push({ code: 'start_ref_not_found', message: `Remote branch not found: ${parsedRemoteRef.remoteRef}` }); } } else { const startRefExists = await runGitCommand(context.primaryWorktree, ['rev-parse', '--verify', '--quiet', startRef]); if (!startRefExists.success) { errors.push({ code: 'start_ref_not_found', message: `Start ref not found: ${startRef}` }); } } } if (parsedRemoteRef) { inferredUpstream = { remote: parsedRemoteRef.remote, branch: parsedRemoteRef.branch }; } } if (localBranch) { const inUse = await findBranchInUse(context.primaryWorktree, localBranch); if (inUse) { errors.push({ code: 'branch_in_use', message: `Branch is already checked out in ${inUse.worktree}` }); } } if ((ensureRemoteName && !ensureRemoteUrl) || (!ensureRemoteName && ensureRemoteUrl)) { errors.push({ code: 'invalid_remote_config', message: 'Both ensureRemoteName and ensureRemoteUrl are required together' }); } const shouldSetUpstream = Boolean(input?.setUpstream); if (shouldSetUpstream) { const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); if (!upstreamRemote || !upstreamBranch) { errors.push({ code: 'upstream_incomplete', message: 'upstreamRemote and upstreamBranch are required when setUpstream is true' }); } else { const remoteExists = await runGitCommand(context.primaryWorktree, ['remote', 'get-url', upstreamRemote]); if (!remoteExists.success && (!ensureRemoteName || ensureRemoteName !== upstreamRemote)) { errors.push({ code: 'remote_not_found', message: `Remote not found: ${upstreamRemote}` }); } } } return { ok: errors.length === 0, errors, resolved: { mode, localBranch: localBranch || null, }, }; } catch (error) { return { ok: false, errors: [{ code: 'validation_failed', message: error instanceof Error ? error.message : 'Failed to validate worktree creation', }], }; } } const assertWorktreeCreatePreflight = async (directory: string, input: CreateGitWorktreePayload = {}): Promise => { const validation = await validateWorktreeCreate(directory, input); if (validation?.ok) { return; } const message = validation?.errors ?.map((error) => error?.message) .filter(Boolean) .join('\n') || 'Failed to validate worktree creation'; throw new Error(message); }; export async function previewWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); await fs.promises.mkdir(context.worktreeRoot, { recursive: true }); const preferredName = String(input?.worktreeName || input?.name || '').trim(); const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); const candidate = await resolveCandidateDirectory( context.worktreeRoot, preferredName, mode === 'new' && preferredBranchName ? preferredBranchName : '', context.primaryWorktree ); return { name: candidate.name, branch: mode === 'new' ? candidate.branch : preferredBranchName, path: candidate.directory, head: '', }; } async function attachGitWorktreeToCandidate( context: Awaited>, candidate: { name: string; directory: string; branch: string }, input: CreateGitWorktreePayload = {}, ): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); const startRef = normalizeStartRef(input?.startRef); const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); let localBranch = ''; let inferredUpstream: { remote: string; branch: string } | null = null; const worktreeAddArgs = ['worktree', 'add', '--no-checkout']; if (mode === 'existing') { const requestedExistingBranch = String(input?.existingBranch || '').trim(); const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch); if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) { await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch); } const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName); localBranch = resolved.localBranch; const inUse = await findBranchInUse(context.primaryWorktree, localBranch); if (inUse) { throw new Error(`Branch is already checked out in ${inUse.worktree}`); } if (resolved.createLocalBranch) { worktreeAddArgs.push('-b', localBranch); } worktreeAddArgs.push(candidate.directory, resolved.checkoutRef); if (resolved.remoteRef) { inferredUpstream = { remote: resolved.remoteRef.remote, branch: resolved.remoteRef.branch, }; } } else { localBranch = candidate.branch; if (!localBranch) { throw new Error('Failed to resolve branch name for new worktree'); } const branchExists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${localBranch}`]); if (branchExists.success) { throw new Error(`Branch already exists: ${localBranch}`); } const inUse = await findBranchInUse(context.primaryWorktree, localBranch); if (inUse) { throw new Error(`Branch is already checked out in ${inUse.worktree}`); } worktreeAddArgs.push('-b', localBranch, candidate.directory); if (startRef && startRef !== 'HEAD') { worktreeAddArgs.push(startRef); } const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); if (parsedRemoteStartRef) { inferredUpstream = { remote: parsedRemoteStartRef.remote, branch: parsedRemoteStartRef.branch, }; } } if (ensureRemoteName && ensureRemoteUrl) { await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); } if (mode === 'new') { const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); if (parsedRemoteStartRef) { await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); } } await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree'); const shouldSetUpstream = Boolean(input?.setUpstream); const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); const bootstrapStatus = setWorktreeBootstrapState( candidate.directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_PHASE_DIRECTORY_CREATED, ) ?? { status: WORKTREE_BOOTSTRAP_PENDING, phase: WORKTREE_PHASE_DIRECTORY_CREATED, error: null, updatedAt: Date.now(), }; queueWorktreeBootstrap({ directory: candidate.directory, projectID: context.projectID, primaryWorktree: context.primaryWorktree, localBranch, setUpstream: shouldSetUpstream, upstreamRemote, upstreamBranch, ensureRemoteName, ensureRemoteUrl, startCommand: input?.startCommand, }); const headResult = await runGitCommand(candidate.directory, ['rev-parse', 'HEAD']); const head = String(headResult.stdout || '').trim(); return { head, name: candidate.name, branch: localBranch, path: candidate.directory, directoryCreated: true, bootstrapStatus, }; } export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); if (input?.returnAfterDirectoryCreated === true) { await assertWorktreeCreatePreflight(directory, input); } await fs.promises.mkdir(context.worktreeRoot, { recursive: true }); const preferredName = String(input?.worktreeName || input?.name || '').trim(); const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); const candidate = await resolveCandidateDirectory( context.worktreeRoot, preferredName, mode === 'new' && preferredBranchName ? preferredBranchName : '', context.primaryWorktree ); if (input?.returnAfterDirectoryCreated === true) { await fs.promises.mkdir(candidate.directory, { recursive: false }); const bootstrapStatus = setWorktreeBootstrapState( candidate.directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_PHASE_DIRECTORY_CREATED, ) ?? { status: WORKTREE_BOOTSTRAP_PENDING, phase: WORKTREE_PHASE_DIRECTORY_CREATED, error: null, updatedAt: Date.now(), }; const localBranch = mode === 'existing' ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) : candidate.branch; const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => { setWorktreeBootstrapFailure(candidate.directory, error); await cleanupFailedFastWorktreeCreate(context, candidate); console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error)); }); trackWorktreeBootstrapTask(candidate.directory, task); return { head: '', name: candidate.name, branch: localBranch, path: candidate.directory, directoryCreated: true, bootstrapStatus, }; } return attachGitWorktreeToCandidate(context, candidate, input); } export async function getWorktreeBootstrapStatus(directory: string): Promise { const key = toBootstrapStateKey(directory); if (!key) { throw new Error('Worktree directory is required'); } const current = worktreeBootstrapState.get(key); if (current) { return current; } return { status: WORKTREE_BOOTSTRAP_READY, phase: WORKTREE_PHASE_SETUP_READY, error: null, updatedAt: Date.now(), }; } export async function removeWorktree(directory: string, input: RemoveGitWorktreePayload): Promise { const targetDirectory = normalizeDirectoryPath(input?.directory); if (!targetDirectory) { throw new Error('Worktree directory is required'); } await waitForActiveWorktreeBootstrap(targetDirectory); const context = await resolveWorktreeProjectContext(directory); const deleteLocalBranch = input?.deleteLocalBranch === true; const targetCanonical = await canonicalPath(targetDirectory); const primaryCanonical = await canonicalPath(context.primaryWorktree); if (targetCanonical === primaryCanonical) { throw new Error('Cannot remove the primary workspace'); } const entries = await listWorktreeEntries(context.primaryWorktree); const matchedEntry = await (async () => { for (const entry of entries) { if (!entry?.worktree) { continue; } const entryCanonical = await canonicalPath(entry.worktree); if (entryCanonical === targetCanonical) { return entry; } } return null; })(); if (!matchedEntry?.worktree) { const targetExists = await checkPathExists(targetDirectory); if (targetExists) { await fs.promises.rm(targetDirectory, { recursive: true, force: true }); } clearWorktreeBootstrapState(targetDirectory); return true; } await runGitCommandOrThrow( context.primaryWorktree, ['worktree', 'remove', '--force', matchedEntry.worktree], 'Failed to remove git worktree' ); if (deleteLocalBranch) { const branchName = cleanBranchName(String(matchedEntry.branchRef || matchedEntry.branch || '').trim()); if (branchName) { await runGitCommandOrThrow( context.primaryWorktree, ['branch', '-D', branchName], `Failed to delete local branch ${branchName}` ); } } clearWorktreeBootstrapState(matchedEntry.worktree); return true; } // ============== Diff Operations ============== /** * Get diff for a file */ export async function getGitDiff( directory: string, filePath: string, staged = false, contextLines?: number ): Promise<{ diff: string }> { const args = ['diff']; if (staged) args.push('--cached'); if (typeof contextLines === 'number') args.push(`-U${contextLines}`); args.push('--', filePath); const result = await execGit(args, directory); return { diff: result.stdout }; } /** * Get diff between two refs for a file (base...head). */ export async function getGitRangeDiff( directory: string, base: string, head: string, filePath: string, contextLines = 3 ): Promise<{ diff: string }> { const baseRef = (base || '').trim(); const headRef = (head || '').trim(); if (!baseRef || !headRef) { return { diff: '' }; } let resolvedBase = baseRef; try { const verify = await execGit(['rev-parse', '--verify', `refs/remotes/origin/${baseRef}`], directory); if (verify.exitCode === 0) { resolvedBase = `origin/${baseRef}`; } } catch { // ignore } const args = ['diff', '--no-color', `-U${Math.max(0, contextLines)}`, `${resolvedBase}...${headRef}`, '--', filePath]; const result = await execGit(args, directory); return { diff: result.stdout }; } /** * List files changed between two refs (base...head). */ export async function getGitRangeFiles( directory: string, base: string, head: string ): Promise { const baseRef = (base || '').trim(); const headRef = (head || '').trim(); if (!baseRef || !headRef) { return []; } let resolvedBase = baseRef; try { const verify = await execGit(['rev-parse', '--verify', `refs/remotes/origin/${baseRef}`], directory); if (verify.exitCode === 0) { resolvedBase = `origin/${baseRef}`; } } catch { // ignore } const args = ['diff', '--name-only', `${resolvedBase}...${headRef}`]; const result = await execGit(args, directory); if (result.exitCode !== 0) return []; return String(result.stdout || '') .split('\n') .map((l) => l.trim()) .filter(Boolean); } /** * Get file diff with original and modified content */ export async function getGitFileDiff( directory: string, filePath: string, staged = false ): Promise<{ original: string; modified: string; path: string }> { const repo = await getRepository(directory); if (repo) { try { // For staged files, get content from HEAD // For unstaged files, get content from index (staged) or HEAD let original: string; if (staged) { original = await repo.show('HEAD', filePath); } else { try { // Try to get from index first original = await repo.show(':0:' + filePath, filePath); } catch { // Fall back to HEAD original = await repo.show('HEAD', filePath); } } let modified: string; if (staged) { const stagedResult = await execGit(['show', `:${filePath}`], directory); modified = stagedResult.exitCode === 0 ? stagedResult.stdout : ''; } else { const fileUri = vscode.Uri.file(path.join(directory, filePath)); const modifiedBytes = await vscode.workspace.fs.readFile(fileUri); modified = Buffer.from(modifiedBytes).toString('utf8'); } return { original, modified, path: filePath }; } catch (error) { console.error('[GitService] Failed to get file diff:', error); } } // Fallback: return empty content return { original: '', modified: '', path: filePath }; } /** * Revert a file to its last committed state */ export async function revertGitFile( directory: string, filePath: string, options: { scope?: 'all' | 'working' } = {}, ): Promise { const scope = options.scope === 'working' ? 'working' : 'all'; const tracked = await execGit(['ls-files', '--error-unmatch', '--', filePath], directory); if (tracked.exitCode !== 0) { const clean = await execGit(['clean', '-f', '-d', '--', filePath], directory); if (clean.exitCode !== 0) { const root = path.resolve(directory); const target = path.resolve(directory, filePath); if (target !== root && !target.startsWith(root + path.sep)) { throw new Error(`Path is outside repository: ${filePath}`); } await fs.promises.rm(target, { recursive: true, force: true }); } return; } if (scope === 'all') { const unstage = await execGit(['restore', '--staged', '--', filePath], directory); if (unstage.exitCode !== 0) { await execGit(['reset', 'HEAD', '--', filePath], directory); } } const restore = await execGit(['restore', '--', filePath], directory); if (restore.exitCode === 0) { return; } const fallback = await execGit(['checkout', '--', filePath], directory); if (fallback.exitCode !== 0) { throw new Error(fallback.stderr || restore.stderr || 'Failed to revert git file'); } } export async function stageGitFiles(directory: string, filePaths: string[]): Promise { const paths = filePaths.map((path) => path.trim()).filter(Boolean); if (paths.length === 0) { throw new Error('path is required'); } const result = await execGit(['add', '--', ...paths], directory); if (result.exitCode === 0) { return; } const isPathspecError = /pathspec/.test(result.stderr) && /did not match any files/.test(result.stderr); if (!isPathspecError) { throw new Error(result.stderr || 'Failed to stage git file'); } // During rapid stage/unstage toggling the optimistic UI can request staging a // path that a prior queued mutation already staged (most visibly a deletion, // whose file is gone from the working tree). `git add` aborts the whole batch on // a single unmatched pathspec, so retry per-path and skip the ones already in // their target state rather than failing the entire "stage all". for (const path of paths) { const perPath = await execGit(['add', '--', path], directory); if (perPath.exitCode === 0) { continue; } const perPathIsPathspecError = /pathspec/.test(perPath.stderr) && /did not match any files/.test(perPath.stderr); if (!perPathIsPathspecError) { throw new Error(perPath.stderr || 'Failed to stage git file'); } } } export async function unstageGitFiles(directory: string, filePaths: string[]): Promise { const paths = filePaths.map((path) => path.trim()).filter(Boolean); if (paths.length === 0) { throw new Error('path is required'); } const result = await execGit(['restore', '--staged', '--', ...paths], directory); if (result.exitCode === 0) { return; } const fallback = await execGit(['reset', 'HEAD', '--', ...paths], directory); if (fallback.exitCode !== 0) { throw new Error(fallback.stderr || result.stderr || 'Failed to unstage git file'); } } const HUNK_ACTION_ARGS: Record<'stage' | 'unstage' | 'discard', string[]> = { stage: ['--cached'], unstage: ['--cached', '--reverse'], discard: ['--reverse'], }; const parsePatchPathToken = (line: string): string | null => { const value = String(line || '').replace(/^(?:-{3}|\+{3})\s+/, ''); if (!value || value === '/dev/null') { return null; } if (value.startsWith('"')) { let token = '"'; let escaped = false; for (let index = 1; index < value.length; index += 1) { const char = value[index]; token += char; if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === '"') { break; } } try { return JSON.parse(token) as string; } catch { return token.slice(1, token.endsWith('"') ? -1 : undefined); } } return value.split('\t', 1)[0] || null; }; const normalizePatchTargetPath = (value: string | null): string | null => { if (!value || value === '/dev/null') { return null; } return value.replace(/^[ab]\//, '').replace(/\\/g, '/'); }; const extractPatchTargetPath = (patch: string): string | null => { const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)]; const realTargets = matches .map((match) => normalizePatchTargetPath(parsePatchPathToken(match[0] ?? ''))) .filter((value): value is string => Boolean(value)); return realTargets[0] || null; }; const getRepoRelativePath = async (directory: string, filePath: string): Promise => { const normalizedFilePath = normalizePath(filePath).replace(/\\/g, '/'); if (!path.isAbsolute(normalizedFilePath)) { return normalizedFilePath.replace(/^\.?\//, ''); } const rootResult = await execGit(['rev-parse', '--show-toplevel'], directory); if (rootResult.exitCode !== 0) { throw new Error(rootResult.stderr || 'Failed to resolve repository root'); } const repoRoot = normalizePath(rootResult.stdout.trim()); return path.relative(repoRoot, normalizedFilePath).replace(/\\/g, '/'); }; /** * Apply a single-hunk patch to stage, unstage, or discard it. * The patch is written to a temp file and applied with `git apply`. */ export async function applyGitHunk( directory: string, filePath: string, patch: string, action: 'stage' | 'unstage' | 'discard', ): Promise { if (!HUNK_ACTION_ARGS[action]) { throw new Error('Invalid hunk action'); } if (!filePath) { throw new Error('path is required'); } if (typeof patch !== 'string' || !patch.trim()) { throw new Error('patch is required'); } if (!/^@@\s/m.test(patch)) { throw new Error('patch does not contain a hunk header'); } const repoRelativePath = await getRepoRelativePath(directory, filePath); const targetPath = extractPatchTargetPath(patch); if (targetPath && targetPath !== repoRelativePath && targetPath !== filePath.replace(/\\/g, '/')) { throw new Error('patch target path does not match the requested file'); } const flags = HUNK_ACTION_ARGS[action]; const tmpDir = os.tmpdir(); const tmpPath = path.join(tmpDir, `openchamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`); try { await fs.promises.writeFile(tmpPath, patch, 'utf8'); const check = await execGit(['apply', ...flags, '--check', tmpPath], directory); if (check.exitCode !== 0) { const detail = (check.stderr || '').trim(); throw new Error( detail ? `Hunk no longer applies — refresh and try again.\n${detail}` : 'Hunk no longer applies — refresh and try again.' ); } const apply = await execGit(['apply', ...flags, tmpPath], directory); if (apply.exitCode !== 0) { throw new Error(apply.stderr || 'Failed to apply git hunk'); } } finally { await fs.promises.rm(tmpPath, { force: true }).catch(() => {}); } } // ============== Commit Operations ============== export interface GitCommitResult { success: boolean; commit: string; branch: string; summary: { changes: number; insertions: number; deletions: number; }; } /** * Create a git commit */ export async function createGitCommit( directory: string, message: string, options?: { addAll?: boolean; files?: string[]; stageFiles?: string[] } ): Promise { if (options?.files?.length && options.stageFiles) { const selectedFiles = new Set(options.files); const stagedResult = await execGit(['diff', '--cached', '--name-only'], directory); const temporarilyUnstagedFiles = stagedResult.stdout .split('\n') .map((line) => line.trim()) .filter((filePath) => filePath && !selectedFiles.has(filePath)); try { if (temporarilyUnstagedFiles.length > 0) { await execGit(['restore', '--staged', '--', ...temporarilyUnstagedFiles], directory); } if (options.stageFiles.length > 0) { await execGit(['add', '--', ...options.stageFiles], directory); } const result = await execGit(['commit', '-m', message], directory); if (result.exitCode !== 0) { return { success: false, commit: '', branch: '', summary: { changes: 0, insertions: 0, deletions: 0 }, }; } const hashResult = await execGit(['rev-parse', 'HEAD'], directory); const branchResult = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], directory); return { success: true, commit: hashResult.stdout.trim(), branch: branchResult.stdout.trim(), summary: { changes: 0, insertions: 0, deletions: 0 }, }; } finally { if (temporarilyUnstagedFiles.length > 0) { await execGit(['add', '--', ...temporarilyUnstagedFiles], directory); } } } const repo = await getRepository(directory); if (repo) { try { if (options?.addAll) { await repo.add(['.']); } else if (options?.files?.length) { const filesToStage = options.stageFiles ?? options.files; if (filesToStage.length > 0) { await repo.add(filesToStage); } } await repo.commit(message); const head = repo.state.HEAD; return { success: true, commit: head?.commit || '', branch: head?.name || '', summary: { changes: 0, insertions: 0, deletions: 0 }, }; } catch (error) { console.error('[GitService] Failed to commit:', error); } } // Fallback to raw git if (options?.addAll) { await execGit(['add', '-A'], directory); } else if (options?.files?.length) { const filesToStage = options.stageFiles ?? options.files; if (filesToStage.length > 0) { await execGit(['add', ...filesToStage], directory); } } const result = await execGit(['commit', '-m', message], directory); if (result.exitCode !== 0) { return { success: false, commit: '', branch: '', summary: { changes: 0, insertions: 0, deletions: 0 }, }; } // Get commit info const hashResult = await execGit(['rev-parse', 'HEAD'], directory); const branchResult = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], directory); return { success: true, commit: hashResult.stdout.trim(), branch: branchResult.stdout.trim(), summary: { changes: 0, insertions: 0, deletions: 0 }, }; } // ============== Remote Operations ============== /** * Convert options to an array of git arguments. * Supports both array format ['--set-upstream', '--force'] and * object format { '--set-upstream': null, '--force': true } */ function normalizeGitOptions(options?: string[] | Record): string[] { if (!options) return []; if (Array.isArray(options)) { return options; } // Object format: { '--set-upstream': null, '--force': true, '--remote': 'origin' } const args: string[] = []; for (const [key, value] of Object.entries(options)) { if (value === null || value === true) { args.push(key); } else if (value !== false && value !== undefined) { args.push(key, String(value)); } } return args; } /** * Check if options contain a specific flag */ function hasOption(options: string[] | Record | undefined, flag: string): boolean { if (!options) return false; if (Array.isArray(options)) { return options.includes(flag); } return flag in options && options[flag] !== false; } /** * Push to remote */ export async function gitPush( directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record } ): Promise<{ success: boolean; pushed: Array<{ local: string; remote: string }>; repo: string; ref: unknown }> { const remote = options?.remote?.trim(); const branch = options?.branch; const gitOptions = options?.options; const describePushFailure = (value: unknown): string => { const message = String( (value as { message?: string } | undefined)?.message || (value as { stderr?: string } | undefined)?.stderr || (value as { stdout?: string } | undefined)?.stdout || '' ).trim(); return message || 'Failed to push to remote'; }; const buildUpstreamOptions = (raw?: string[] | Record): string[] => { const normalized = normalizeGitOptions(raw); if (hasOption(normalized, '--set-upstream') || hasOption(normalized, '-u')) { return normalized; } return [...normalized, '--set-upstream']; }; const looksLikeMissingUpstream = (value: unknown): boolean => { const message = String( (value as { message?: string } | undefined)?.message || (value as { stderr?: string } | undefined)?.stderr || '' ).toLowerCase(); return ( message.includes('has no upstream') || message.includes('no upstream') || message.includes('set-upstream') || message.includes('set upstream') || (message.includes('upstream') && message.includes('push') && message.includes('-u')) ); }; const getCurrentBranch = async (): Promise => { const result = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], directory); return String(result.stdout || '').trim(); }; const hasTrackingBranch = async (): Promise => { const result = await execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], directory); return result.exitCode === 0 && Boolean(String(result.stdout || '').trim()); }; const getRemotes = async (): Promise => { const result = await execGit(['remote'], directory); if (result.exitCode !== 0) { return []; } return String(result.stdout || '') .split('\n') .map((line) => line.trim()) .filter(Boolean); }; const pushRaw = async (args: string[]) => { const result = await execGit(args, directory); if (result.exitCode !== 0) { throw new Error(describePushFailure(result)); } }; const normalizePushResult = (local: string, remoteName: string) => ({ success: true, pushed: [{ local, remote: remoteName }], repo: directory, ref: null, }); if (!remote && !branch) { try { const args = ['push']; const normalizedOptions = normalizeGitOptions(gitOptions); if (normalizedOptions.length > 0) { args.push(...normalizedOptions); } await pushRaw(args); return { success: true, pushed: [], repo: directory, ref: null, }; } catch (error) { if (!looksLikeMissingUpstream(error)) { throw new Error(describePushFailure(error)); } const currentBranch = await getCurrentBranch(); const remotes = await getRemotes(); const fallbackRemote = remotes.includes('origin') ? 'origin' : remotes[0]; if (!currentBranch || !fallbackRemote) { throw new Error(describePushFailure(error)); } const args = ['push', ...buildUpstreamOptions(gitOptions), fallbackRemote, currentBranch]; await pushRaw(args); return normalizePushResult(currentBranch, fallbackRemote); } } const remoteName = remote || 'origin'; if (!branch) { try { const currentBranch = await getCurrentBranch(); const tracking = await hasTrackingBranch(); if (currentBranch && !tracking) { const args = ['push', ...buildUpstreamOptions(gitOptions), remoteName, currentBranch]; await pushRaw(args); return normalizePushResult(currentBranch, remoteName); } } catch (error) { console.warn('[GitService] Failed to determine upstream state before push:', error); } } try { const args = ['push', ...normalizeGitOptions(gitOptions), remoteName]; if (branch) { args.push(branch); } await pushRaw(args); return normalizePushResult(branch || '', remoteName); } catch (error) { if (!looksLikeMissingUpstream(error)) { throw new Error(describePushFailure(error)); } const fallbackBranch = branch || await getCurrentBranch(); if (!fallbackBranch) { throw new Error(describePushFailure(error)); } const args = ['push', ...buildUpstreamOptions(gitOptions), remoteName, fallbackBranch]; await pushRaw(args); return normalizePushResult(fallbackBranch, remoteName); } } /** * Pull from remote */ export async function gitPull( directory: string, options?: { remote?: string; branch?: string; rebase?: boolean } ): Promise<{ success: boolean; summary: { changes: number; insertions: number; deletions: number }; files: string[]; insertions: number; deletions: number }> { const repo = await getRepository(directory); if (repo && options?.rebase !== true) { try { await repo.pull(); return { success: true, summary: { changes: 0, insertions: 0, deletions: 0 }, files: [], insertions: 0, deletions: 0, }; } catch (error) { console.error('[GitService] Failed to pull:', error); } } // Fallback to raw git const beforeHead = await execGit(['rev-parse', 'HEAD'], directory); const args = ['pull']; if (options?.rebase === true) args.push('--rebase'); if (options?.remote) args.push(options.remote); if (options?.branch) args.push(options.branch); const result = await execGit(args, directory); if (result.exitCode !== 0) { throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to pull from remote'); } const afterHead = await execGit(['rev-parse', 'HEAD'], directory); const before = beforeHead.exitCode === 0 ? beforeHead.stdout.trim() : ''; const after = afterHead.exitCode === 0 ? afterHead.stdout.trim() : ''; const changedFiles = before && after && before !== after ? await execGit(['diff', '--name-only', before, after], directory) : { stdout: '', stderr: '', exitCode: 0 }; const files = changedFiles.exitCode === 0 ? changedFiles.stdout.split('\n').map((line) => line.trim()).filter(Boolean) : []; return { success: result.exitCode === 0, summary: { changes: files.length, insertions: 0, deletions: 0 }, files, insertions: 0, deletions: 0, }; } export async function listGitStashes(directory: string): Promise> { const result = await execGit(['stash', 'list', '--format=%gd%x1f%gs%x1f%cr%x1f%H'], directory); if (result.exitCode !== 0) throw new Error(result.stderr.trim() || 'Failed to list stashes'); return result.stdout.split('\n').map((line) => line.trim()).filter(Boolean).map((line) => { const [ref = '', message = '', relativeTime = '', hash = ''] = line.split('\x1f'); return { ref, message, relativeTime, hash }; }).filter((entry) => entry.ref); } export async function countGitStashFiles(directory: string, refs: string[]): Promise> { const uniqueRefs = Array.from(new Set(refs.map((ref) => String(ref || '').trim()).filter(Boolean))); const counts: Record = {}; const concurrency = 4; let cursor = 0; const worker = async () => { while (cursor < uniqueRefs.length) { const ref = uniqueRefs[cursor++]; if (!ref) continue; const names = await execGit(['stash', 'show', '--name-only', ref], directory); counts[ref] = names.exitCode === 0 ? names.stdout.split('\n').map((line) => line.trim()).filter(Boolean).length : 0; } }; await Promise.all(Array.from({ length: Math.min(concurrency, uniqueRefs.length) }, () => worker())); return counts; } export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> { const message = options.message?.trim() || `OpenChamber stash ${new Date().toISOString()}`; const result = await execGit(['stash', 'push', '--include-untracked', '-m', message], directory); if (result.exitCode !== 0) throw new Error(result.stderr.trim() || 'Failed to stash changes'); const output = result.stdout.trim() || result.stderr.trim(); return { success: true, created: !/no local changes/i.test(output), message, output }; } export async function applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> { const ref = options.ref || 'stash@{0}'; // Prefer --index so the staged/unstaged split captured in the stash is restored // faithfully. Fall back to a plain apply when the index can't be reinstated // cleanly (e.g. conflicts), which is the prior behavior. const withIndex = await execGit(['stash', 'apply', '--index', ref], directory); if (withIndex.exitCode === 0) { return { success: true, ref }; } const result = await execGit(['stash', 'apply', ref], directory); if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to apply stash'); return { success: true, ref }; } export async function dropGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> { const ref = options.ref || 'stash@{0}'; const result = await execGit(['stash', 'drop', ref], directory); if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to drop stash'); return { success: true, ref }; } export async function popGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> { const ref = options.ref || 'stash@{0}'; await applyGitStash(directory, { ref }); await dropGitStash(directory, { ref }); return { success: true, ref }; } /** * Fetch from remote */ export async function gitFetch( directory: string, options?: { remote?: string; branch?: string } ): Promise<{ success: boolean }> { const repo = await getRepository(directory); if (repo) { try { await repo.fetch({ remote: options?.remote, ref: options?.branch }); return { success: true }; } catch (error) { console.error('[GitService] Failed to fetch:', error); } } // Fallback to raw git const args = ['fetch']; if (options?.remote) args.push(options.remote); if (options?.branch) args.push(options.branch); const result = await execGit(args, directory); return { success: result.exitCode === 0 }; } // ============== Log Operations ============== export interface GitLogEntry { hash: string; date: string; message: string; refs: string; body: string; author_name: string; author_email: string; filesChanged: number; insertions: number; deletions: number; parents: string[]; } /** * Resolve a log base ref using local-first semantics (mirrors web service.js). * * - Returns undefined when `from` is falsy/whitespace. * - Returns `from` unchanged when the local ref resolves. * - Returns `origin/` when local is absent but the remote-tracking ref exists. * - Returns `from` unchanged when neither resolves (lets git surface the error). */ async function resolveBaseRefForLog( from: string | undefined, directory: string ): Promise { const normalized = typeof from === 'string' ? from.trim() : undefined; if (!normalized) return undefined; const checkRef = async (ref: string): Promise => { const result = await execGit(['rev-parse', '--verify', ref], directory); return result.exitCode === 0 && Boolean(result.stdout.trim()); }; if (await checkRef(normalized)) return normalized; const originRef = `refs/remotes/origin/${normalized}`; if (await checkRef(originRef)) return `origin/${normalized}`; return normalized; } /** * Get git log */ export async function getGitLog( directory: string, options?: { maxCount?: number; from?: string; to?: string; file?: string; all?: boolean } ): Promise<{ all: GitLogEntry[]; latest: GitLogEntry | null; total: number }> { const maxCount = options?.maxCount || 50; if (options?.all) { const logArgs = [ 'log', `--max-count=${maxCount}`, '--all', '--topo-order', '--date=iso', '--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D', '--shortstat', ]; const result = await execGit(logArgs, directory); if (result.exitCode !== 0) { throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log'); } const records = result.stdout .split('\x1e') .map((e) => e.trim()) .filter(Boolean); const entries: GitLogEntry[] = []; for (const record of records) { const lines = record.split('\n').filter((l) => l.trim().length > 0); const header = lines.shift() || ''; const [hash, parentsRaw, author_name, author_email, date, message, refsRaw] = header.split('\x1f'); if (!hash) continue; const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : []; const refs = refsRaw ? refsRaw.trim() : ''; let filesChanged = 0; let insertions = 0; let deletions = 0; for (const line of lines) { const filesMatch = line.match(/(\d+)\s+files?\s+changed/); const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/); const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/); if (filesMatch) filesChanged = parseInt(filesMatch[1], 10); if (insertMatch) insertions = parseInt(insertMatch[1], 10); if (deleteMatch) deletions = parseInt(deleteMatch[1], 10); } entries.push({ hash, date: date || '', message: message || '', refs, body: '', author_name: author_name || '', author_email: author_email || '', filesChanged, insertions, deletions, parents, }); } return { all: entries, latest: entries[0] || null, total: entries.length }; } // Prefer the local ref; fall back to origin/ only when the local ref // cannot be resolved (e.g. user has never checked out the base branch). const resolvedFrom = await resolveBaseRefForLog(options?.from, directory); const args = [ 'log', `--max-count=${maxCount}`, '--date=iso', '--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D', '--shortstat', ]; if (resolvedFrom && options?.to) { args.push(`${resolvedFrom}..${options.to}`); } else if (resolvedFrom) { args.push(`${resolvedFrom}..HEAD`); } else if (options?.to) { args.push(options.to); } if (options?.file) { args.push('--', options.file); } const result = await execGit(args, directory); if (result.exitCode !== 0) { throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to get git log'); } const records = result.stdout .split('\x1e') .map((entry) => entry.trim()) .filter(Boolean); const statsMap = new Map(); for (const record of records) { const lines = record.split('\n').filter((line) => line.trim().length > 0); const header = lines.shift() || ''; const [hash, parentsRaw] = header.split('\x1f'); const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : []; if (!hash) continue; let filesChanged = 0; let insertions = 0; let deletions = 0; for (const line of lines) { const filesMatch = line.match(/(\d+)\s+files?\s+changed/); const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/); const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/); if (filesMatch) filesChanged = parseInt(filesMatch[1], 10); if (insertMatch) insertions = parseInt(insertMatch[1], 10); if (deleteMatch) deletions = parseInt(deleteMatch[1], 10); } statsMap.set(hash, { filesChanged, insertions, deletions, parents }); } const entries: GitLogEntry[] = []; for (const record of records) { const header = record.split('\n').filter((l) => l.trim().length > 0)[0] || ''; const [hash] = header.split('\x1f'); if (!hash) continue; const stats = statsMap.get(hash) || { filesChanged: 0, insertions: 0, deletions: 0, parents: [] }; // Need to re-parse header fields for the final entries array const lines = record.split('\n').filter((l) => l.trim().length > 0); const lineHeader = lines.shift() || ''; const [, , author_name, author_email, date, message, refs] = lineHeader.split('\x1f'); entries.push({ hash, date: date || '', message: message || '', refs: refs?.trim() || '', body: '', author_name: author_name || '', author_email: author_email || '', filesChanged: stats.filesChanged, insertions: stats.insertions, deletions: stats.deletions, parents: stats.parents, }); } return { all: entries, latest: entries[0] || null, total: entries.length, }; } /** * Get files changed in a commit */ export async function getCommitFiles( directory: string, hash: string ): Promise<{ files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> }> { const numstatResult = await execGit(['show', '--numstat', '--format=', hash], directory); if (numstatResult.exitCode !== 0) { return { files: [] }; } const files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> = []; const lines = numstatResult.stdout.trim().split('\n').filter(Boolean); for (const line of lines) { const parts = line.split('\t'); if (parts.length < 3) continue; const [insertionsRaw, deletionsRaw, ...pathParts] = parts; const filePath = pathParts.join('\t'); if (!filePath) continue; const isBinary = insertionsRaw === '-' && deletionsRaw === '-'; const insertions = isBinary ? 0 : (parseInt(insertionsRaw, 10) || 0); const deletions = isBinary ? 0 : (parseInt(deletionsRaw, 10) || 0); let changeType = 'M'; if (filePath.includes(' => ')) { changeType = 'R'; } files.push({ path: filePath, insertions, deletions, isBinary, changeType }); } // Get accurate change types from --name-status const nameStatusResult = await execGit(['show', '--name-status', '--format=', hash], directory); if (nameStatusResult.exitCode === 0) { const statusMap = new Map(); for (const line of nameStatusResult.stdout.trim().split('\n').filter(Boolean)) { const match = line.match(/^([AMDRC])\d*\t(.+)$/); if (match) { const [, status, pathPart] = match; statusMap.set(extractGitStatusPath(status, pathPart), status); } } for (const file of files) { const basePath = extractGitNumstatDestinationPath(file.path); const status = statusMap.get(basePath) ?? statusMap.get(file.path); if (status) { file.changeType = status; } } } return { files }; } export async function getCommitFileDiff( directory: string, hash: string, filePath: string, isBinary: boolean ): Promise<{ original: string; modified: string; isBinary: boolean }> { if (isBinary) { return { original: '', modified: '', isBinary: true }; } const [originalResult, modifiedResult] = await Promise.all([ execGit(['show', `${hash}^:${filePath}`], directory), execGit(['show', `${hash}:${filePath}`], directory), ]); if (originalResult.exitCode !== 0 && modifiedResult.exitCode !== 0) { throw new Error(`Failed to read file content at commit ${hash}`); } return { original: originalResult.exitCode === 0 ? originalResult.stdout : '', modified: modifiedResult.exitCode === 0 ? modifiedResult.stdout : '', isBinary: false, }; } // ============== Git Identity Operations ============== export interface GitIdentitySummary { userName: string | null; userEmail: string | null; sshCommand: string | null; } /** * Get current git identity for a directory */ export async function getCurrentGitIdentity(directory: string): Promise { const repo = await getRepository(directory); if (repo) { try { const userName = await repo.getConfig('user.name').catch(() => ''); const userEmail = await repo.getConfig('user.email').catch(() => ''); const sshCommand = await repo.getConfig('core.sshCommand').catch(() => ''); return { userName: userName || null, userEmail: userEmail || null, sshCommand: sshCommand || null, }; } catch (error) { console.error('[GitService] Failed to get identity:', error); } } // Fallback to raw git const nameResult = await execGit(['config', 'user.name'], directory); const emailResult = await execGit(['config', 'user.email'], directory); const sshResult = await execGit(['config', 'core.sshCommand'], directory); return { userName: nameResult.exitCode === 0 ? nameResult.stdout.trim() : null, userEmail: emailResult.exitCode === 0 ? emailResult.stdout.trim() : null, sshCommand: sshResult.exitCode === 0 ? sshResult.stdout.trim() : null, }; } /** * Escape an SSH key path for use in core.sshCommand. * Handles Windows/Unix differences and prevents command injection. */ function escapeSshKeyPath(sshKeyPath: string): string { // Validate: reject paths with characters that could enable injection // Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives) const dangerousChars = /[`$\\!"';&|<>(){}[\]*?#~]/; if (dangerousChars.test(sshKeyPath)) { throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`); } const isWindows = process.platform === 'win32'; if (isWindows) { // On Windows, Git (via MSYS/MinGW) expects Unix-style paths // Convert backslashes to forward slashes and handle drive letters let unixPath = sshKeyPath.replace(/\\/g, '/'); // Convert "C:/path" to "/c/path" for MSYS compatibility const driveMatch = unixPath.match(/^([A-Za-z]):\//); if (driveMatch) { unixPath = `/${driveMatch[1].toLowerCase()}${unixPath.slice(2)}`; } // Use single quotes for the path (prevents shell interpretation) return `'${unixPath}'`; } else { // On Unix, use single quotes and escape any single quotes in the path // Single quotes prevent all shell interpretation except for single quotes themselves const escaped = sshKeyPath.replace(/'/g, "'\\''"); return `'${escaped}'`; } } /** * Build the SSH command string for git config */ function buildSshCommand(sshKeyPath: string): string { const escapedPath = escapeSshKeyPath(sshKeyPath); return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`; } /** * Set git identity for a directory */ export async function setGitIdentity( directory: string, userName: string, userEmail: string, sshKey?: string | null, signCommits?: boolean | null, signingKey?: string | null ): Promise<{ success: boolean }> { const repo = await getRepository(directory); // Build SSH command once if needed const sshCommand = sshKey ? buildSshCommand(sshKey) : null; const shouldSignCommits = signCommits === true && typeof signingKey === 'string' && signingKey.trim().length > 0; if (repo) { try { await repo.setConfig('user.name', userName); await repo.setConfig('user.email', userEmail); if (sshCommand) { await repo.setConfig('core.sshCommand', sshCommand); } if (shouldSignCommits) { await repo.setConfig('gpg.format', 'ssh'); await repo.setConfig('user.signingkey', signingKey.trim()); await repo.setConfig('commit.gpgsign', 'true'); } return { success: true }; } catch (error) { console.error('[GitService] Failed to set identity:', error); } } // Fallback to raw git await execGit(['config', 'user.name', userName], directory); await execGit(['config', 'user.email', userEmail], directory); if (sshCommand) { await execGit(['config', 'core.sshCommand', sshCommand], directory); } if (shouldSignCommits) { await execGit(['config', 'gpg.format', 'ssh'], directory); await execGit(['config', 'user.signingkey', signingKey.trim()], directory); await execGit(['config', 'commit.gpgsign', 'true'], directory); } return { success: true }; } // ============== Remote Operations ============== export interface GitRemote { name: string; fetchUrl: string; pushUrl: string; } /** * Get list of remotes */ export async function getRemotes(directory: string): Promise { const result = await execGit(['remote', '-v'], directory); if (result.exitCode !== 0) { return []; } const remoteMap = new Map(); const lines = result.stdout.split('\n').filter(Boolean); for (const line of lines) { const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); if (match) { const [, name, url, type] = match; if (!remoteMap.has(name)) { remoteMap.set(name, { name, fetchUrl: '', pushUrl: '' }); } const remote = remoteMap.get(name)!; if (type === 'fetch') { remote.fetchUrl = url; } else { remote.pushUrl = url; } } } return Array.from(remoteMap.values()); } export async function removeRemote(directory: string, remote: string): Promise<{ success: boolean }> { const remoteName = String(remote || '').trim(); if (!remoteName) { throw new Error('Remote name is required'); } if (remoteName === 'origin') { throw new Error('Cannot remove origin remote'); } const result = await execGit(['remote', 'remove', remoteName], directory); if (result.exitCode !== 0) { throw new Error(result.stderr || result.stdout || `Failed to remove remote ${remoteName}`); } return { success: true }; } // ============== Merge & Rebase Operations ============== export interface GitMergeResult { success: boolean; conflict?: boolean; conflictFiles?: string[]; } export interface GitRebaseResult { success: boolean; conflict?: boolean; conflictFiles?: string[]; } /** * Rebase current branch onto target */ export async function rebase( directory: string, options: { onto: string } ): Promise { const result = await execGit(['rebase', options.onto], directory); if (result.exitCode === 0) { return { success: true, conflict: false }; } const output = (result.stdout + result.stderr).toLowerCase(); const isConflict = output.includes('conflict') || output.includes('could not apply') || output.includes('merge conflict'); if (isConflict) { const statusResult = await execGit(['status', '--porcelain'], directory); const conflictFiles = statusResult.stdout .split('\n') .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) .map((line) => line.slice(3).trim()); return { success: false, conflict: true, conflictFiles }; } throw new Error(result.stderr || 'Rebase failed'); } /** * Abort an in-progress rebase */ export async function abortRebase(directory: string): Promise<{ success: boolean }> { const result = await execGit(['rebase', '--abort'], directory); return { success: result.exitCode === 0 }; } /** * Merge branch into current */ export async function merge( directory: string, options: { branch: string } ): Promise { const result = await execGit(['merge', options.branch], directory); if (result.exitCode === 0) { return { success: true, conflict: false }; } const output = (result.stdout + result.stderr).toLowerCase(); const isConflict = output.includes('conflict') || output.includes('merge conflict') || output.includes('automatic merge failed'); if (isConflict) { const statusResult = await execGit(['status', '--porcelain'], directory); const conflictFiles = statusResult.stdout .split('\n') .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) .map((line) => line.slice(3).trim()); return { success: false, conflict: true, conflictFiles }; } throw new Error(result.stderr || 'Merge failed'); } /** * Abort an in-progress merge */ export async function abortMerge(directory: string): Promise<{ success: boolean }> { const result = await execGit(['merge', '--abort'], directory); return { success: result.exitCode === 0 }; } /** * Continue an in-progress rebase after conflicts are resolved */ export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { const result = await execGit(['rebase', '--continue'], directory); if (result.exitCode === 0) { return { success: true, conflict: false }; } const output = (result.stdout + result.stderr).toLowerCase(); const isConflict = output.includes('conflict') || output.includes('needs merge') || output.includes('unmerged'); if (isConflict) { const statusResult = await execGit(['status', '--porcelain'], directory); const conflictFiles = statusResult.stdout .split('\n') .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) .map((line) => line.slice(3).trim()); return { success: false, conflict: true, conflictFiles }; } throw new Error(result.stderr || 'Continue rebase failed'); } /** * Continue an in-progress merge after conflicts are resolved */ export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { // For merge, we commit after resolving conflicts const result = await execGit(['commit', '--no-edit'], directory); if (result.exitCode === 0) { return { success: true, conflict: false }; } const output = (result.stdout + result.stderr).toLowerCase(); const isConflict = output.includes('conflict') || output.includes('needs merge') || output.includes('unmerged'); if (isConflict) { const statusResult = await execGit(['status', '--porcelain'], directory); const conflictFiles = statusResult.stdout .split('\n') .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) .map((line) => line.slice(3).trim()); return { success: false, conflict: true, conflictFiles }; } throw new Error(result.stderr || 'Continue merge failed'); } // ============== Commit Actions ============== export async function checkoutCommit(directory: string, hash: string): Promise<{ success: boolean }> { if (!isValidCommitHash(hash)) { throw new Error('Invalid commit hash'); } const result = await execGit(['checkout', hash], directory); if (result.exitCode !== 0) { throw new Error(result.stderr || 'Failed to checkout commit'); } return { success: true }; } export async function cherryPick(directory: string, hash: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { if (!isValidCommitHash(hash)) { throw new Error('Invalid commit hash'); } const result = await execGit(['cherry-pick', hash], directory); if (result.exitCode === 0) { return { success: true, conflict: false }; } const output = (result.stdout + result.stderr).toLowerCase(); const isConflict = output.includes('conflict') || output.includes('patch does not apply'); if (isConflict) { const statusResult = await execGit(['status', '--porcelain'], directory); const conflictFiles = statusResult.stdout .split('\n') .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) .map((line) => line.slice(3).trim()); return { success: false, conflict: true, conflictFiles }; } throw new Error(result.stderr || 'Cherry-pick failed'); } export async function revertCommit(directory: string, hash: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { if (!isValidCommitHash(hash)) { throw new Error('Invalid commit hash'); } const result = await execGit(['revert', '--no-commit', hash], directory); if (result.exitCode === 0) { return { success: true, conflict: false }; } const output = (result.stdout + result.stderr).toLowerCase(); const isConflict = output.includes('conflict') || output.includes('revert failed'); if (isConflict) { const statusResult = await execGit(['status', '--porcelain'], directory); const conflictFiles = statusResult.stdout .split('\n') .filter((line) => line.startsWith('UU') || line.startsWith('AA') || line.startsWith('DD')) .map((line) => line.slice(3).trim()); return { success: false, conflict: true, conflictFiles }; } throw new Error(result.stderr || 'Revert failed'); } export async function resetToCommit( directory: string, hash: string, mode: 'soft' | 'mixed' | 'hard', force = false ): Promise<{ success: boolean }> { if (!isValidCommitHash(hash)) { throw new Error('Invalid commit hash'); } if (mode === 'hard' && !force) { const statusResult = await execGit(['status', '--porcelain'], directory); const isDirty = statusResult.stdout.trim().length > 0; if (isDirty) { throw new Error('Cannot hard reset: uncommitted changes in working tree. Stash or commit first, or use force.'); } } const result = await execGit(['reset', `--${mode}`, hash], directory); if (result.exitCode !== 0) { throw new Error(result.stderr || 'Reset failed'); } return { success: true }; } // ============== Worktree Validation & Canonicalization ============== /** * Resolve a path to its canonical (real) absolute path. */ async function canonicalizePath(filePath: string): Promise { try { const realPath = await fs.promises.realpath(filePath); return realPath; } catch { return path.resolve(filePath); } } /** * Validate that a directory is inside a given worktree root. */ export async function validateWorktreeDirectory( directory: string, worktreeRoot: string ): Promise<{ valid: boolean; insideWorktreeRoot: boolean; resolvedWorktreeRoot: string | null; resolvedCwd: string | null; }> { const directoryPath = normalizeDirectoryPath(directory); const rootPath = normalizeDirectoryPath(worktreeRoot); if (!directoryPath || !rootPath) { return { valid: false, insideWorktreeRoot: false, resolvedWorktreeRoot: null, resolvedCwd: null }; } const isRepo = await checkIsGitRepository(directoryPath); if (!isRepo) { return { valid: false, insideWorktreeRoot: false, resolvedWorktreeRoot: null, resolvedCwd: null }; } const resolvedCwd = await canonicalizePath(directoryPath); const resolvedRoot = await canonicalizePath(rootPath); const inside = resolvedCwd.startsWith(resolvedRoot + path.sep) || resolvedCwd === resolvedRoot; return { valid: true, insideWorktreeRoot: inside, resolvedWorktreeRoot: resolvedRoot, resolvedCwd, }; } /** * Canonicalize the worktree state for a directory, returning branch, headState, * worktreeStatus, and attentionReason (merge/rebase/cherry-pick/revert). */ export async function canonicalizeWorktreeState( directory: string ): Promise<{ worktreeRoot: string | null; cwd: string | null; branch: string | null; headState: 'branch' | 'detached' | 'unborn'; worktreeStatus: 'ready' | 'missing' | 'invalid' | 'not-a-repo'; legacy: boolean; degraded: boolean; attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; }> { const directoryPath = normalizeDirectoryPath(directory); if (!directoryPath) { return { worktreeRoot: null, cwd: null, branch: null, headState: 'detached', worktreeStatus: 'not-a-repo', legacy: false, degraded: false, attentionReason: null, }; } const isRepo = await checkIsGitRepository(directoryPath); if (!isRepo) { return { worktreeRoot: null, cwd: null, branch: null, headState: 'detached', worktreeStatus: 'not-a-repo', legacy: false, degraded: false, attentionReason: null, }; } const cwd = await canonicalizePath(directoryPath); let worktreeRoot: string | null = null; let worktreeStatus: 'ready' | 'missing' | 'invalid' = 'ready'; let headState: 'branch' | 'detached' | 'unborn' = 'branch'; let branch: string | null = null; let attentionReason: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null = null; // Resolve worktree project context (worktreeRoot) try { const context = await resolveWorktreeProjectContext(directoryPath); worktreeRoot = await canonicalizePath(context.worktreeRoot); } catch { worktreeStatus = 'invalid'; } // Resolve head state and branch try { const symbolicRef = await execGit(['symbolic-ref', '-q', 'HEAD'], directoryPath); if (symbolicRef.exitCode === 0 && symbolicRef.stdout.trim()) { headState = 'branch'; branch = cleanBranchName(symbolicRef.stdout.trim()); } else { const revParse = await execGit(['rev-parse', 'HEAD'], directoryPath); if (revParse.exitCode !== 0 || !revParse.stdout.trim()) { headState = 'unborn'; branch = null; } else { headState = 'detached'; branch = revParse.stdout.trim().slice(0, 7); } } } catch { headState = 'unborn'; branch = null; } // Detect attention reasons (merge, rebase, cherry-pick, revert) try { const mergeHead = await execGit(['rev-parse', '--verify', 'MERGE_HEAD'], directoryPath); if (mergeHead.exitCode === 0) { attentionReason = 'merge'; } else { const fsp = fs.promises; const rebaseMerge = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false); const rebaseApply = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false); if (rebaseMerge || rebaseApply) { attentionReason = 'rebase'; } else { const cherryPickHead = await fsp.stat(path.join(directoryPath, '.git', 'CHERRY_PICK_HEAD')).then(() => true).catch(() => false); const revertHead = await fsp.stat(path.join(directoryPath, '.git', 'REVERT_HEAD')).then(() => true).catch(() => false); if (cherryPickHead) attentionReason = 'cherry-pick'; else if (revertHead) attentionReason = 'revert'; } } } catch { // Status check failed — ignore } return { worktreeRoot, cwd, branch, headState, worktreeStatus, legacy: false, degraded: false, attentionReason, }; }