import { readFile, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' import { spawn } from 'node:child_process' const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) /** Map `fn` over `items` with at most `limit` running at once, preserving order. * Keeps the per-file git/disk reads overlapping without spawning hundreds of * subprocesses at once (macOS has a low default open-file limit). */ async function mapLimit(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise { const out = new Array(items.length) let next = 0 const worker = async (): Promise => { while (next < items.length) { const i = next++ out[i] = await fn(items[i], i) } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) return out } export type GitStatusLetter = 'A' | 'M' | 'D' | 'R' | 'U' export interface GitChange { path: string status: GitStatusLetter staged: boolean original: string updated: string } export interface GitLoad { branch: string changes: GitChange[] } interface GitResult { code: number stdout: string stderr: string } /** * Run a git subcommand directly via child_process. * * We deliberately spawn `git` ourselves instead of going through simple-git: * inside Electron's main process simple-git's spawn (which wires up a stdin * pipe) trips `spawn EBADF` on macOS. Forcing stdin to 'ignore' — the child * never reads input — sidesteps the bad-descriptor crash entirely. */ function runGit(root: string, args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: process.env, windowsHide: true, }) let stdout = '' let stderr = '' child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') child.stdout.on('data', (d: string) => { stdout += d }) child.stderr.on('data', (d: string) => { stderr += d }) child.on('error', reject) child.on('close', (code) => resolve({ code: code ?? -1, stdout, stderr })) }) } /** Run git, rejecting on a non-zero exit. */ async function git(root: string, args: string[]): Promise { const { code, stdout, stderr } = await runGit(root, args) if (code !== 0) throw new Error(`git ${args.join(' ')} failed (${code}): ${stderr.trim()}`) return stdout } /** Map a porcelain code pair to our display letter + staged flag. */ export function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } { const staged = index !== ' ' && index !== '?' const code = staged ? index : working let letter: GitStatusLetter switch (code) { case 'A': case 'C': case '?': letter = 'A'; break case 'D': letter = 'D'; break case 'R': letter = 'R'; break case 'U': letter = 'M'; break case 'M': default: letter = 'M'; break } return { letter, staged } } /** Parse a `## ...` porcelain branch header into a display branch name. */ function parseBranch(header: string): string { // e.g. "main...origin/main [ahead 1]", "main", "No commits yet on main", // "HEAD (no branch)". const noCommits = header.match(/^No commits yet on (.+)$/) if (noCommits) return noCommits[1].trim() if (header.startsWith('HEAD ')) return 'HEAD' const upstream = header.indexOf('...') const head = upstream >= 0 ? header.slice(0, upstream) : header return head.split(' ')[0].trim() || 'HEAD' } interface StatusEntry { index: string; working: string; path: string } interface ParsedStatus { branch: string; files: StatusEntry[] } /** Parse `git status --porcelain -b -z` output. NUL-separated, never quoted. */ export function parseStatus(raw: string): ParsedStatus { const parts = raw.split('\0') let branch = 'HEAD' const files: StatusEntry[] = [] for (let i = 0; i < parts.length; i++) { const p = parts[i] if (!p) continue if (p.startsWith('## ')) { branch = parseBranch(p.slice(3)); continue } const index = p[0] const working = p[1] const path = p.slice(3) // skip "XY " // For renames/copies the original path follows as its own NUL field; the // destination (this entry's path) is what we display, so just skip it. if (index === 'R' || index === 'C' || working === 'R' || working === 'C') i++ files.push({ index, working, path }) } return { branch, files } } async function headText(root: string, path: string): Promise { try { return await git(root, ['show', `HEAD:${path}`]) } catch { return '' } } async function diskText(root: string, path: string): Promise { try { const buf = await readFile(join(root, path)) // skip obvious binaries for (let i = 0; i < Math.min(buf.length, 8000); i++) if (buf[i] === 0) return '' return buf.toString('utf8') } catch { return '' } } export async function isRepo(root: string): Promise { try { const { code, stdout } = await runGit(root, ['rev-parse', '--is-inside-work-tree']) if (code === 0 && stdout.trim() === 'true') return true } catch { /* git missing / transient — fall back to disk. */ } // An external branch switch briefly rewrites .git; trust its presence on // disk rather than blanking the whole panel on a momentary probe failure. return existsSync(join(root, '.git')) } // Coalesce overlapping loads per root: a branch switch or rapid edits fire // several watcher pings, each triggering a reload. We keep at most one read in // flight plus one trailing read (which captures whatever changed during the // first), so the panel always settles on fresh state without a spawn pile-up. const loadInFlight = new Map>() const loadPending = new Map>() export function load(root: string): Promise { const running = loadInFlight.get(root) if (running) { let pending = loadPending.get(root) if (!pending) { pending = running.catch(() => {}).then(() => { loadPending.delete(root) return startLoad(root) }) loadPending.set(root, pending) } return pending } return startLoad(root) } function startLoad(root: string): Promise { const p = doLoad(root).finally(() => { if (loadInFlight.get(root) === p) loadInFlight.delete(root) }) loadInFlight.set(root, p) return p } async function doLoad(root: string): Promise { if (!(await isRepo(root))) return null // A branch switch / checkout from an external tool (Sublime Merge, the CLI) // rewrites .git and briefly holds .git/index.lock; a status that lands in // that window fails. Retry so we settle on the real new state instead of // rejecting (which would leave the git column stale or blank). let raw: string | null = null for (let attempt = 0; attempt < 6; attempt++) { try { raw = await git(root, ['status', '--porcelain', '-b', '--untracked-files=all', '-z']) break } catch (err) { if (attempt === 5) throw err await delay(120) } } if (raw == null) return null const { branch, files } = parseStatus(raw) // Each changed file needs its HEAD blob (a `git show` spawn) + its disk text. // Done serially this is O(files) subprocess spawns in a row — staging one file // re-reads ALL of them, which is the dominant cost of a reload. Run them with // bounded concurrency instead so the spawns overlap (cap keeps us well under // macOS's low default FD limit). Order is preserved by index. const changes = await mapLimit(files, 12, async (f) => { const { letter, staged } = classify(f.index, f.working) const isNew = f.index === '?' || f.index === 'A' const isDeleted = letter === 'D' const original = isNew ? '' : await headText(root, f.path) const updated = isDeleted ? '' : await diskText(root, f.path) return { path: f.path, status: letter, staged, original, updated } as GitChange }) return { branch, changes } } export async function stage(root: string, paths: string[]): Promise { // `git add` stages modifications, additions AND deletions of the given paths. await git(root, ['add', '--', ...paths]) } export async function unstage(root: string, paths: string[]): Promise { const { code } = await runGit(root, ['reset', '--', ...paths]) if (code !== 0) { // empty repo (no HEAD yet): fall back to removing from the index. await git(root, ['rm', '--cached', '-r', '--', ...paths]) } } export async function commit(root: string, message: string): Promise { await git(root, ['commit', '-m', message]) } /** * Discard working-tree changes for each path: * - exists in HEAD → restore index + worktree to the last commit * - not in HEAD → a new file (staged or untracked): unstage + delete from disk */ export async function discard(root: string, paths: string[]): Promise { for (const p of paths) { const { code } = await runGit(root, ['cat-file', '-e', `HEAD:${p}`]) if (code === 0) { await git(root, ['checkout', 'HEAD', '--', p]) } else { await runGit(root, ['reset', '-q', 'HEAD', '--', p]) // no HEAD / not staged — ignore result await rm(join(root, p), { force: true }) } } }