import simpleGit from 'simple-git'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { execFile } from 'child_process'; import { promisify } from 'util'; const fsp = fs.promises; const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; /** * Escape an SSH key path for use in core.sshCommand. * Handles Windows/Unix differences and prevents command injection. */ function escapeSshKeyPath(sshKeyPath) { const isWindows = process.platform === 'win32'; // Normalize path first on Windows (convert backslashes to forward slashes) let normalizedPath = sshKeyPath; if (isWindows) { normalizedPath = sshKeyPath.replace(/\\/g, '/'); } // Validate: reject paths with characters that could enable injection // Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives) // Note: backslash is not in this list since we've already normalized Windows paths const dangerousChars = /[`$!"';&|<>(){}[\]*?#~]/; if (dangerousChars.test(normalizedPath)) { throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`); } if (isWindows) { // On Windows, Git (via MSYS/MinGW) expects Unix-style paths // Convert "C:/path" to "/c/path" for MSYS compatibility let unixPath = normalizedPath; 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 = normalizedPath.replace(/'/g, "'\\''"); return `'${escaped}'`; } } /** * Build the SSH command string for git config */ function buildSshCommand(sshKeyPath) { const escapedPath = escapeSshKeyPath(sshKeyPath); return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`; } const isSocketPath = async (candidate) => { if (!candidate || typeof candidate !== 'string') { return false; } try { const stat = await fsp.stat(candidate); return typeof stat.isSocket === 'function' && stat.isSocket(); } catch { return false; } }; const resolveSshAuthSock = async () => { const existing = (process.env.SSH_AUTH_SOCK || '').trim(); if (existing) { return existing; } if (process.platform === 'win32') { return null; } const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh'); if (await isSocketPath(gpgSock)) { return gpgSock; } const runGpgconf = async (args) => { 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 null; }; const buildGitEnv = async () => { const env = { ...process.env }; if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) { const resolved = await resolveSshAuthSock(); if (resolved) { env.SSH_AUTH_SOCK = resolved; } } return env; }; const createGit = async (directory) => { const env = await buildGitEnv(); if (!directory) { return simpleGit({ env }); } return simpleGit({ baseDir: normalizeDirectoryPath(directory), env }); }; const normalizeDirectoryPath = (value) => { 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; }; const cleanBranchName = (branch) => { 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; }; export async function isGitRepository(directory) { const directoryPath = normalizeDirectoryPath(directory); if (!directoryPath || !fs.existsSync(directoryPath)) { return false; } const gitDir = path.join(directoryPath, '.git'); return fs.existsSync(gitDir); } export async function getGlobalIdentity() { const git = await createGit(); try { const userName = await git.getConfig('user.name', 'global').catch(() => null); const userEmail = await git.getConfig('user.email', 'global').catch(() => null); const sshCommand = await git.getConfig('core.sshCommand', 'global').catch(() => null); return { userName: userName?.value || null, userEmail: userEmail?.value || null, sshCommand: sshCommand?.value || null }; } catch (error) { console.error('Failed to get global Git identity:', error); return { userName: null, userEmail: null, sshCommand: null }; } } export async function getRemoteUrl(directory, remoteName = 'origin') { const git = await createGit(directory); try { const url = await git.remote(['get-url', remoteName]); return url?.trim() || null; } catch { return null; } } export async function getCurrentIdentity(directory) { const git = await createGit(directory); try { const userName = await git.getConfig('user.name', 'local').catch(() => git.getConfig('user.name', 'global') ); const userEmail = await git.getConfig('user.email', 'local').catch(() => git.getConfig('user.email', 'global') ); const sshCommand = await git.getConfig('core.sshCommand', 'local').catch(() => git.getConfig('core.sshCommand', 'global') ); return { userName: userName?.value || null, userEmail: userEmail?.value || null, sshCommand: sshCommand?.value || null }; } catch (error) { console.error('Failed to get current Git identity:', error); return { userName: null, userEmail: null, sshCommand: null }; } } export async function hasLocalIdentity(directory) { const git = await createGit(directory); try { const localName = await git.getConfig('user.name', 'local').catch(() => null); const localEmail = await git.getConfig('user.email', 'local').catch(() => null); return Boolean(localName?.value || localEmail?.value); } catch { return false; } } export async function setLocalIdentity(directory, profile) { const git = await createGit(directory); try { await git.addConfig('user.name', profile.userName, false, 'local'); await git.addConfig('user.email', profile.userEmail, false, 'local'); const authType = profile.authType || 'ssh'; if (authType === 'ssh' && profile.sshKey) { await git.addConfig( 'core.sshCommand', buildSshCommand(profile.sshKey), false, 'local' ); await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {}); } else if (authType === 'token' && profile.host) { await git.addConfig( 'credential.helper', 'store', false, 'local' ); await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {}); } return true; } catch (error) { console.error('Failed to set Git identity:', error); throw error; } } export async function getStatus(directory) { const directoryPath = normalizeDirectoryPath(directory); const git = await createGit(directoryPath); try { // Use -uall to show all untracked files individually, not just directories const status = await git.status(['-uall']); const [stagedStatsRaw, workingStatsRaw] = await Promise.all([ git.raw(['diff', '--cached', '--numstat']).catch(() => ''), git.raw(['diff', '--numstat']).catch(() => ''), ]); const diffStatsMap = new Map(); const accumulateStats = (raw) => { if (!raw) return; raw .split('\n') .map((line) => line.trim()) .filter(Boolean) .forEach((line) => { const parts = line.split('\t'); if (parts.length < 3) { return; } const [insertionsRaw, deletionsRaw, ...pathParts] = parts; const path = pathParts.join('\t'); if (!path) { return; } const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0; const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0; const existing = diffStatsMap.get(path) || { insertions: 0, deletions: 0 }; diffStatsMap.set(path, { insertions: existing.insertions + insertions, deletions: existing.deletions + deletions, }); }); }; accumulateStats(stagedStatsRaw); accumulateStats(workingStatsRaw); const diffStats = Object.fromEntries(diffStatsMap.entries()); const newFileStats = await Promise.all( status.files.map(async (file) => { const working = (file.working_dir || '').trim(); const indexStatus = (file.index || '').trim(); const statusCode = working || indexStatus; if (statusCode !== '?' && statusCode !== 'A') { return null; } const existing = diffStats[file.path]; if (existing && existing.insertions > 0) { return null; } const absolutePath = path.join(directoryPath, file.path); try { const stat = await fsp.stat(absolutePath); if (!stat.isFile()) { return null; } const buffer = await fsp.readFile(absolutePath); if (buffer.indexOf(0) !== -1) { return { path: file.path, insertions: existing?.insertions ?? 0, deletions: existing?.deletions ?? 0, }; } const normalized = buffer.toString('utf8').replace(/\r\n/g, '\n'); if (!normalized.length) { return { path: file.path, insertions: 0, deletions: 0, }; } const segments = normalized.split('\n'); if (normalized.endsWith('\n')) { segments.pop(); } const lineCount = segments.length; return { path: file.path, insertions: lineCount, deletions: 0, }; } catch (error) { console.warn('Failed to estimate diff stats for new file', file.path, error); return null; } }) ); for (const entry of newFileStats) { if (!entry) continue; diffStats[entry.path] = { insertions: entry.insertions, deletions: entry.deletions, }; } const selectBaseRefForUnpublished = async () => { const candidates = []; const originHead = await git .raw(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD']) .then((value) => String(value || '').trim()) .catch(() => ''); if (originHead) { // "refs/remotes/origin/main" -> "origin/main" candidates.push(originHead.replace(/^refs\/remotes\//, '')); } candidates.push('origin/main', 'origin/master', 'main', 'master'); for (const ref of candidates) { const exists = await git .raw(['rev-parse', '--verify', ref]) .then((value) => String(value || '').trim()) .catch(() => ''); if (exists) return ref; } return null; }; let tracking = status.tracking || null; let ahead = status.ahead; let behind = status.behind; // When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind. // We still want to show the number of unpublished commits to the user. if (!tracking && status.current) { const baseRef = await selectBaseRefForUnpublished(); if (baseRef) { const countRaw = await git .raw(['rev-list', '--count', `${baseRef}..HEAD`]) .then((value) => String(value || '').trim()) .catch(() => ''); const count = parseInt(countRaw, 10); if (Number.isFinite(count)) { ahead = count; behind = 0; } } } // Check for in-progress operations let mergeInProgress = null; let rebaseInProgress = null; try { // Check MERGE_HEAD for merge in progress const mergeHeadExists = await git .raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD']) .then(() => true) .catch(() => false); if (mergeHeadExists) { const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => ''); const headSha = mergeHead.trim().slice(0, 7); // Only set mergeInProgress if we actually have a valid head SHA if (headSha) { const mergeMsg = await fsp.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8').catch(() => ''); 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 fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false); const rebaseApplyExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false); if (rebaseMergeExists || rebaseApplyExists) { const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply'; const headName = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'head-name'), 'utf8').catch(() => ''); const onto = await fsp.readFile(path.join(directoryPath, '.git', 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) { rebaseInProgress = { headName: headNameTrimmed, onto: ontoTrimmed, }; } } } catch { // ignore } return { current: status.current, tracking, ahead, behind, files: status.files.map((f) => ({ path: f.path, index: f.index, working_dir: f.working_dir, })), isClean: status.isClean(), diffStats, mergeInProgress, rebaseInProgress, }; } catch (error) { console.error('Failed to get Git status:', error); throw error; } } export async function getDiff(directory, { path, staged = false, contextLines = 3 } = {}) { const git = await createGit(directory); try { const args = ['diff', '--no-color']; if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { args.push(`-U${Math.max(0, contextLines)}`); } if (staged) { args.push('--cached'); } if (path) { args.push('--', path); } const diff = await git.raw(args); if (diff && diff.trim().length > 0) { return diff; } if (staged) { return diff; } try { await git.raw(['ls-files', '--error-unmatch', path]); return diff; } catch { const noIndexArgs = ['diff', '--no-color']; if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { noIndexArgs.push(`-U${Math.max(0, contextLines)}`); } noIndexArgs.push('--no-index', '--', '/dev/null', path); try { const noIndexDiff = await git.raw(noIndexArgs); return noIndexDiff; } catch (noIndexError) { // git diff --no-index returns exit code 1 when differences exist (not a real error) if (noIndexError.exitCode === 1 && noIndexError.message) { return noIndexError.message; } throw noIndexError; } } } catch (error) { console.error('Failed to get Git diff:', error); throw error; } } export async function getRangeDiff(directory, { base, head, path, contextLines = 3 } = {}) { const git = await createGit(directory); const baseRef = typeof base === 'string' ? base.trim() : ''; const headRef = typeof head === 'string' ? head.trim() : ''; if (!baseRef || !headRef) { throw new Error('base and head are required'); } // Prefer remote-tracking base ref so merged commits don't reappear // when local base branch is stale (common when user stays on feature branch). let resolvedBase = baseRef; const originCandidate = `refs/remotes/origin/${baseRef}`; try { const verified = await git.raw(['rev-parse', '--verify', originCandidate]); if (verified && verified.trim()) { resolvedBase = `origin/${baseRef}`; } } catch { // ignore } const args = ['diff', '--no-color']; if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { args.push(`-U${Math.max(0, contextLines)}`); } args.push(`${resolvedBase}...${headRef}`); if (path) { args.push('--', path); } const diff = await git.raw(args); return diff; } export async function getRangeFiles(directory, { base, head } = {}) { const git = await createGit(directory); const baseRef = typeof base === 'string' ? base.trim() : ''; const headRef = typeof head === 'string' ? head.trim() : ''; if (!baseRef || !headRef) { throw new Error('base and head are required'); } let resolvedBase = baseRef; const originCandidate = `refs/remotes/origin/${baseRef}`; try { const verified = await git.raw(['rev-parse', '--verify', originCandidate]); if (verified && verified.trim()) { resolvedBase = `origin/${baseRef}`; } } catch { // ignore } const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]); return String(raw || '') .split('\n') .map((l) => l.trim()) .filter(Boolean); } const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif']; function isImageFile(filePath) { const ext = filePath.split('.').pop()?.toLowerCase(); return IMAGE_EXTENSIONS.includes(ext || ''); } function getImageMimeType(filePath) { const ext = filePath.split('.').pop()?.toLowerCase(); const mimeMap = { 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'svg': 'image/svg+xml', 'webp': 'image/webp', 'ico': 'image/x-icon', 'bmp': 'image/bmp', 'avif': 'image/avif', }; return mimeMap[ext] || 'application/octet-stream'; } export async function getFileDiff(directory, { path: filePath, staged = false } = {}) { if (!directory || !filePath) { throw new Error('directory and path are required for getFileDiff'); } const directoryPath = normalizeDirectoryPath(directory); const git = await createGit(directoryPath); const isImage = isImageFile(filePath); const mimeType = isImage ? getImageMimeType(filePath) : null; let original = ''; try { if (isImage) { // For images, use git show with raw output and convert to base64 try { const { stdout } = await execFileAsync('git', ['show', `HEAD:${filePath}`], { cwd: directoryPath, encoding: 'buffer', maxBuffer: 50 * 1024 * 1024, // 50MB max }); if (stdout && stdout.length > 0) { original = `data:${mimeType};base64,${stdout.toString('base64')}`; } } catch { original = ''; } } else { original = await git.show([`HEAD:${filePath}`]); } } catch { original = ''; } const fullPath = path.join(directoryPath, filePath); let modified = ''; try { const stat = await fsp.stat(fullPath); if (stat.isFile()) { if (isImage) { // For images, read as binary and convert to data URL const buffer = await fsp.readFile(fullPath); modified = `data:${mimeType};base64,${buffer.toString('base64')}`; } else { modified = await fsp.readFile(fullPath, 'utf8'); } } } catch (error) { if (error && typeof error === 'object' && error.code === 'ENOENT') { modified = ''; } else { console.error('Failed to read modified file contents for diff:', error); throw error; } } return { original, modified, path: filePath, }; } export async function revertFile(directory, filePath) { const directoryPath = normalizeDirectoryPath(directory); const git = await createGit(directoryPath); const repoRoot = path.resolve(directoryPath); const absoluteTarget = path.resolve(repoRoot, filePath); if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) { throw new Error('Invalid file path'); } const isTracked = await git .raw(['ls-files', '--error-unmatch', filePath]) .then(() => true) .catch(() => false); if (!isTracked) { try { await git.raw(['clean', '-f', '-d', '--', filePath]); return; } catch (cleanError) { try { await fsp.rm(absoluteTarget, { recursive: true, force: true }); return; } catch (fsError) { if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') { return; } console.error('Failed to remove untracked file during revert:', fsError); throw fsError; } } } try { await git.raw(['restore', '--staged', filePath]); } catch (error) { await git.raw(['reset', 'HEAD', '--', filePath]).catch(() => {}); } try { await git.raw(['restore', filePath]); } catch (error) { try { await git.raw(['checkout', '--', filePath]); } catch (fallbackError) { console.error('Failed to revert git file:', fallbackError); throw fallbackError; } } } export async function collectDiffs(directory, files = []) { const results = []; for (const filePath of files) { try { const diff = await getDiff(directory, { path: filePath }); if (diff && diff.trim().length > 0) { results.push({ path: filePath, diff }); } } catch (error) { console.error(`Failed to diff ${filePath}:`, error); } } return results; } export async function pull(directory, options = {}) { const git = await createGit(directory); try { const result = await git.pull( options.remote || 'origin', options.branch, options.options || {} ); return { success: true, summary: result.summary, files: result.files, insertions: result.insertions, deletions: result.deletions }; } catch (error) { console.error('Failed to pull:', error); throw error; } } export async function push(directory, options = {}) { const git = await createGit(directory); const buildUpstreamOptions = (raw) => { if (Array.isArray(raw)) { return raw.includes('--set-upstream') ? raw : [...raw, '--set-upstream']; } if (raw && typeof raw === 'object') { return { ...raw, '--set-upstream': null }; } return ['--set-upstream']; }; const looksLikeMissingUpstream = (error) => { const message = String(error?.message || error?.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 normalizePushResult = (result) => { return { success: true, pushed: result.pushed, repo: result.repo, ref: result.ref, }; }; const remote = options.remote || 'origin'; // If caller didn't specify a branch, this is the common "Push"/"Commit & Push" path. // When there's no upstream yet (typical for freshly-created worktree branches), publish it on first push. if (!options.branch) { try { const status = await git.status(); if (status.current && !status.tracking) { const result = await git.push(remote, status.current, buildUpstreamOptions(options.options)); return normalizePushResult(result); } } catch (error) { // If we can't read status, fall back to the regular push path below. console.warn('Failed to read git status before push:', error); } } try { const result = await git.push(remote, options.branch, options.options || {}); return normalizePushResult(result); } catch (error) { // Last-resort fallback: retry with upstream if the error suggests it's missing. if (!looksLikeMissingUpstream(error)) { console.error('Failed to push:', error); throw error; } try { const status = await git.status(); const branch = options.branch || status.current; if (!branch) { console.error('Failed to push: missing branch name for upstream setup:', error); throw error; } const result = await git.push(remote, branch, buildUpstreamOptions(options.options)); return normalizePushResult(result); } catch (fallbackError) { console.error('Failed to push (including upstream fallback):', fallbackError); throw fallbackError; } } } export async function deleteRemoteBranch(directory, options = {}) { const { branch, remote } = options; if (!branch) { throw new Error('branch is required to delete remote branch'); } const git = await createGit(directory); const targetBranch = branch.startsWith('refs/heads/') ? branch.substring('refs/heads/'.length) : branch; const remoteName = remote || 'origin'; try { await git.push(remoteName, `:${targetBranch}`); return { success: true }; } catch (error) { console.error('Failed to delete remote branch:', error); throw error; } } export async function fetch(directory, options = {}) { const git = await createGit(directory); try { await git.fetch( options.remote || 'origin', options.branch, options.options || {} ); return { success: true }; } catch (error) { console.error('Failed to fetch:', error); throw error; } } export async function commit(directory, message, options = {}) { const git = await createGit(directory); try { if (options.addAll) { await git.add('.'); } else if (Array.isArray(options.files) && options.files.length > 0) { await git.add(options.files); } const commitArgs = !options.addAll && Array.isArray(options.files) && options.files.length > 0 ? options.files : undefined; const result = await git.commit(message, commitArgs); return { success: true, commit: result.commit, branch: result.branch, summary: result.summary }; } catch (error) { console.error('Failed to commit:', error); throw error; } } export async function getBranches(directory) { const git = await createGit(directory); try { const result = await git.branch(); const allBranches = result.all; const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/')); const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches); const filteredAll = [ ...allBranches.filter(branch => !branch.startsWith('remotes/')), ...activeRemoteBranches ]; return { all: filteredAll, current: result.current, branches: result.branches }; } catch (error) { console.error('Failed to get branches:', error); throw error; } } async function filterActiveRemoteBranches(git, remoteBranches) { try { const lsRemoteResult = await git.raw(['ls-remote', '--heads', 'origin']); const actualRemoteBranches = new Set(); const lines = lsRemoteResult.trim().split('\n'); for (const line of lines) { if (line.includes('\trefs/heads/')) { const branchName = line.split('\t')[1].replace('refs/heads/', ''); actualRemoteBranches.add(branchName); } } return remoteBranches.filter(remoteBranch => { const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/); if (!match) return false; const branchName = match[1]; return actualRemoteBranches.has(branchName); }); } catch (error) { console.warn('Failed to filter active remote branches, returning all:', error.message); return remoteBranches; } } export async function createBranch(directory, branchName, options = {}) { const git = await createGit(directory); try { await git.checkoutBranch(branchName, options.startPoint || 'HEAD'); return { success: true, branch: branchName }; } catch (error) { console.error('Failed to create branch:', error); throw error; } } export async function checkoutBranch(directory, branchName) { const git = await createGit(directory); try { await git.checkout(branchName); return { success: true, branch: branchName }; } catch (error) { console.error('Failed to checkout branch:', error); throw error; } } export async function getWorktrees(directory) { const directoryPath = normalizeDirectoryPath(directory); if (!directoryPath || !fs.existsSync(directoryPath) || !fs.existsSync(path.join(directoryPath, '.git'))) { return []; } const git = await createGit(directoryPath); try { const result = await git.raw(['worktree', 'list', '--porcelain']); const worktrees = []; const lines = result.split('\n'); let current = {}; for (const line of lines) { if (line.startsWith('worktree ')) { if (current.worktree) { worktrees.push(current); } current = { worktree: line.substring(9) }; } else if (line.startsWith('HEAD ')) { current.head = line.substring(5); } else if (line.startsWith('branch ')) { current.branch = cleanBranchName(line.substring(7)); } else if (line === '') { if (current.worktree) { worktrees.push(current); current = {}; } } } if (current.worktree) { worktrees.push(current); } return worktrees; } catch (error) { console.warn('Failed to list worktrees, returning empty list:', error?.message || error); return []; } } export async function deleteBranch(directory, branch, options = {}) { const git = await createGit(directory); try { const branchName = branch.startsWith('refs/heads/') ? branch.substring('refs/heads/'.length) : branch; const args = ['branch', options.force ? '-D' : '-d', branchName]; await git.raw(args); return { success: true }; } catch (error) { console.error('Failed to delete branch:', error); throw error; } } export async function getLog(directory, options = {}) { const git = await createGit(directory); try { const maxCount = options.maxCount || 50; const baseLog = await git.log({ maxCount, from: options.from, to: options.to, file: options.file }); const logArgs = [ 'log', `--max-count=${maxCount}`, '--date=iso', '--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e', '--shortstat' ]; if (options.from && options.to) { logArgs.push(`${options.from}..${options.to}`); } else if (options.from) { logArgs.push(`${options.from}..HEAD`); } else if (options.to) { logArgs.push(options.to); } if (options.file) { logArgs.push('--', options.file); } const rawLog = await git.raw(logArgs); const records = rawLog .split('\x1e') .map((entry) => entry.trim()) .filter(Boolean); const statsMap = new Map(); records.forEach((record) => { const lines = record.split('\n').filter((line) => line.trim().length > 0); const header = lines.shift() || ''; const [hash] = header.split('\x1f'); if (!hash) { return; } let filesChanged = 0; let insertions = 0; let deletions = 0; lines.forEach((line) => { 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 }); }); const merged = baseLog.all.map((entry) => { const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0 }; return { hash: entry.hash, date: entry.date, message: entry.message, refs: entry.refs || '', body: entry.body || '', author_name: entry.author_name, author_email: entry.author_email, filesChanged: stats.filesChanged, insertions: stats.insertions, deletions: stats.deletions }; }); return { all: merged, latest: merged[0] || null, total: baseLog.total }; } catch (error) { console.error('Failed to get log:', error); throw error; } } export async function isLinkedWorktree(directory) { const git = await createGit(directory); try { const [gitDir, gitCommonDir] = await Promise.all([ git.raw(['rev-parse', '--git-dir']).then((output) => output.trim()), git.raw(['rev-parse', '--git-common-dir']).then((output) => output.trim()) ]); return gitDir !== gitCommonDir; } catch (error) { console.error('Failed to determine worktree type:', error); return false; } } export async function getCommitFiles(directory, commitHash) { const git = await createGit(directory); try { const numstatRaw = await git.raw([ 'show', '--numstat', '--format=', commitHash ]); const files = []; const lines = numstatRaw.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 insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0; const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0; const isBinary = insertionsRaw === '-' && deletionsRaw === '-'; let changeType = 'M'; let displayPath = filePath; if (filePath.includes(' => ')) { changeType = 'R'; const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/); if (match) { displayPath = filePath; } } files.push({ path: displayPath, insertions, deletions, isBinary, changeType }); } const nameStatusRaw = await git.raw([ 'show', '--name-status', '--format=', commitHash ]).catch(() => ''); const statusMap = new Map(); const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean); for (const line of statusLines) { const match = line.match(/^([AMDRC])\d*\t(.+)$/); if (match) { const [, status, path] = match; statusMap.set(path, status); } } for (const file of files) { const basePath = file.path.includes(' => ') ? file.path.split(' => ').pop()?.replace(/[{}]/g, '') || file.path : file.path; const status = statusMap.get(basePath) || statusMap.get(file.path); if (status) { file.changeType = status; } } return { files }; } catch (error) { console.error('Failed to get commit files:', error); throw error; } } export async function renameBranch(directory, oldName, newName) { const git = await createGit(directory); try { // Use git branch -m command to rename the branch await git.raw(['branch', '-m', oldName, newName]); return { success: true, branch: newName }; } catch (error) { console.error('Failed to rename branch:', error); throw error; } } export async function getRemotes(directory) { const git = await createGit(directory); try { const remotes = await git.getRemotes(true); return remotes.map((remote) => ({ name: remote.name, fetchUrl: remote.refs.fetch, pushUrl: remote.refs.push })); } catch (error) { console.error('Failed to get remotes:', error); throw error; } } export async function rebase(directory, options = {}) { const git = await createGit(directory); try { const { onto } = options; if (!onto) { throw new Error('onto parameter is required for rebase'); } await git.rebase([onto]); return { success: true, conflict: false }; } catch (error) { const errorMessage = String(error?.message || error || '').toLowerCase(); const isConflict = errorMessage.includes('conflict') || errorMessage.includes('could not apply') || errorMessage.includes('merge conflict'); if (isConflict) { // Get list of conflicted files const status = await git.status().catch(() => ({ conflicted: [] })); return { success: false, conflict: true, conflictFiles: status.conflicted || [] }; } console.error('Failed to rebase:', error); throw error; } } export async function abortRebase(directory) { const git = await createGit(directory); try { await git.rebase(['--abort']); return { success: true }; } catch (error) { console.error('Failed to abort rebase:', error); throw error; } } export async function merge(directory, options = {}) { const git = await createGit(directory); try { const { branch } = options; if (!branch) { throw new Error('branch parameter is required for merge'); } await git.merge([branch]); return { success: true, conflict: false }; } catch (error) { const errorMessage = String(error?.message || error || '').toLowerCase(); const isConflict = errorMessage.includes('conflict') || errorMessage.includes('merge conflict') || errorMessage.includes('automatic merge failed'); if (isConflict) { // Get list of conflicted files const status = await git.status().catch(() => ({ conflicted: [] })); return { success: false, conflict: true, conflictFiles: status.conflicted || [] }; } console.error('Failed to merge:', error); throw error; } } export async function abortMerge(directory) { const git = await createGit(directory); try { await git.merge(['--abort']); return { success: true }; } catch (error) { console.error('Failed to abort merge:', error); throw error; } } export async function continueRebase(directory) { const directoryPath = normalizeDirectoryPath(directory); const git = await createGit(directoryPath); try { // Set GIT_EDITOR to prevent editor prompts await git.env('GIT_EDITOR', 'true').rebase(['--continue']); return { success: true, conflict: false }; } catch (error) { const errorMessage = String(error?.message || error || '').toLowerCase(); const isConflict = errorMessage.includes('conflict') || errorMessage.includes('needs merge') || errorMessage.includes('unmerged') || errorMessage.includes('fix conflicts'); if (isConflict) { const status = await git.status().catch(() => ({ conflicted: [] })); return { success: false, conflict: true, conflictFiles: status.conflicted || [] }; } // Check for "nothing to commit" which means rebase step is complete if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes')) { // Skip this commit and continue try { await git.env('GIT_EDITOR', 'true').rebase(['--skip']); return { success: true, conflict: false }; } catch { // If skip also fails, the rebase may be complete return { success: true, conflict: false }; } } console.error('Failed to continue rebase:', error); throw error; } } export async function continueMerge(directory) { const directoryPath = normalizeDirectoryPath(directory); const git = await createGit(directoryPath); try { // Check if there are still unmerged files const status = await git.status(); if (status.conflicted && status.conflicted.length > 0) { return { success: false, conflict: true, conflictFiles: status.conflicted }; } // For merge, we commit after resolving conflicts // Use --no-edit to use the default merge commit message await git.env('GIT_EDITOR', 'true').commit([], { '--no-edit': null }); return { success: true, conflict: false }; } catch (error) { const errorMessage = String(error?.message || error || '').toLowerCase(); const isConflict = errorMessage.includes('conflict') || errorMessage.includes('needs merge') || errorMessage.includes('unmerged') || errorMessage.includes('fix conflicts'); if (isConflict) { const status = await git.status().catch(() => ({ conflicted: [] })); return { success: false, conflict: true, conflictFiles: status.conflicted || [] }; } // "nothing to commit" can happen if all conflicts resolved to one side if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes added')) { // The merge is effectively complete (all changes already committed or no changes needed) return { success: true, conflict: false }; } console.error('Failed to continue merge:', error); throw error; } } export async function getConflictDetails(directory) { const directoryPath = normalizeDirectoryPath(directory); const git = await createGit(directoryPath); try { // Get git status --porcelain const statusPorcelain = await git.raw(['status', '--porcelain']).catch(() => ''); // Get unmerged files const unmergedFilesRaw = await git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => ''); const unmergedFiles = unmergedFilesRaw .split('\n') .map((line) => line.trim()) .filter(Boolean); // Get current diff const diff = await git.raw(['diff']).catch(() => ''); // Detect operation type and get head info let operation = 'merge'; let headInfo = ''; // Check for MERGE_HEAD (merge in progress) const mergeHeadExists = await git .raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD']) .then(() => true) .catch(() => false); if (mergeHeadExists) { operation = 'merge'; const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => ''); const mergeMsg = await fsp .readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8') .catch(() => ''); headInfo = `MERGE_HEAD: ${mergeHead.trim()}\n${mergeMsg}`; } else { // Check for REBASE_HEAD (rebase in progress) const rebaseHeadExists = await git .raw(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD']) .then(() => true) .catch(() => false); if (rebaseHeadExists) { operation = 'rebase'; const rebaseHead = await git.raw(['rev-parse', 'REBASE_HEAD']).catch(() => ''); headInfo = `REBASE_HEAD: ${rebaseHead.trim()}`; } } return { statusPorcelain: statusPorcelain.trim(), unmergedFiles, diff: diff.trim(), headInfo: headInfo.trim(), operation, }; } catch (error) { console.error('Failed to get conflict details:', error); throw error; } } // ============== Stash Operations ============== export async function stash(directory, options = {}) { const git = await createGit(directory); try { const args = ['stash', 'push']; // Include untracked files by default if (options.includeUntracked !== false) { args.push('--include-untracked'); } if (options.message) { args.push('-m', options.message); } await git.raw(args); return { success: true }; } catch (error) { console.error('Failed to stash:', error); throw error; } } export async function stashPop(directory) { const git = await createGit(directory); try { await git.raw(['stash', 'pop']); return { success: true }; } catch (error) { console.error('Failed to pop stash:', error); throw error; } }