import { readFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { simpleGit, type SimpleGit } from 'simple-git' 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[] } function git(root: string): SimpleGit { return simpleGit({ baseDir: root, maxConcurrentProcesses: 4 }) } /** 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 } } async function headText(g: SimpleGit, path: string): Promise { try { return await g.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 { return await git(root).checkIsRepo() } catch { return false } } export async function load(root: string): Promise { const g = git(root) if (!(await isRepo(root))) return null const status = await g.status() const branch = status.current || 'HEAD' const changes: GitChange[] = [] for (const f of status.files) { // simple-git uses path "from -> to" for renames; take the destination. const path = f.path.includes(' -> ') ? f.path.split(' -> ').pop()! : f.path const { letter, staged } = classify(f.index, f.working_dir) const isNew = f.index === '?' || f.index === 'A' const isDeleted = letter === 'D' const original = isNew ? '' : await headText(g, path) const updated = isDeleted ? '' : await diskText(root, path) changes.push({ path, status: letter, staged, original, updated }) } 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 { try { await git(root).reset(['--', ...paths]) } catch { // empty repo (no HEAD yet): fall back to removing from the index. await git(root).raw(['rm', '--cached', '-r', '--', ...paths]) } } export async function commit(root: string, message: string): Promise { await git(root).commit(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 { const g = git(root) for (const p of paths) { const inHead = await g.raw(['cat-file', '-e', `HEAD:${p}`]).then(() => true).catch(() => false) if (inHead) { await g.checkout(['HEAD', '--', p]) } else { try { await g.raw(['reset', '-q', 'HEAD', '--', p]) } catch { /* no HEAD / not staged */ } await rm(join(root, p), { force: true }) } } }