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'; import { createRequire } from 'module'; const fsp = fs.promises; const require = createRequire(import.meta.url); const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; let resolvedGitBinary = null; const worktreeBootstrapState = new Map(); const activeWorktreeBootstrapTasks = new Map(); const remoteExistenceCache = new Map(); const SIMPLE_GIT_SAFE_BINARY_PATTERN = /^([a-z]:)?([a-z0-9/.\\_~-]+)$/i; const SIMPLE_GIT_UNSAFE_BINARY_WARNING = 'Invalid value supplied for custom binary, restricted characters must be removed'; const REMOTE_EXISTENCE_CACHE_TTL_MS = 30_000; const gitIndexMutationQueues = new Map(); const WORKTREE_BOOTSTRAP_PENDING = 'pending'; const WORKTREE_BOOTSTRAP_READY = 'ready'; const WORKTREE_BOOTSTRAP_FAILED = 'failed'; const WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED = 'directory-created'; const WORKTREE_BOOTSTRAP_PHASE_GIT_READY = 'git-ready'; const WORKTREE_BOOTSTRAP_PHASE_SETUP_READY = 'setup-ready'; const GIT_NULL_REF = '0'.repeat(40); const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250; const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750; const toBootstrapStateKey = (directory) => { const normalized = normalizeDirectoryPath(directory); if (!normalized) { return ''; } return path.resolve(normalized); }; const createWorktreeBootstrapState = (status, phase, error = null) => ({ status, phase, error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null, updatedAt: Date.now(), }); const setWorktreeBootstrapState = (directory, status, phase, error = null) => { const key = toBootstrapStateKey(directory); if (!key) { return null; } const state = createWorktreeBootstrapState(status, phase, error); worktreeBootstrapState.set(key, state); return state; }; const clearWorktreeBootstrapState = (directory) => { const key = toBootstrapStateKey(directory); if (!key) { return; } worktreeBootstrapState.delete(key); }; const trackWorktreeBootstrapTask = (directory, task) => { const key = toBootstrapStateKey(directory); if (!key) { return task; } activeWorktreeBootstrapTasks.set(key, task); const clearTask = () => { if (activeWorktreeBootstrapTasks.get(key) === task) { activeWorktreeBootstrapTasks.delete(key); } }; void task.then(clearTask, clearTask); return task; }; const waitForActiveWorktreeBootstrap = async (directory) => { const key = toBootstrapStateKey(directory); if (!key) { return; } while (true) { const task = activeWorktreeBootstrapTasks.get(key); if (!task) { return; } await task.catch(() => undefined); } }; const isExecutableFile = (candidate) => { if (typeof candidate !== 'string' || candidate.trim().length === 0) { return false; } try { const stat = fs.statSync(candidate); if (!stat.isFile()) { return false; } if (process.platform === 'win32') { const ext = path.extname(candidate).toLowerCase(); return ext.length === 0 || ext === '.exe' || ext === '.cmd' || ext === '.bat' || ext === '.com'; } fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { return false; } }; const normalizeGitExecutableCandidate = (candidate) => { if (typeof candidate !== 'string') { return null; } const trimmed = candidate.trim(); if (!trimmed) { return null; } const ext = path.extname(trimmed).toLowerCase(); if (ext === '.cmd' || ext === '.bat' || ext === '.com') { const exeCandidate = trimmed.slice(0, -ext.length) + '.exe'; if (isExecutableFile(exeCandidate)) { return exeCandidate; } } return trimmed; }; const isSafeSimpleGitBinary = (candidate) => ( typeof candidate === 'string' && SIMPLE_GIT_SAFE_BINARY_PATTERN.test(candidate) ); const createSimpleGit = (options) => { if (!options?.unsafe?.allowUnsafeCustomBinary) { return simpleGit(options); } const originalWarn = console.warn; console.warn = (...args) => { if (String(args[0] || '').includes(SIMPLE_GIT_UNSAFE_BINARY_WARNING)) { return; } originalWarn(...args); }; try { return simpleGit(options); } finally { console.warn = originalWarn; } }; const listPathExecutableCandidates = (binaryName) => { const currentPath = process.env.PATH || ''; const seen = new Set(); const matches = []; for (const segment of currentPath.split(path.delimiter)) { const dir = typeof segment === 'string' ? segment.trim() : ''; if (!dir || seen.has(dir)) { continue; } seen.add(dir); matches.push(path.join(dir, binaryName)); } return matches; }; const listWindowsGitInstallCandidates = () => { const roots = [ process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LocalAppData, ] .map((value) => (typeof value === 'string' ? value.trim() : '')) .filter(Boolean); const candidates = []; for (const root of roots) { candidates.push(path.join(root, 'Git', 'cmd', 'git.exe')); candidates.push(path.join(root, 'Git', 'bin', 'git.exe')); candidates.push(path.join(root, 'Git', 'mingw64', 'bin', 'git.exe')); candidates.push(path.join(root, 'Programs', 'Git', 'cmd', 'git.exe')); candidates.push(path.join(root, 'Programs', 'Git', 'bin', 'git.exe')); } return candidates; }; const resolveGitBinary = () => { if (process.platform !== 'win32') { return 'git'; } if (resolvedGitBinary) { return resolvedGitBinary; } const explicit = [process.env.GIT_BINARY, process.env.OPENCHAMBER_GIT_BINARY] .map((value) => (typeof value === 'string' ? value.trim() : '')) .filter(Boolean); for (const candidate of explicit) { const normalized = normalizeGitExecutableCandidate(candidate); if (isExecutableFile(normalized)) { resolvedGitBinary = normalized; return resolvedGitBinary; } } const pathDiscovered = [ ...listPathExecutableCandidates('git.exe'), ...listPathExecutableCandidates('git'), ] .map(normalizeGitExecutableCandidate) .filter(Boolean) .filter((candidate) => isExecutableFile(candidate)); if (pathDiscovered.length > 0) { resolvedGitBinary = 'git'; return resolvedGitBinary; } const discovered = [ ...listWindowsGitInstallCandidates(), ] .map(normalizeGitExecutableCandidate) .filter(Boolean) .filter((candidate) => isExecutableFile(candidate)); const preferredExe = discovered.find((candidate) => isSafeSimpleGitBinary(candidate) && candidate.toLowerCase().endsWith('.exe')) || discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe')); resolvedGitBinary = preferredExe || discovered[0] || 'git.exe'; return resolvedGitBinary; }; const getGitBinary = () => resolveGitBinary(); /** * 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, { allowUnsafeSshCommand = false } = {}) => { const env = await buildGitEnv(); const spawnOptions = { windowsHide: true }; const binary = getGitBinary(); const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe'; const unsafe = hasCustomBinary || allowUnsafeSshCommand ? { ...(hasCustomBinary && { allowUnsafeCustomBinary: true }), ...(allowUnsafeSshCommand && { allowUnsafeSshCommand: true }), } : undefined; // Always pin simple-git to an explicit working directory. Omitting baseDir // makes simple-git use process.cwd(), which breaks when the OpenChamber // server was launched from a neutral directory (e.g. $HOME) and the opened // project lives elsewhere — session/project discovery then sees spurious // "not a git repository" errors and can abort enumeration. const baseDir = normalizeDirectoryPath(directory); if (typeof baseDir !== 'string' || !baseDir.trim()) { throw new Error('Git directory is required'); } return createSimpleGit({ baseDir, env, spawnOptions, binary, unsafe, }); }; // Global config reads do not need a repository; use the home directory as a // stable baseDir so we never accidentally inherit process.cwd(). const createGitForGlobalConfig = async () => createGit(os.homedir()); 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 normalizePath = (value) => { const normalized = normalizeDirectoryPath(value); if (typeof normalized !== 'string') { return normalized; } return normalized.replace(/\\/g, '/'); }; const getGitIndexMutationQueueKey = (directory) => { const normalized = normalizeDirectoryPath(directory); if (!normalized) { return ''; } return path.resolve(normalized); }; const withGitIndexMutationQueue = async (directory, task) => { let key = getGitIndexMutationQueueKey(directory); try { const directoryPath = normalizeDirectoryPath(directory); if (directoryPath) { const git = await createGit(directoryPath); key = await resolveGitRepositoryRoot(directoryPath, git); } } catch { // Fall back to the normalized directory key when the repo root is unavailable. } if (!key) { return task(); } const previous = gitIndexMutationQueues.get(key) || Promise.resolve(); const current = previous.catch(() => {}).then(task); const tail = current.catch(() => {}); gitIndexMutationQueues.set(key, tail); try { return await current; } finally { if (gitIndexMutationQueues.get(key) === tail) { gitIndexMutationQueues.delete(key); } } }; const normalizeFilePathList = (paths) => Array.from(new Set( (Array.isArray(paths) ? paths : [paths]) .map((value) => String(value || '').trim()) .filter(Boolean) )); const validateRepositoryFilePaths = (directoryPath, filePaths) => { const repoRoot = path.resolve(directoryPath); for (const filePath of filePaths) { const absoluteTarget = path.resolve(repoRoot, filePath); if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) { throw new Error(`Path is outside repository: ${filePath}`); } } }; const toGitPath = (value) => value.replace(/\\/g, '/'); const isInsideOrSameDirectory = (root, target) => { const relative = path.relative(root, target); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); }; const resolveGitRepositoryRoot = async (directoryPath, git) => { const topLevel = await git.raw(['rev-parse', '--show-toplevel']); const normalizedTopLevel = topLevel.trim(); return path.isAbsolute(normalizedTopLevel) ? path.resolve(normalizedTopLevel) : path.resolve(directoryPath, normalizedTopLevel); }; const createRepositoryGitContext = async (directory) => { const directoryPath = normalizeDirectoryPath(directory); if (typeof directoryPath !== 'string' || !directoryPath.trim()) { throw new Error('Git directory is required'); } const directoryGit = await createGit(directoryPath); const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit); const git = path.resolve(directoryPath) === repoRoot ? directoryGit : await createGit(repoRoot); return { directoryPath, directoryGit, repoRoot, git }; }; /** * Absolute repository root for a directory anywhere inside it. Callers that key * persisted data by repository need this so two directories in the same * repository do not address different records. */ export async function getRepositoryRoot(directory) { const { repoRoot } = await createRepositoryGitContext(directory); return repoRoot; } const resolveGitInternalPath = async (repoRoot, git, gitPath) => { const resolved = await git.raw(['rev-parse', '--git-path', gitPath]); return path.resolve(repoRoot, resolved.trim()); }; const resolveGitFileContext = async (directoryPath, git, filePath, repoRootOverride = null) => { const repoRoot = repoRootOverride || await resolveGitRepositoryRoot(directoryPath, git); const candidates = Array.from(new Set([ path.resolve(repoRoot, filePath), path.resolve(directoryPath, filePath), ])); for (const absolutePath of candidates) { if (!isInsideOrSameDirectory(repoRoot, absolutePath)) { continue; } const repoPath = toGitPath(path.relative(repoRoot, absolutePath)); const worktreeEntry = await fsp.lstat(absolutePath).catch(() => null); const isSymbolicLink = worktreeEntry?.isSymbolicLink() ?? false; const existsInWorktree = worktreeEntry?.isFile() || isSymbolicLink; const existsInIndex = await git.raw(['cat-file', '-e', `:${repoPath}`]).then(() => true).catch(() => false); const existsInHead = await git.raw(['cat-file', '-e', `HEAD:${repoPath}`]).then(() => true).catch(() => false); if (existsInWorktree || existsInIndex || existsInHead) { return { absolutePath, repoPath, repoRoot, isSymbolicLink, }; } } throw new Error('Invalid file path'); }; 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; }; 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) => values[Math.floor(Math.random() * values.length)]; const generateOpenCodeRandomName = () => `${pickRandom(OPENCODE_ADJECTIVES)}-${pickRandom(OPENCODE_NOUNS)}`; const slugWorktreeName = (value) => { 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) => { const lines = String(raw || '').split('\n').map((line) => line.trim()); const entries = []; let current = 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) => { const absolutePath = path.resolve(input); const realPath = await fsp.realpath(absolutePath).catch(() => absolutePath); const normalized = path.normalize(realPath); return process.platform === 'win32' ? normalized.toLowerCase() : normalized; }; const checkPathExists = async (targetPath) => { try { await fsp.stat(targetPath); return true; } catch { return false; } }; const normalizeStartRef = (value) => { const trimmed = String(value || '').trim(); if (!trimmed) { return 'HEAD'; } return trimmed; }; function isValidCommitHash(hash) { return typeof hash === 'string' && /^[0-9a-fA-F]{7,40}$/.test(hash); } const parseRemoteBranchRef = (value) => { 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, value) => { 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, branch) => { const remoteName = String(remote || '').trim(); const branchName = String(branch || '').trim(); if (!remoteName || !branchName) { return null; } return { remote: remoteName, branch: branchName, full: `${remoteName}/${branchName}`, }; }; const parseGitErrorText = (error) => { const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; const message = typeof error?.message === 'string' ? error.message : ''; // Some runtimes (notably Bun + simple-git GitError) surface the fatal text // primarily via message/toString; keep String(error) as a last resort so // "not a git repository" matching never misses and aborts callers. const fallback = !message && error != null ? String(error) : ''; return [stderr, stdout, message, fallback] .map((chunk) => String(chunk || '').trim()) .filter(Boolean) .join('\n') .trim(); }; const parseAheadBehindCounts = (value) => { const [aheadRaw, behindRaw] = String(value || '').trim().split(/\s+/); const ahead = parseInt(aheadRaw, 10); const behind = parseInt(behindRaw, 10); if (!Number.isFinite(ahead) || !Number.isFinite(behind)) { return null; } return { ahead, behind }; }; const getRemoteExistenceCacheKey = (directory, remoteName) => { const normalizedDirectory = normalizeDirectoryPath(directory) || ''; return `${path.resolve(normalizedDirectory)}\0${remoteName}`; }; const hasRemote = async (git, directory, remoteName) => { const remote = String(remoteName || '').trim(); if (!remote) { return false; } const key = getRemoteExistenceCacheKey(directory, remote); const cached = remoteExistenceCache.get(key); if (cached && Date.now() - cached.checkedAt < REMOTE_EXISTENCE_CACHE_TTL_MS) { return cached.exists; } const exists = await git .raw(['remote', 'get-url', remote]) .then((value) => String(value || '').trim().length > 0) .catch(() => false); remoteExistenceCache.set(key, { exists, checkedAt: Date.now() }); return exists; }; const buildRawGitOptions = (raw) => { if (Array.isArray(raw)) { return raw.map((value) => String(value || '').trim()).filter(Boolean); } if (!raw || typeof raw !== 'object') { return []; } return Object.entries(raw).flatMap(([key, value]) => { const option = String(key || '').trim(); if (!option || value === false) { return []; } if (value === true || value == null) { return [option]; } return [option, String(value)]; }); }; const getRemoteBranchComparison = async (git, remoteName, branchName) => { const remote = String(remoteName || '').trim(); const branch = String(branchName || '').trim(); if (!remote || !branch) { return null; } const remoteRef = `refs/remotes/${remote}/${branch}`; const exists = await git .raw(['rev-parse', '--verify', remoteRef]) .then((value) => String(value || '').trim()) .catch(() => ''); if (!exists) { return null; } const countsRaw = await git .raw(['rev-list', '--left-right', '--count', `HEAD...${remoteRef}`]) .then((value) => String(value || '').trim()) .catch(() => ''); const counts = parseAheadBehindCounts(countsRaw); if (!counts) { return null; } return { remote, branch, ahead: counts.ahead, behind: counts.behind, }; }; const isNotGitRepositoryError = (error) => { const text = parseGitErrorText(error); return /not a git repository/i.test(text); }; // A directory that no longer exists (e.g. a worktree deleted while something // was still polling its status) is an expected, benign condition — not a fault // to scream about. simple-git throws "Cannot use simple-git on a directory that // does not exist"; the underlying fs errors are ENOENT/ENOTDIR. const isMissingDirectoryError = (error) => { const code = error?.code; if (code === 'ENOENT' || code === 'ENOTDIR') { return true; } const text = parseGitErrorText(error); return /directory that does not exist|does not exist|no such file or directory/i.test(text); }; const runGitCommand = async (cwd, args) => { try { const { stdout, stderr } = await execFileAsync(getGitBinary(), args, { cwd, env: await buildGitEnv(), windowsHide: true, maxBuffer: 20 * 1024 * 1024, }); return { success: true, exitCode: 0, stdout: String(stdout || ''), stderr: String(stderr || ''), }; } catch (error) { return { success: false, exitCode: typeof error?.code === 'number' ? error.code : 1, stdout: String(error?.stdout || ''), stderr: String(error?.stderr || ''), message: parseGitErrorText(error), }; } }; const resolveGitCommitFilePath = async (repoRoot, hash, candidates) => { for (const candidate of candidates) { const [originalTreeResult, modifiedTreeResult] = await Promise.all([ runGitCommand(repoRoot, ['ls-tree', '--name-only', `${hash}^`, '--', candidate]), runGitCommand(repoRoot, ['ls-tree', '--name-only', hash, '--', candidate]), ]); if ((originalTreeResult.success && originalTreeResult.stdout.trim()) || (modifiedTreeResult.success && modifiedTreeResult.stdout.trim())) { return candidate; } } throw new Error('Invalid file path'); }; const runGitCommandOrThrow = async (cwd, args, fallbackMessage) => { const result = await runGitCommand(cwd, args); if (!result.success) { throw new Error(result.message || fallbackMessage || 'Git command failed'); } return result; }; const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); const isIndexLockError = (result) => { 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) => { 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) => { try { const stat = await fsp.stat(filePath); return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`; } catch (error) { if (error?.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']; const isFilenameTooLongError = (message) => /file ?name too long/i.test(String(message || '')); const formatWorktreePopulateError = (message) => { 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'); }; export const ensureWorktreeLongpaths = async (directory) => { 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']); }; export const populateWorktreeWithLockRecovery = async (directory) => { 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 fsp.unlink(lockPath).catch((error) => { if (error?.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) => { let hookDirectory = 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 fsp.stat(hookPath); if (!stat.isFile()) return; if (process.platform !== 'win32') { await fsp.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 ${directory}: ${error instanceof Error ? error.message : String(error)}`); } }; const derivePrimaryWorktreeRootFromGitDir = (gitDir) => { const normalized = normalizePath(gitDir); if (!normalized) return null; if (normalized.endsWith('/.git')) { return normalized.slice(0, -'/.git'.length) || null; } const marker = '/.git/worktrees/'; const markerIndex = normalized.indexOf(marker); if (markerIndex > 0) { return normalized.slice(0, markerIndex) || null; } return null; }; export async function resolvePrimaryWorktreeRoot(directory) { const result = await runGitCommand(directory, ['rev-parse', '--absolute-git-dir', '--git-common-dir']); if (!result.success) { return { root: directory }; } const lines = String(result.stdout || '') .split('\n') .map((line) => line.trim()) .filter(Boolean); const absoluteGitDir = normalizePath(lines[0] || ''); const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir); if (rootFromAbsoluteGitDir) { return { root: rootFromAbsoluteGitDir }; } const rawCommonDir = normalizePath(lines[1] || ''); if (rawCommonDir) { const commonDir = path.isAbsolute(rawCommonDir) ? rawCommonDir : path.resolve(directory, rawCommonDir); const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir); if (rootFromCommonDir) { return { root: rootFromCommonDir }; } } return { root: directory }; } export async function resolveWorktreeTopLevel(directory) { const result = await runGitCommand(directory, ['rev-parse', '--show-toplevel']); if (!result.success) { return { root: directory }; } const root = normalizePath(String(result.stdout || '').trim()); return { root: root || directory }; } export async function getCommitSummaries(directory, shas) { const commits = Array.isArray(shas) ? shas.map((sha) => String(sha || '').trim()).filter(Boolean) : []; if (commits.length === 0) { return { commits: [] }; } if (commits.some((sha) => !/^[0-9a-fA-F]{4,64}$/.test(sha))) { throw new Error('Invalid commit SHA'); } const result = await runGitCommandOrThrow( directory, ['show', '-s', '--format=%H%x09%h%x09%s', ...commits, '--'], 'Failed to get commit summaries' ); const parsed = String(result.stdout || '') .split(/\r?\n/) .filter(Boolean) .map((line) => { const [sha, short, subject] = line.split('\t'); return { sha: sha || '', short: short || '', subject: subject || '' }; }) .filter((entry) => entry.sha && entry.short); return { commits: parsed }; } const trimGitLines = (value) => String(value || '') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); const gitStdoutText = (result) => String(result?.stdout || '').trim(); const gitStderrText = (result) => String(result?.stderr || result?.message || '').trim(); const normalizeIntegrateBranch = (value, fieldName) => { const branch = String(value || '').trim(); if (!branch) { throw new Error(`${fieldName} is required`); } if (branch.startsWith('-') || branch.includes('\0')) { throw new Error(`Invalid ${fieldName}`); } return branch; }; const normalizeIntegrateSha = (value) => { const sha = String(value || '').trim(); if (!/^[0-9a-fA-F]{4,64}$/.test(sha)) { throw new Error('Invalid commit SHA'); } return sha; }; const normalizeIntegratePath = (value, fieldName) => { const target = normalizeDirectoryPath(value); if (!target) { throw new Error(`${fieldName} is required`); } return path.resolve(target); }; const runGitOk = (result) => Boolean(result?.success); const listGitWorktreesForIntegrate = async (repoRoot) => { const out = await runGitCommandOrThrow(repoRoot, ['worktree', 'list', '--porcelain'], 'Failed to list git worktrees'); const entries = []; let current = null; for (const line of String(out.stdout || '').split(/\r?\n/)) { if (line.startsWith('worktree ')) { if (current) entries.push(current); current = { path: line.slice('worktree '.length).trim(), branchRef: null }; continue; } if (!current) continue; if (line.startsWith('branch ')) { current.branchRef = line.slice('branch '.length).trim(); } } if (current) entries.push(current); return entries.filter((entry) => Boolean(entry.path)); }; const ensureLocalIntegrateBranch = async (repoRoot, candidate) => { const raw = normalizeIntegrateBranch(candidate, 'targetBranch'); if (raw === 'HEAD') { return 'HEAD'; } const hasLocal = await runGitCommand(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${raw}`]); if (runGitOk(hasLocal)) { return raw; } if (raw.startsWith('remotes/')) { const remoteRef = raw.slice('remotes/'.length); const parts = remoteRef.split('/'); const remote = normalizeIntegrateBranch(parts[0] || 'origin', 'remote'); const name = normalizeIntegrateBranch(parts.slice(1).join('/'), 'branch'); await runGitCommandOrThrow(repoRoot, ['branch', '--track', name, `${remote}/${name}`], 'Failed to track remote branch'); return name; } const remoteCheck = await runGitCommand(repoRoot, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${raw}`]); if (runGitOk(remoteCheck)) { await runGitCommandOrThrow(repoRoot, ['branch', '--track', raw, `origin/${raw}`], 'Failed to track remote branch'); return raw; } return raw; }; export async function computeIntegratePlan(input = {}) { const repoRoot = normalizeIntegratePath(input.repoRoot, 'repoRoot'); const sourceBranch = normalizeIntegrateBranch(input.sourceBranch, 'sourceBranch'); const targetBranchRaw = normalizeIntegrateBranch(input.targetBranch, 'targetBranch'); if (sourceBranch === 'HEAD' || targetBranchRaw === 'HEAD') { return { repoRoot, sourceBranch, targetBranch: targetBranchRaw, commits: [] }; } const targetBranch = await ensureLocalIntegrateBranch(repoRoot, targetBranchRaw); const cherry = await runGitCommandOrThrow(repoRoot, ['cherry', targetBranch, sourceBranch], 'Failed to compute cherry commits'); const plus = new Set(); for (const line of trimGitLines(cherry.stdout)) { const match = line.match(/^\+\s+([0-9a-f]{7,40})\b/i); if (match) { plus.add(match[1]); } } const revList = await runGitCommandOrThrow(repoRoot, ['rev-list', '--reverse', `${targetBranch}..${sourceBranch}`], 'Failed to list commits'); const commits = trimGitLines(revList.stdout).filter((sha) => plus.has(sha)); return { repoRoot, sourceBranch, targetBranch, commits }; } const createIntegrateTempWorktree = async (repoRoot, targetBranch) => { const tmpParent = path.join(os.homedir(), '.config', 'openchamber', 'tmp'); await fsp.mkdir(tmpParent, { recursive: true }); const tmpDir = await fsp.mkdtemp(path.join(tmpParent, 'oc-integrate-')); try { await runGitCommandOrThrow(repoRoot, ['worktree', 'add', '--force', tmpDir, targetBranch], 'Failed to create temp worktree'); return tmpDir; } catch (error) { await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); throw error; } }; const removeIntegrateTempWorktree = async (repoRoot, tmpDir) => { await runGitCommand(repoRoot, ['worktree', 'remove', '--force', tmpDir]).catch(() => undefined); await runGitCommand(repoRoot, ['worktree', 'prune']).catch(() => undefined); }; const maybeFastForwardIntegrateUpstream = async (tmpDir) => { const upstream = await runGitCommand(tmpDir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); const upstreamRef = gitStdoutText(upstream); if (!upstreamRef) { return; } await runGitCommand(tmpDir, ['fetch']); const ff = await runGitCommand(tmpDir, ['merge', '--ff-only', upstreamRef]); if (!runGitOk(ff)) { throw new Error(gitStderrText(ff) || 'Fast-forward failed'); } }; export async function getIntegrateConflictDetails(tmpDir) { const target = normalizeIntegratePath(tmpDir, 'tempWorktreePath'); const [status, unmerged, diff, meta, patch] = await Promise.all([ runGitCommand(target, ['status', '--porcelain']), runGitCommand(target, ['diff', '--name-only', '--diff-filter=U']), runGitCommand(target, ['diff']), runGitCommand(target, ['show', '--no-patch', '--pretty=fuller', 'CHERRY_PICK_HEAD']), runGitCommand(target, ['show', 'CHERRY_PICK_HEAD']), ]); return { statusPorcelain: String(status.stdout || ''), unmergedFiles: trimGitLines(unmerged.stdout), diff: String(diff.stdout || diff.stderr || ''), currentPatchMeta: String(meta.stdout || meta.stderr || ''), currentPatch: String(patch.stdout || patch.stderr || ''), }; } export async function isCherryPickInProgress(tmpDir) { const target = normalizeIntegratePath(tmpDir, 'tempWorktreePath'); const head = await runGitCommand(target, ['rev-parse', '--verify', '--quiet', 'CHERRY_PICK_HEAD']); return { inProgress: runGitOk(head) }; } const computeCleanIntegrateWorktreesToSync = async ({ repoRoot, targetBranch, excludePaths }) => { const targetRef = `refs/heads/${targetBranch}`; const exclude = new Set(excludePaths); const entries = await listGitWorktreesForIntegrate(repoRoot); const candidates = entries .filter((entry) => entry.branchRef === targetRef) .map((entry) => entry.path) .filter((candidate) => candidate && !exclude.has(candidate)); const clean = []; for (const candidate of candidates) { const status = await runGitCommand(candidate, ['status', '--porcelain']); if (!gitStdoutText(status)) { clean.push(candidate); } } return clean; }; const syncCleanIntegrateTargetWorktrees = async (paths) => { for (const target of paths) { await runGitCommand(target, ['reset', '--hard']).catch(() => undefined); } }; const normalizeIntegratePlan = async (plan = {}) => { const repoRoot = normalizeIntegratePath(plan.repoRoot, 'repoRoot'); const sourceBranch = normalizeIntegrateBranch(plan.sourceBranch, 'sourceBranch'); const targetBranch = normalizeIntegrateBranch(plan.targetBranch, 'targetBranch'); const commits = Array.isArray(plan.commits) ? plan.commits.map(normalizeIntegrateSha) : []; return { repoRoot, sourceBranch, targetBranch, commits }; }; const normalizeIntegrateState = (state = {}) => ({ repoRoot: normalizeIntegratePath(state.repoRoot, 'repoRoot'), tempWorktreePath: normalizeIntegratePath(state.tempWorktreePath, 'tempWorktreePath'), sourceBranch: normalizeIntegrateBranch(state.sourceBranch, 'sourceBranch'), targetBranch: normalizeIntegrateBranch(state.targetBranch, 'targetBranch'), cleanTargetWorktrees: Array.isArray(state.cleanTargetWorktrees) ? state.cleanTargetWorktrees.map((entry) => normalizeIntegratePath(entry, 'cleanTargetWorktree')) : [], remainingCommits: Array.isArray(state.remainingCommits) ? state.remainingCommits.map(normalizeIntegrateSha) : [], currentCommit: normalizeIntegrateSha(state.currentCommit), }); export async function integrateWorktreeCommits(inputPlan = {}) { const plan = await normalizeIntegratePlan(inputPlan); if (plan.commits.length === 0) { return { kind: 'noop', reason: 'No commits to move' }; } const tmpDir = await createIntegrateTempWorktree(plan.repoRoot, plan.targetBranch); let cleanTargetWorktrees = []; let remaining = []; try { await maybeFastForwardIntegrateUpstream(tmpDir); const clean = await runGitCommand(tmpDir, ['status', '--porcelain']); if (gitStdoutText(clean)) { throw new Error('Target branch has local changes; abort integration and retry'); } cleanTargetWorktrees = await computeCleanIntegrateWorktreesToSync({ repoRoot: plan.repoRoot, targetBranch: plan.targetBranch, excludePaths: [tmpDir], }).catch(() => []); remaining = [...plan.commits]; while (remaining.length > 0) { const sha = remaining[0]; const pick = await runGitCommand(tmpDir, ['cherry-pick', sha]); if (runGitOk(pick)) { remaining.shift(); continue; } const unmerged = await runGitCommand(tmpDir, ['diff', '--name-only', '--diff-filter=U']); const unmergedFiles = trimGitLines(unmerged.stdout); if (unmergedFiles.length > 0) { const details = await getIntegrateConflictDetails(tmpDir); return { kind: 'conflict', state: { repoRoot: plan.repoRoot, tempWorktreePath: tmpDir, sourceBranch: plan.sourceBranch, targetBranch: plan.targetBranch, cleanTargetWorktrees, remainingCommits: remaining, currentCommit: sha, }, details, }; } throw new Error(gitStderrText(pick) || 'Cherry-pick failed'); } await removeIntegrateTempWorktree(plan.repoRoot, tmpDir); await syncCleanIntegrateTargetWorktrees(cleanTargetWorktrees).catch(() => undefined); return { kind: 'success', moved: plan.commits.length }; } catch (error) { await removeIntegrateTempWorktree(plan.repoRoot, tmpDir).catch(() => undefined); throw error; } } export async function abortIntegrate(stateInput = {}) { const state = normalizeIntegrateState(stateInput); await runGitCommand(state.tempWorktreePath, ['cherry-pick', '--abort']).catch(() => undefined); await removeIntegrateTempWorktree(state.repoRoot, state.tempWorktreePath); return { success: true }; } export async function continueIntegrate(stateInput = {}) { const state = normalizeIntegrateState(stateInput); const cont = await runGitCommand(state.tempWorktreePath, ['cherry-pick', '--continue']); if (!runGitOk(cont)) { const unmerged = await runGitCommand(state.tempWorktreePath, ['diff', '--name-only', '--diff-filter=U']); if (trimGitLines(unmerged.stdout).length > 0) { const details = await getIntegrateConflictDetails(state.tempWorktreePath); return { kind: 'conflict', state, details }; } throw new Error(gitStderrText(cont) || 'Cherry-pick continue failed'); } const remaining = [...state.remainingCommits]; if (remaining.length > 0 && remaining[0] === state.currentCommit) { remaining.shift(); } const still = [...remaining]; while (still.length > 0) { const sha = still[0]; const pick = await runGitCommand(state.tempWorktreePath, ['cherry-pick', sha]); if (runGitOk(pick)) { still.shift(); continue; } const unmerged = await runGitCommand(state.tempWorktreePath, ['diff', '--name-only', '--diff-filter=U']); if (trimGitLines(unmerged.stdout).length > 0) { const details = await getIntegrateConflictDetails(state.tempWorktreePath); return { kind: 'conflict', state: { ...state, remainingCommits: still, currentCommit: sha, }, details, }; } throw new Error(gitStderrText(pick) || 'Cherry-pick failed'); } await removeIntegrateTempWorktree(state.repoRoot, state.tempWorktreePath); await syncCleanIntegrateTargetWorktrees(state.cleanTargetWorktrees).catch(() => undefined); return { kind: 'success', moved: state.remainingCommits.length }; } const ensureOpenCodeProjectId = async (primaryWorktree) => { const gitDir = path.join(primaryWorktree, '.git'); const idFile = path.join(gitDir, 'opencode'); const existing = await fsp.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 fsp.mkdir(gitDir, { recursive: true }).catch(() => undefined); await fsp.writeFile(idFile, projectId, 'utf8').catch(() => undefined); return projectId; }; const resolveWorktreeProjectContext = async (directory) => { 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) => { const rawResult = await runGitCommandOrThrow( directory, ['worktree', 'list', '--porcelain'], 'Failed to list git worktrees' ); return parseWorktreePorcelain(rawResult.stdout); }; const resolveWorktreeNameCandidates = (baseName) => { 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, preferredName, explicitBranchName, primaryWorktree) => { 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 resolveBranchForExistingMode = async (primaryWorktree, existingBranch, preferredBranchName) => { 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, }; } 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, localBranchName) => { 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, command) => { const text = String(command || '').trim(); if (!text) { return { success: true }; } if (process.platform === 'win32') { const result = await execFileAsync('cmd', ['/c', text], { cwd: directory, env: await buildGitEnv(), windowsHide: true, maxBuffer: 20 * 1024 * 1024, }).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({ success: false, stdout: error?.stdout, stderr: error?.stderr, message: parseGitErrorText(error), })); return result; } const result = await execFileAsync('bash', ['-lc', text], { cwd: directory, env: await buildGitEnv(), maxBuffer: 20 * 1024 * 1024, }).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({ success: false, stdout: error?.stdout, stderr: error?.stderr, message: parseGitErrorText(error), })); return result; }; const loadProjectStartCommand = async (projectID) => { const storagePath = path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`); try { const raw = await fsp.readFile(storagePath, 'utf8'); const parsed = JSON.parse(raw); const start = typeof parsed?.commands?.start === 'string' ? parsed.commands.start.trim() : ''; return start || ''; } catch { return ''; } }; // OpenCode owns its own project/sandbox registry. It records a worktree as a // sandbox itself when an instance boots for that directory, and filters entries // whose directory no longer exists when reading them back. OpenChamber used to // write that state directly into OpenCode's storage JSON and SQLite database, // behind the back of the running process: the row changed but the server was // never told, so a worktree created while OpenCode was running stayed unknown // to it until a restart. Registration is not ours to perform. const isAttachedGitWorktreeDirectory = async (directory) => { 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, candidate) => { 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 fsp.readdir(candidateDirectory); if (entries.length === 0) { await fsp.rmdir(candidateDirectory); } } catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error?.code)) { console.warn('Failed to clean up empty worktree directory after creation failure:', error instanceof Error ? error.message : String(error)); } } }; const runWorktreeStartScripts = async (directory, projectID, startCommand) => { const projectStart = await loadProjectStartCommand(projectID); if (projectStart) { const projectResult = await runWorktreeStartCommand(directory, projectStart); if (!projectResult.success) { console.warn('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('Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout); } }; const queueWorktreeBootstrap = (args) => { 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('Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error)); }); } setWorktreeBootstrapState( directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_BOOTSTRAP_PHASE_GIT_READY ); await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => { console.warn('Worktree start script task failed:', error instanceof Error ? error.message : String(error)); }); setWorktreeBootstrapState( directory, WORKTREE_BOOTSTRAP_READY, WORKTREE_BOOTSTRAP_PHASE_SETUP_READY ); }) .catch((error) => { setWorktreeBootstrapState( directory, WORKTREE_BOOTSTRAP_FAILED, WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED, error instanceof Error ? error.message : String(error) ); console.warn('Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error)); }); trackWorktreeBootstrapTask(directory, task); }; const ensureRemoteWithUrl = async (primaryWorktree, remoteName, remoteUrl) => { 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 fetchRemoteBranchRef = async (primaryWorktree, remoteName, branchName) => { 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 checkRemoteBranchExists = async (primaryWorktree, remoteName, branchName, 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, localBranch, upstream) => { 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) => { 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); }; export async function isGitRepository(directory) { const directoryPath = normalizeDirectoryPath(directory); if (!directoryPath || !fs.existsSync(directoryPath)) { return false; } const result = await runGitCommand(directoryPath, ['rev-parse', '--git-dir']); return result.success; } export async function getGlobalIdentity() { const git = await createGitForGlobalConfig(); 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, { allowUnsafeSshCommand: true }); 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.raw([ 'config', '--local', 'core.sshCommand', buildSshCommand(profile.sshKey) ]); 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(() => {}); } if (profile.signCommits === true && typeof profile.signingKey === 'string' && profile.signingKey.trim()) { await git.addConfig('gpg.format', 'ssh', false, 'local'); await git.addConfig('user.signingkey', profile.signingKey.trim(), false, 'local'); await git.addConfig('commit.gpgsign', 'true', false, 'local'); } return true; } catch (error) { console.error('Failed to set Git identity:', error); throw error; } } export async function getStatus(directory, options = {}) { const lightMode = options.mode === 'light'; const normalizedDirectory = normalizeDirectoryPath(directory); if (typeof normalizedDirectory !== 'string' || !normalizedDirectory.trim()) { throw new Error('directory is required'); } try { // Prefer an explicit non-repo check before simple-git status so a missing // repository never depends on process.cwd() or an opaque GitError shape. if (!(await isGitRepository(normalizedDirectory))) { throw new Error('fatal: not a git repository (or any of the parent directories): .git'); } const { directoryPath, repoRoot, git } = await createRepositoryGitContext(normalizedDirectory); // Use -uall to show all untracked files individually, not just directories const status = await git.status(['-uall']); // Light mode: skip numstat + new-file line counting for faster response const [stagedStatsRaw, workingStatsRaw] = lightMode ? ['', ''] : 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 MAX_NEW_FILE_STATS = 200; const MAX_NEW_FILE_STAT_SIZE = 1024 * 1024; const newFileStats = []; if (!lightMode) { for (const file of status.files) { if (newFileStats.length >= MAX_NEW_FILE_STATS) { break; } const working = (file.working_dir || '').trim(); const indexStatus = (file.index || '').trim(); const statusCode = working || indexStatus; if (statusCode !== '?' && statusCode !== 'A') { continue; } const existing = diffStats[file.path]; if (existing && existing.insertions > 0) { continue; } const absolutePath = path.join(repoRoot, file.path); try { const stat = await fsp.stat(absolutePath); if (!stat.isFile() || stat.size > MAX_NEW_FILE_STAT_SIZE) { continue; } const buffer = await fsp.readFile(absolutePath); if (buffer.indexOf(0) !== -1) { newFileStats.push({ path: file.path, insertions: existing?.insertions ?? 0, deletions: existing?.deletions ?? 0, }); continue; } const normalized = buffer.toString('utf8').replace(/\r\n/g, '\n'); if (!normalized.length) { newFileStats.push({ path: file.path, insertions: 0, deletions: 0, }); continue; } const segments = normalized.split('\n'); if (normalized.endsWith('\n')) { segments.pop(); } const lineCount = segments.length; newFileStats.push({ path: file.path, insertions: lineCount, deletions: 0, }); } catch (error) { if (error?.code !== 'ENOENT') { console.warn('Failed to estimate diff stats for new file', file.path, error); } } } } for (const entry of newFileStats) { 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; let upstreamComparison; // 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. // Light mode skips this — the basic ahead/behind from git status is sufficient for polling. if (!lightMode && !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; } } } if ( !lightMode && status.current && (!tracking || !tracking.startsWith('upstream/')) && await hasRemote(git, directoryPath, 'upstream') ) { upstreamComparison = await getRemoteBranchComparison(git, 'upstream', status.current); } // 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 mergeMsgPath = await resolveGitInternalPath(repoRoot, git, 'MERGE_MSG').catch(() => ''); const mergeMsg = mergeMsgPath ? await fsp.readFile(mergeMsgPath, '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 rebaseMergePath = await resolveGitInternalPath(repoRoot, git, 'rebase-merge').catch(() => ''); const rebaseApplyPath = await resolveGitInternalPath(repoRoot, git, 'rebase-apply').catch(() => ''); const rebaseMergeExists = rebaseMergePath ? await fsp.stat(rebaseMergePath).then(() => true).catch(() => false) : false; const rebaseApplyExists = rebaseApplyPath ? await fsp.stat(rebaseApplyPath).then(() => true).catch(() => false) : false; if (rebaseMergeExists || rebaseApplyExists) { const rebasePath = rebaseMergeExists ? rebaseMergePath : rebaseApplyPath; const headName = await fsp.readFile(path.join(rebasePath, 'head-name'), 'utf8').catch(() => ''); const onto = await fsp.readFile(path.join(rebasePath, '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, upstreamComparison, files: status.files.map((f) => ({ path: f.path, index: f.index, working_dir: f.working_dir, })), isClean: status.isClean(), diffStats: lightMode ? undefined : diffStats, mergeInProgress, rebaseInProgress, }; } catch (error) { if (isNotGitRepositoryError(error) || isMissingDirectoryError(error)) { // Re-throw a plain Error so route/session callers can match reliably and // continue enumerating other projects instead of treating GitError as 500. throw new Error('fatal: not a git repository (or any of the parent directories): .git'); } console.error('Failed to get Git status:', error); throw error; } } export async function getDiff(directory, { path: filePath, staged = false, contextLines = 3 } = {}) { const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); try { const args = ['diff', '--no-color']; const fileContext = filePath ? await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot) : null; if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { args.push(`-U${Math.max(0, contextLines)}`); } if (staged) { args.push('--cached'); } if (fileContext) { args.push('--', fileContext.repoPath); } const diff = await git.raw(args); if (diff && diff.trim().length > 0) { return diff; } if (staged) { return diff; } if (!fileContext) { return diff; } try { await git.raw(['ls-files', '--error-unmatch', '--', fileContext.repoPath]); return diff; } catch { if (fileContext.isSymbolicLink) { const target = await fsp.readlink(fileContext.absolutePath); return [ `diff --git a/${fileContext.repoPath} b/${fileContext.repoPath}`, 'new file mode 120000', '--- /dev/null', `+++ b/${fileContext.repoPath}`, '@@ -0,0 +1 @@', `+${target}`, '\\ No newline at end of file', '', ].join('\n'); } 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', fileContext.repoPath); 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; } } /** * Individual untracked file paths, honoring ignore rules. * * Deliberately not `--directory`: collapsed directory entries end in a slash * and are not valid inputs to the per-file diff helpers, so a caller would * silently lose every file inside a new directory. Listing files costs more * entries but each one is usable. * * Callers that only need this list should not pay for `getStatus`, which also * computes ahead/behind, diff stats, and merge state — an order of magnitude * more work for an answer they throw away. */ export async function listUntrackedPaths(directory) { const { repoRoot } = await createRepositoryGitContext(directory); const result = await runGitCommand(repoRoot, [ 'ls-files', '--others', '--exclude-standard', ]); if (!result.success) return []; return String(result.stdout || '') .split('\n') .map((line) => line.trim()) .filter(Boolean); } /** * Diffs for untracked files, produced against an empty tree. * * `getDiff` re-resolves the repository context on every call, which costs an * extra `rev-parse` per file; a walkthrough of a branch with thirty new files * pays that thirty times. This resolves once and reuses it, with a bounded pool * so a repository full of new files cannot flood the process table. * * Returns one entry per input path, in order; unreadable paths yield `''` * rather than failing the batch. */ export async function getUntrackedDiffs(directory, filePaths = [], { concurrency = 8, contextLines = 3 } = {}) { const paths = (Array.isArray(filePaths) ? filePaths : []).filter((value) => typeof value === 'string' && value); if (paths.length === 0) return []; const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); const results = new Array(paths.length).fill(''); let cursor = 0; const worker = async () => { while (cursor < paths.length) { const index = cursor++; try { const fileContext = await resolveGitFileContext(directoryPath, directoryGit, paths[index], repoRoot); const args = ['diff', '--no-color']; if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { args.push(`-U${Math.max(0, contextLines)}`); } args.push('--no-index', '--', '/dev/null', fileContext.repoPath); try { results[index] = await git.raw(args); } catch (error) { // `git diff --no-index` exits 1 whenever there are differences, which // for a new file is always. results[index] = error?.exitCode === 1 && error?.message ? error.message : ''; } } catch { results[index] = ''; } } }; await Promise.all(Array.from({ length: Math.min(concurrency, paths.length) }, worker)); return results; } export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) { const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(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 } // Not every repository has an `origin`. When the base names a branch that // exists only on another remote, a bare name does not resolve — git looks in // refs/heads, not across remotes — and the diff fails with "ambiguous // argument". Fall back to whichever remote actually carries it. if (resolvedBase === baseRef && !/[*?[\]^~:\\]/.test(baseRef)) { const resolvesLocally = await git .raw(['rev-parse', '--verify', `refs/heads/${baseRef}`]) .then((value) => Boolean(String(value || '').trim())) .catch(() => false); if (!resolvesLocally) { const remoteMatch = await git .raw(['for-each-ref', '--count=1', '--format=%(refname:short)', `refs/remotes/*/${baseRef}`]) .then((value) => String(value || '').trim()) .catch(() => ''); if (remoteMatch) { resolvedBase = remoteMatch; } } } const args = ['diff', '--no-color']; if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { args.push(`-U${Math.max(0, contextLines)}`); } args.push(`${resolvedBase}...${headRef}`); if (filePath) { const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot); args.push('--', fileContext.repoPath); } const diff = await git.raw(args); return diff; } export async function getRangeFiles(directory, { base, head } = {}) { const { git } = await createRepositoryGitContext(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']; const BINARY_SNIFF_BYTES = 8192; 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'; } const parseIsBinaryFromNumstat = (raw) => { const text = String(raw || '').trim(); if (!text) { return false; } // Expected format: \t\t const firstLine = text.split('\n').map((line) => line.trim()).find(Boolean) || ''; const [added, deleted] = firstLine.split('\t'); return added === '-' || deleted === '-'; }; const extractGitStatusPath = (status, pathPart) => { if ((status === 'R' || status === 'C') && pathPart.includes('\t')) { return pathPart.split('\t').pop() || pathPart; } return pathPart; }; const extractGitNumstatDestinationPath = (filePath) => { 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; }; const looksBinaryBySniff = async (absolutePath) => { try { const handle = await fsp.open(absolutePath, 'r'); try { const buffer = Buffer.alloc(BINARY_SNIFF_BYTES); const { bytesRead } = await handle.read(buffer, 0, BINARY_SNIFF_BYTES, 0); if (bytesRead <= 0) { return false; } return buffer.subarray(0, bytesRead).includes(0); } finally { await handle.close(); } } catch { return false; } }; const isBinaryDiff = async (directoryPath, filePath, staged) => { // Fast path: ask git for numstat. For binary, it returns "-\t-\t". const args = ['diff', '--numstat']; if (staged) { args.push('--cached'); } args.push('--', filePath); const result = await runGitCommand(directoryPath, args); if (parseIsBinaryFromNumstat(result.stdout)) { return true; } // Fallback for untracked files (diff output is empty): use --no-index against /dev/null if (!staged) { const tracked = await runGitCommand(directoryPath, ['ls-files', '--error-unmatch', '--', filePath]).then((r) => r.success); if (!tracked) { const noIndex = await runGitCommand(directoryPath, ['diff', '--no-index', '--numstat', '--', '/dev/null', filePath]); if (parseIsBinaryFromNumstat(noIndex.stdout) || parseIsBinaryFromNumstat(noIndex.stderr) || parseIsBinaryFromNumstat(noIndex.message)) { return true; } const text = `${noIndex.stdout || ''}\n${noIndex.stderr || ''}\n${noIndex.message || ''}`.toLowerCase(); if (text.includes('binary files') || text.includes('git binary patch')) { return true; } } } return false; }; export async function getFileDiff(directory, { path: filePath, staged = false } = {}) { if (!directory || !filePath) { throw new Error('directory and path are required for getFileDiff'); } const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); const isImage = isImageFile(filePath); const mimeType = isImage ? getImageMimeType(filePath) : null; const { absolutePath, repoPath, isSymbolicLink } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot); if (!isImage && !isSymbolicLink) { const isBinaryBySniff = await looksBinaryBySniff(absolutePath); const isBinary = isBinaryBySniff || (await isBinaryDiff(repoRoot, repoPath, staged)); if (isBinary) { return { original: '', modified: '', path: filePath, isBinary: true, }; } } let original = ''; try { if (isImage) { // For images, use git show with raw output and convert to base64 try { const { stdout } = await execFileAsync(getGitBinary(), ['show', `HEAD:${repoPath}`], { cwd: repoRoot, encoding: 'buffer', windowsHide: true, 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:${repoPath}`]); } } catch { original = ''; } let modified = ''; try { if (staged) { if (isImage) { const { stdout } = await execFileAsync(getGitBinary(), ['show', `:${repoPath}`], { cwd: repoRoot, encoding: 'buffer', windowsHide: true, maxBuffer: 50 * 1024 * 1024, }); if (stdout && stdout.length > 0) { modified = `data:${mimeType};base64,${stdout.toString('base64')}`; } } else { modified = await git.show([`:${repoPath}`]); } } else { if (isSymbolicLink) { modified = await fsp.readlink(absolutePath); } else { const stat = await fsp.stat(absolutePath); if (!stat.isFile()) { return { original: typeof original === 'string' ? original.replace(/\r\n/g, '\n') : original, modified: '', path: filePath, isBinary: false, }; } if (isImage) { // For images, read as binary and convert to data URL const buffer = await fsp.readFile(absolutePath); modified = `data:${mimeType};base64,${buffer.toString('base64')}`; } else { modified = await fsp.readFile(absolutePath, '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: typeof original === 'string' ? original.replace(/\r\n/g, '\n') : original, modified: typeof modified === 'string' ? modified.replace(/\r\n/g, '\n') : modified, path: filePath, isBinary: false, }; } export async function revertFile(directory, filePath, options = {}) { return withGitIndexMutationQueue(directory, async () => { const scope = options?.scope === 'working' ? 'working' : 'all'; const directoryPath = normalizeDirectoryPath(directory); const directoryGit = await createGit(directoryPath); const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit); const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot); const git = await createGit(repoRoot); const isTracked = await git .raw(['ls-files', '--error-unmatch', '--', repoPath]) .then(() => true) .catch(() => false); if (!isTracked) { try { await git.raw(['clean', '-f', '-d', '--', repoPath]); return; } catch (cleanError) { try { await fsp.rm(absolutePath, { 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; } } } if (scope === 'all') { try { await git.raw(['restore', '--staged', '--', repoPath]); } catch (error) { await git.raw(['reset', 'HEAD', '--', repoPath]).catch(() => {}); } } try { await git.raw(['restore', '--', repoPath]); } catch (error) { try { await git.raw(['checkout', '--', repoPath]); } catch (fallbackError) { console.error('Failed to revert git file:', fallbackError); throw fallbackError; } } }); } const HUNK_ACTION_FLAGS = { stage: ['--cached'], unstage: ['--cached', '--reverse'], discard: ['--reverse'], }; const parsePatchPathToken = (line) => { 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); } catch { return token.slice(1, token.endsWith('"') ? -1 : undefined); } } return value.split('\t', 1)[0] || null; }; const normalizePatchTargetPath = (value) => { if (!value || value === '/dev/null') { return null; } return value.replace(/^[ab]\//, ''); }; const extractPatchTargetPath = (patch) => { const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)]; const realTargets = matches .map((match) => normalizePatchTargetPath(parsePatchPathToken(match[0]))) .filter(Boolean); return realTargets[0] || null; }; const writeTempPatchFile = async (patch) => { const tmpDir = os.tmpdir(); const tmpPath = path.join(tmpDir, `openchamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`); await fsp.writeFile(tmpPath, patch, 'utf8'); return tmpPath; }; export async function applyHunk(directory, filePath, options = {}) { const action = options?.action; if (!action || !HUNK_ACTION_FLAGS[action]) { throw new Error('Invalid hunk action'); } const patch = typeof options?.patch === 'string' ? options.patch : ''; if (!patch.trim()) { throw new Error('patch is required to apply a hunk'); } if (!/^@@\s/m.test(patch)) { throw new Error('patch does not contain a hunk header'); } return withGitIndexMutationQueue(directory, async () => { const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot); validateRepositoryFilePaths(repoRoot, [fileContext.repoPath]); const targetPath = extractPatchTargetPath(patch); if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) { throw new Error('patch target path does not match the requested file'); } const flags = HUNK_ACTION_FLAGS[action]; let tmpPath = null; try { tmpPath = await writeTempPatchFile(patch); try { await git.raw(['apply', ...flags, '--check', tmpPath]); } catch (checkError) { const text = parseGitErrorText(checkError); throw new Error( text ? `Hunk no longer applies — refresh and try again.\n${text}` : 'Hunk no longer applies — refresh and try again.' ); } await git.raw(['apply', ...flags, tmpPath]); } finally { if (tmpPath) { await fsp.rm(tmpPath, { force: true }).catch(() => {}); } } }); } 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 createRepositoryGitContext(directory); const pullOptions = options.rebase === true ? { ...(options.options && typeof options.options === 'object' && !Array.isArray(options.options) ? options.options : {}), '--rebase': null } : options.options || {}; try { const remote = String(options.remote || '').trim(); const requestedBranch = String(options.branch || '').trim(); let branch = requestedBranch; if (remote && !branch) { // simple-git only includes the remote when both remote and branch are provided. // Resolve the current branch so selecting a remote in the UI really runs `git pull `. const status = await git.status(); branch = String(status.current || '').trim(); } const result = await git.pull( remote || 'origin', branch || undefined, pullOptions ); 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 listStashes(directory) { const { git } = await createRepositoryGitContext(directory); const output = await git.raw(['stash', 'list', '--format=%gd%x1f%gs%x1f%cr%x1f%H']); return String(output || '') .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 countStashFiles(directory, refs = []) { const { git } = await createRepositoryGitContext(directory); const uniqueRefs = Array.from(new Set((Array.isArray(refs) ? refs : []).map((ref) => String(ref || '').trim()).filter(Boolean))); const counts = {}; const concurrency = 4; let cursor = 0; const worker = async () => { while (cursor < uniqueRefs.length) { const ref = uniqueRefs[cursor++]; if (!ref) continue; try { const names = await git.raw(['stash', 'show', '--name-only', ref]); counts[ref] = String(names || '').split('\n').map((line) => line.trim()).filter(Boolean).length; } catch { counts[ref] = 0; } } }; await Promise.all(Array.from({ length: Math.min(concurrency, uniqueRefs.length) }, () => worker())); return counts; } export async function stashPush(directory, options = {}) { const { git } = await createRepositoryGitContext(directory); const message = typeof options.message === 'string' && options.message.trim() ? options.message.trim() : `OpenChamber stash ${new Date().toISOString()}`; const output = await git.raw(['stash', 'push', '--include-untracked', '-m', message]); return { success: true, created: !/no local changes/i.test(String(output || '')), message, output: String(output || '').trim(), }; } export async function stashApply(directory, options = {}) { const { git } = await createRepositoryGitContext(directory); const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : '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. await git.raw(['stash', 'apply', '--index', ref]).catch(async () => { await git.raw(['stash', 'apply', ref]); }); return { success: true, ref }; } export async function stashDrop(directory, options = {}) { const { git } = await createRepositoryGitContext(directory); const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}'; await git.raw(['stash', 'drop', ref]); return { success: true, ref }; } export async function stashPop(directory, options = {}) { const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}'; await stashApply(directory, { ref }); await stashDrop(directory, { ref }); return { success: true, ref }; } export async function push(directory, options = {}) { const { git } = await createRepositoryGitContext(directory); const describePushError = (error) => { const fromNestedGit = error?.git && typeof error.git === 'object' ? [error.git.message, error.git.stderr, error.git.stdout] : []; const candidates = [ error?.message, error?.stderr, error?.stdout, ...fromNestedGit, ] .map((value) => String(value || '').trim()) .filter(Boolean); return candidates[0] || 'Failed to push to remote'; }; 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 = String(options.remote || '').trim(); if (!remote && !options.branch) { try { await git.push(); return { success: true, pushed: [], repo: directory, ref: null, }; } catch (error) { if (!looksLikeMissingUpstream(error)) { const message = describePushError(error); console.error('Failed to push:', error); throw new Error(message); } try { const status = await git.status(); const branch = status.current; const remotes = await git.getRemotes(true); const fallbackRemote = remotes.find((entry) => entry.name === 'origin')?.name || remotes[0]?.name; if (!branch || !fallbackRemote) { const message = describePushError(error); throw new Error(message); } const result = await git.push(fallbackRemote, branch, buildUpstreamOptions(options.options)); return normalizePushResult(result); } catch (fallbackError) { const message = describePushError(fallbackError); console.error('Failed to push (including upstream fallback):', fallbackError); throw new Error(message); } } } const remoteName = 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(remoteName, 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(remoteName, 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)) { const message = describePushError(error); console.error('Failed to push:', error); throw new Error(message); } 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(remoteName, branch, buildUpstreamOptions(options.options)); return normalizePushResult(result); } catch (fallbackError) { const message = describePushError(fallbackError); console.error('Failed to push (including upstream fallback):', fallbackError); throw new Error(message); } } } 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 createRepositoryGitContext(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 createRepositoryGitContext(directory); try { const remote = String(options.remote || '').trim(); const branch = String(options.branch || '').trim(); const fetchOptions = options.options || {}; if (remote && !branch) { // simple-git drops the remote when branch is omitted, so use raw to preserve `git fetch `. await git.raw(['fetch', ...buildRawGitOptions(fetchOptions), remote]); } else { await git.fetch( remote || 'origin', branch || undefined, fetchOptions ); } return { success: true }; } catch (error) { console.error('Failed to fetch:', error); throw error; } } export async function stageFile(directory, filePath) { await stageFiles(directory, [filePath]); } export async function stageFiles(directory, paths) { if (!directory) { throw new Error('directory and path are required for stageFile'); } const filePaths = normalizeFilePathList(paths); if (filePaths.length === 0) { throw new Error('directory and path are required for stageFile'); } validateRepositoryFilePaths(normalizeDirectoryPath(directory), filePaths); await withGitIndexMutationQueue(directory, async () => { const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); const repoPaths = Array.from(new Set(await Promise.all(filePaths.map(async (filePath) => { const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot); return fileContext.repoPath; })))); validateRepositoryFilePaths(repoRoot, repoPaths); await git.raw(['add', '--', ...repoPaths]).catch(async (error) => { const gitErrorText = parseGitErrorText(error); const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files'); if (!isPathspecError) { throw error; } // 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 repoPath of repoPaths) { await git.raw(['add', '--', repoPath]).catch((perPathError) => { const perPathText = parseGitErrorText(perPathError); const perPathIsPathspecError = perPathText.includes('pathspec') && perPathText.includes('did not match any files'); if (!perPathIsPathspecError) { throw perPathError; } }); } }); }); } export async function unstageFile(directory, filePath) { await unstageFiles(directory, [filePath]); } export async function unstageFiles(directory, paths) { if (!directory) { throw new Error('directory and path are required for unstageFile'); } const filePaths = normalizeFilePathList(paths); if (filePaths.length === 0) { throw new Error('directory and path are required for unstageFile'); } validateRepositoryFilePaths(normalizeDirectoryPath(directory), filePaths); await withGitIndexMutationQueue(directory, async () => { const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); const repoPaths = Array.from(new Set(await Promise.all(filePaths.map(async (filePath) => { const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot); return fileContext.repoPath; })))); validateRepositoryFilePaths(repoRoot, repoPaths); await git.raw(['restore', '--staged', '--', ...repoPaths]).catch(async () => { await git.raw(['reset', 'HEAD', '--', ...repoPaths]); }); }); } export async function commit(directory, message, options = {}) { return withGitIndexMutationQueue(directory, async () => { const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); let temporarilyUnstagedFiles = []; try { const requestedFiles = Array.isArray(options.files) ? options.files .map((value) => String(value || '').trim()) .filter(Boolean) : []; const requestedStageFiles = Array.isArray(options.stageFiles) ? options.stageFiles .map((value) => String(value || '').trim()) .filter(Boolean) : null; let filesToCommit = []; let commitFromIndexOnly = false; if (options.addAll) { await git.add('.'); } else if (requestedFiles.length > 0) { filesToCommit = Array.from(new Set(await Promise.all(requestedFiles.map(async (filePath) => { const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot); return fileContext.repoPath; })))); const stageFilesToCommit = requestedStageFiles ? Array.from(new Set(await Promise.all(requestedStageFiles.map(async (filePath) => { const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot); return fileContext.repoPath; })))) : null; const status = await git.status(); const fileStatusByPath = new Map(status.files.map((file) => [file.path, file])); filesToCommit = filesToCommit.filter((filePath) => fileStatusByPath.has(filePath)); if (filesToCommit.length === 0) { throw new Error('No selected files are available to commit. Refresh git status and try again.'); } if (requestedStageFiles) { commitFromIndexOnly = true; const selectedFileSet = new Set(filesToCommit); temporarilyUnstagedFiles = status.files .filter((file) => { const indexStatus = (file.index || '').trim(); return indexStatus && indexStatus !== '?' && !selectedFileSet.has(file.path); }) .map((file) => file.path); if (temporarilyUnstagedFiles.length > 0) { await git.raw(['restore', '--staged', '--', ...temporarilyUnstagedFiles]); } } const filesNeedingAdd = requestedStageFiles ? (stageFilesToCommit || []).filter((filePath) => fileStatusByPath.has(filePath)) : filesToCommit.filter((filePath) => { const fileStatus = fileStatusByPath.get(filePath); if (!fileStatus) { return false; } const alreadyFullyStaged = fileStatus.index !== ' ' && fileStatus.working_dir === ' '; return !alreadyFullyStaged; }); if (filesNeedingAdd.length > 0) { await git.raw(['add', '--', ...filesNeedingAdd]); } } const commitArgs = !commitFromIndexOnly && !options.addAll && filesToCommit.length > 0 ? filesToCommit : undefined; let result; try { result = await git.commit(message, commitArgs); } catch (error) { const gitErrorText = parseGitErrorText(error); const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files'); if (!isPathspecError || !commitArgs || commitArgs.length === 0) { throw error; } // Fallback for deleted/stale selections: commit currently staged changes. result = await git.commit(message); } if (temporarilyUnstagedFiles.length > 0) { await git.raw(['add', '--', ...temporarilyUnstagedFiles]).catch((restoreError) => { console.error('Failed to restore temporarily unstaged files:', restoreError); }); } return { success: true, commit: result.commit, branch: result.branch, summary: result.summary }; } catch (error) { if (temporarilyUnstagedFiles.length > 0) { await git.raw(['add', '--', ...temporarilyUnstagedFiles]).catch((restoreError) => { console.error('Failed to restore temporarily unstaged files after commit failure:', restoreError); }); } console.error('Failed to commit:', error); throw error; } }); } export async function getBranches(directory) { const { git } = await createRepositoryGitContext(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 defaultBranches = await getRemoteDefaultBranches(git); const filteredAll = [ ...allBranches.filter(branch => !branch.startsWith('remotes/')), ...activeRemoteBranches ]; return { all: filteredAll, current: result.current, branches: result.branches, defaultBranches, }; } catch (error) { console.error('Failed to get branches:', error); throw error; } } async function getRemoteDefaultBranches(git) { let defaults = {}; try { const refs = await git.raw([ 'for-each-ref', '--format=%(refname) %(symref)', 'refs/remotes', ]); defaults = Object.fromEntries( refs.trim().split('\n').flatMap((line) => { const [ref, symbolicRef] = line.split(' '); const match = ref.match(/^refs\/remotes\/([^/]+)\/HEAD$/); const prefix = match ? `refs/remotes/${match[1]}/` : ''; return match && typeof symbolicRef === 'string' && symbolicRef.startsWith(prefix) ? [[match[1], symbolicRef.slice(prefix.length)]] : []; }) ); } catch { defaults = {}; } // `remote/HEAD` is written by clone and by `git remote set-head`; a remote // added by hand may never have one. Without this the caller falls back to // guessing main/master/develop, which is exactly the guess this data exists // to replace — so ask the remote itself, but only for the remotes that are // actually missing an answer. try { const remotes = await git.getRemotes(); const missing = remotes.filter((remote) => remote?.name && !defaults[remote.name]); if (missing.length === 0) return defaults; const resolved = await Promise.all(missing.map(async (remote) => { try { const output = await git.raw(['ls-remote', '--symref', remote.name, 'HEAD']); const match = String(output || '').match(/^ref:\s+refs\/heads\/(.+?)\s+HEAD$/m); return match ? [remote.name, match[1]] : null; } catch { // Unreachable or refusing: no answer is better than a guessed one. return null; } })); for (const entry of resolved) { if (entry) defaults[entry[0]] = entry[1]; } } catch { // Remote list unavailable; the local symrefs are still valid. } return defaults; } async function filterActiveRemoteBranches(git, remoteBranches) { try { const remotes = await git.getRemotes(); const branchesByRemote = new Map(); // A remote that did not answer says nothing about its branches. Dropping // them would turn "we could not ask" into "these branches are gone", and // callers use this list to decide whether a base branch exists at all — so // offline would silently remove comparisons that work perfectly well // against the local remote-tracking refs. const unreachableRemotes = new Set(); await Promise.all(remotes.map(async (remote) => { try { const lsRemoteResult = await git.raw(['ls-remote', '--heads', remote.name]); 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); } } branchesByRemote.set(remote.name, actualRemoteBranches); } catch { unreachableRemotes.add(remote.name); } })); return remoteBranches.filter(remoteBranch => { const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/); if (!match) return false; const remoteName = remoteBranch.split('/')[1]; const branchName = match[1]; if (unreachableRemotes.has(remoteName)) return true; return branchesByRemote.get(remoteName)?.has(branchName) ?? false; }); } 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 createRepositoryGitContext(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 createRepositoryGitContext(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 checkoutCommit(directory, hash) { if (!isValidCommitHash(hash)) { throw new Error('Invalid commit hash'); } const { git } = await createRepositoryGitContext(directory); try { await git.checkout(hash); return { success: true }; } catch (error) { console.error('Failed to checkout commit:', error); throw error; } } export async function cherryPick(directory, hash) { if (!isValidCommitHash(hash)) { throw new Error('Invalid commit hash'); } const { git } = await createRepositoryGitContext(directory); try { await git.raw(['cherry-pick', hash]); return { success: true, conflict: false }; } catch (error) { const errorMessage = String(error?.message || error || '').toLowerCase(); const isConflict = errorMessage.includes('conflict') || errorMessage.includes('patch does not apply'); if (isConflict) { const status = await git.status().catch(() => ({ conflicted: [] })); return { success: false, conflict: true, conflictFiles: status.conflicted || [], }; } console.error('Failed to cherry-pick:', error); throw error; } } export async function revertCommit(directory, hash) { if (!isValidCommitHash(hash)) { throw new Error('Invalid commit hash'); } const { git } = await createRepositoryGitContext(directory); try { await git.raw(['revert', '--no-commit', hash]); return { success: true, conflict: false }; } catch (error) { const errorMessage = String(error?.message || error || '').toLowerCase(); const isConflict = errorMessage.includes('conflict') || errorMessage.includes('revert failed'); if (isConflict) { const status = await git.status().catch(() => ({ conflicted: [] })); return { success: false, conflict: true, conflictFiles: status.conflicted || [], }; } console.error('Failed to revert commit:', error); throw error; } } export async function resetToCommit(directory, hash, mode, force = false) { if (!isValidCommitHash(hash)) { throw new Error('Invalid commit hash'); } const { git } = await createRepositoryGitContext(directory); if (mode === 'hard' && !force) { const status = await git.status(); const isDirty = !status.isClean(); if (isDirty) { throw new Error('Cannot hard reset: uncommitted changes in working tree. Stash or commit first, or use force.'); } } try { await git.raw(['reset', `--${mode}`, hash]); return { success: true }; } catch (error) { console.error('Failed to reset to commit:', error); throw error; } } export async function getWorktrees(directory) { const directoryPath = normalizeDirectoryPath(directory); if (!directoryPath || !fs.existsSync(directoryPath)) { return []; } try { const directoryGit = await createGit(directoryPath); const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit); const result = await runGitCommandOrThrow( repoRoot, ['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('Failed to list worktrees, returning empty list:', error?.message || error); return []; } } export async function validateWorktreeCreate(directory, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const errors = []; 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 = 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, input = {}) => { 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, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); await fsp.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, }; } async function attachGitWorktreeToCandidate(context, candidate, input = {}) { 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 = 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_BOOTSTRAP_PHASE_DIRECTORY_CREATED ); 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, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); if (input?.returnAfterDirectoryCreated === true) { await assertWorktreeCreatePreflight(directory, input); } await fsp.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 fsp.mkdir(candidate.directory, { recursive: false }); const bootstrapStatus = setWorktreeBootstrapState( candidate.directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED ); const localBranch = mode === 'existing' ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) : candidate.branch; const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => { setWorktreeBootstrapState( candidate.directory, WORKTREE_BOOTSTRAP_FAILED, WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED, error instanceof Error ? error.message : String(error) ); await cleanupFailedFastWorktreeCreate(context, candidate); console.warn('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) { const key = toBootstrapStateKey(directory); if (!key) { throw new Error('Worktree directory is required'); } const current = worktreeBootstrapState.get(key); if (current) { return current; } return createWorktreeBootstrapState( WORKTREE_BOOTSTRAP_READY, WORKTREE_BOOTSTRAP_PHASE_SETUP_READY ); } export async function removeWorktree(directory, input = {}) { 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 worktreeRootCanonical = await canonicalPath(context.worktreeRoot); 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 isManagedOrphan = targetCanonical !== worktreeRootCanonical && isInsideOrSameDirectory(worktreeRootCanonical, targetCanonical); const targetExists = await checkPathExists(targetDirectory); if (targetExists && isManagedOrphan) { await fsp.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; } export async function deleteBranch(directory, branch, options = {}) { const { git } = await createRepositoryGitContext(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; } } /** * Resolve a log base ref using local-first semantics. * * - If `from` is falsy / whitespace → return undefined. * - If the local ref resolves → return it unchanged (caller's intent preserved). * - If the local ref is absent but `origin/` exists → return `origin/` * (common when the user has never checked out the base branch locally). * - If neither resolves → return `from` unchanged so git surfaces a meaningful error. * * @param {string | undefined} from - The raw `from` option value. * @param {(ref: string) => Promise} checkRef - Returns true when the ref resolves. * @returns {Promise} */ export async function resolveBaseRefForLog(from, checkRef) { const normalized = typeof from === 'string' ? from.trim() : undefined; if (!normalized) return undefined; if (await checkRef(normalized)) return normalized; const originRef = `refs/remotes/origin/${normalized}`; if (await checkRef(originRef)) return `origin/${normalized}`; return normalized; } export async function getLog(directory, options = {}) { const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); try { 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 rawLog = await git.raw(logArgs); const records = rawLog .split('\x1e') .map((e) => e.trim()) .filter(Boolean); const entries = []; 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 }; } const filePath = options.file ? (await resolveGitFileContext(directoryPath, directoryGit, options.file, repoRoot)).repoPath : undefined; // 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 checkRef = async (ref) => { try { const out = await git.raw(['rev-parse', '--verify', ref]); return Boolean(out && out.trim()); } catch { return false; } }; const resolvedFrom = await resolveBaseRefForLog(options.from, checkRef); const baseLog = await git.log({ maxCount, from: resolvedFrom, to: options.to, file: filePath }); const logArgs = [ 'log', `--max-count=${maxCount}`, '--date=iso', '--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s', '--shortstat' ]; if (resolvedFrom && options.to) { logArgs.push(`${resolvedFrom}..${options.to}`); } else if (resolvedFrom) { logArgs.push(`${resolvedFrom}..HEAD`); } else if (options.to) { logArgs.push(options.to); } if (filePath) { logArgs.push('--', filePath); } 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, parentsRaw] = header.split('\x1f'); const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : []; 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, parents }); }); const merged = baseLog.all.map((entry) => { const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0, parents: [] }; 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, parents: stats.parents || [], }; }); 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 validateWorktreeDirectory(directory, worktreeRoot) { const directoryPath = normalizeDirectoryPath(directory); const rootPath = normalizeDirectoryPath(worktreeRoot); if (!directoryPath || !rootPath) { return { valid: false, insideWorktreeRoot: false, resolvedWorktreeRoot: null, resolvedCwd: null, }; } const isRepo = await isGitRepository(directoryPath); if (!isRepo) { return { valid: false, insideWorktreeRoot: false, resolvedWorktreeRoot: null, resolvedCwd: null, }; } const resolvedCwd = await canonicalPath(directoryPath); const resolvedRoot = await canonicalPath(rootPath); const inside = resolvedCwd.startsWith(resolvedRoot + path.sep) || resolvedCwd === resolvedRoot; return { valid: true, insideWorktreeRoot: inside, resolvedWorktreeRoot: resolvedRoot, resolvedCwd, }; } export async function canonicalizeWorktreeState(directory) { 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 isGitRepository(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 canonicalPath(directoryPath); const git = await createGit(directoryPath); const repoRoot = await resolveGitRepositoryRoot(directoryPath, git).catch(() => directoryPath); let worktreeRoot = null; let worktreeStatus = 'ready'; let headState = /** @type {'branch' | 'detached' | 'unborn'} */ ('branch'); let branch = null; let attentionReason = /** @type {'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null} */ (null); try { const context = await resolveWorktreeProjectContext(directoryPath); worktreeRoot = await canonicalPath(context.worktreeRoot); } catch { worktreeStatus = 'invalid'; } try { const symbolicRef = await git.raw(['symbolic-ref', '-q', 'HEAD']).catch(() => ''); if (symbolicRef.trim()) { headState = 'branch'; branch = cleanBranchName(symbolicRef.trim()); } else { const revParse = await git.raw(['rev-parse', 'HEAD']).catch(() => ''); if (!revParse.trim()) { headState = 'unborn'; branch = null; } else { headState = 'detached'; branch = revParse.trim().slice(0, 7); } } } catch { headState = 'unborn'; branch = null; } // Detect attention reasons from getStatus side-effects try { const status = await git.status(['-uall']); if (status.current && (await git.raw(['rev-parse', '--verify', 'MERGE_HEAD']).then(() => true).catch(() => false))) { attentionReason = 'merge'; } else { const rebaseMergePath = await resolveGitInternalPath(repoRoot, git, 'rebase-merge').catch(() => ''); const rebaseApplyPath = await resolveGitInternalPath(repoRoot, git, 'rebase-apply').catch(() => ''); const rebaseMerge = rebaseMergePath ? await fsp.stat(rebaseMergePath).then(() => true).catch(() => false) : false; const rebaseApply = rebaseApplyPath ? await fsp.stat(rebaseApplyPath).then(() => true).catch(() => false) : false; if (rebaseMerge || rebaseApply) { attentionReason = 'rebase'; } else if (status.conflicted && status.conflicted.length > 0) { const cherryPickHeadPath = await resolveGitInternalPath(repoRoot, git, 'CHERRY_PICK_HEAD').catch(() => ''); const revertHeadPath = await resolveGitInternalPath(repoRoot, git, 'REVERT_HEAD').catch(() => ''); const cherryPickHead = cherryPickHeadPath ? await fsp.stat(cherryPickHeadPath).then(() => true).catch(() => false) : false; const revertHead = revertHeadPath ? await fsp.stat(revertHeadPath).then(() => true).catch(() => false) : 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, }; } export async function getCommitFiles(directory, commitHash) { const { git } = await createRepositoryGitContext(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, 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 }; } catch (error) { console.error('Failed to get commit files:', error); throw error; } } export async function renameBranch(directory, oldName, newName) { const { git, repoRoot } = await createRepositoryGitContext(directory); try { const normalizedOldName = cleanBranchName(String(oldName || '').trim()); const normalizedNewName = cleanBranchName(String(newName || '').trim()); const previousRemote = await git .raw(['config', '--get', `branch.${normalizedOldName}.remote`]) .then((value) => String(value || '').trim()) .catch(() => ''); const previousMerge = await git .raw(['config', '--get', `branch.${normalizedOldName}.merge`]) .then((value) => String(value || '').trim()) .catch(() => ''); // Use git branch -m command to rename the branch await git.raw(['branch', '-m', oldName, newName]); if (previousRemote && previousMerge && normalizedNewName) { const previousMergeBranch = cleanBranchName(previousMerge); const nextMergeBranch = previousMergeBranch === normalizedOldName ? normalizedNewName : previousMergeBranch; const upstream = normalizeUpstreamTarget(previousRemote, nextMergeBranch); if (upstream) { try { await runGitCommandOrThrow( repoRoot, ['branch', `--set-upstream-to=${upstream.full}`, normalizedNewName], `Failed to set upstream to ${upstream.full}` ); } catch { await setBranchTrackingFallback(repoRoot, normalizedNewName, upstream); } } } return { success: true, branch: newName }; } catch (error) { console.error('Failed to rename branch:', error); throw error; } } export async function getRemotes(directory) { const { git } = await createRepositoryGitContext(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) { if (isNotGitRepositoryError(error)) { return []; } console.error('Failed to get remotes:', error); throw error; } } export async function removeRemote(directory, options = {}) { const remoteName = String(options.remote || '').trim(); if (!remoteName) { throw new Error('remote is required to remove a remote'); } if (remoteName === 'origin') { throw new Error('Cannot remove origin remote'); } const { git } = await createRepositoryGitContext(directory); try { await git.removeRemote(remoteName); return { success: true }; } catch (error) { console.error('Failed to remove remote:', error); throw error; } } export async function rebase(directory, options = {}) { const { git } = await createRepositoryGitContext(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 createRepositoryGitContext(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 createRepositoryGitContext(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 createRepositoryGitContext(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 { git } = await createRepositoryGitContext(directory); 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 { git } = await createRepositoryGitContext(directory); 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 { repoRoot, git } = await createRepositoryGitContext(directory); 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 mergeMsgPath = await resolveGitInternalPath(repoRoot, git, 'MERGE_MSG').catch(() => ''); const mergeMsg = mergeMsgPath ? await fsp.readFile(mergeMsgPath, '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; } } export async function getCommitFileDiff(directory, hash, filePath, isBinary) { if (!directory || !hash || !filePath) { throw new Error('directory, hash, and path are required for getCommitFileDiff'); } if (isBinary) { return { original: '', modified: '', isBinary: true }; } const { directoryPath, repoRoot } = await createRepositoryGitContext(directory); const candidates = Array.from(new Set([ toGitPath(path.relative(repoRoot, path.resolve(repoRoot, filePath))), toGitPath(path.relative(repoRoot, path.resolve(directoryPath, filePath))), ])).filter((candidate) => candidate && !candidate.startsWith('..') && !path.isAbsolute(candidate)); let originalResult = null; let modifiedResult = null; for (const candidate of candidates) { const [candidateOriginalResult, candidateModifiedResult] = await Promise.all([ runGitCommand(repoRoot, ['show', `${hash}^:${candidate}`]), runGitCommand(repoRoot, ['show', `${hash}:${candidate}`]), ]); if (candidateOriginalResult.success || candidateModifiedResult.success) { originalResult = candidateOriginalResult; modifiedResult = candidateModifiedResult; break; } } if (!originalResult || !modifiedResult) { const resolvedPath = await resolveGitCommitFilePath(repoRoot, hash, candidates); [originalResult, modifiedResult] = await Promise.all([ runGitCommand(repoRoot, ['show', `${hash}^:${resolvedPath}`]), runGitCommand(repoRoot, ['show', `${hash}:${resolvedPath}`]), ]); } const original = originalResult.success ? originalResult.stdout : ''; const modified = modifiedResult.success ? modifiedResult.stdout : ''; if (!originalResult.success && !modifiedResult.success) { throw new Error(`Failed to read file content at commit ${hash}: ${originalResult.stderr || modifiedResult.stderr}`); } return { original, modified, isBinary: false }; }