import { execGit, execGitOrThrow } from './executor' import type { RepoStatus, ChangedFile, FileStatus } from '../../shared/types/git.types' import { createLogger } from '../logger' const logger = createLogger('git:changes') /** * Parse git status --porcelain=v1 output into structured ChangedFile objects. * XY format: X = index status, Y = work tree status */ export function parseStatus(output: string): ChangedFile[] { const files: ChangedFile[] = [] const lines = output.split('\n').filter((l) => l.length > 0) for (const line of lines) { if (line.length < 3) continue const x = line[0] // index (staged) status const y = line[1] // working tree (unstaged) status let path = line.slice(3) let originalPath: string | undefined // Handle rename/copy: "R old -> new" or "R100 old\0new" if (path.includes(' -> ')) { const parts = path.split(' -> ') originalPath = parts[0].trim() path = parts[1].trim() } // Remove surrounding quotes (git adds them for special chars) if (path.startsWith('"') && path.endsWith('"')) { path = path.slice(1, -1) } const staged = x !== ' ' && x !== '?' const unstaged = y !== ' ' && y !== '?' const isUntracked = x === '?' && y === '?' const isConflicted = (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) files.push({ path, originalPath, status: getFileStatus(x, y), staged, unstaged, indexStatus: x, workingStatus: y, }) } return files } function getFileStatus(x: string, y: string): FileStatus { if (x === '?' && y === '?') return 'untracked' if (x === '!' && y === '!') return 'ignored' if (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) return 'conflicted' if (x === 'D' || y === 'D') return 'deleted' if (x === 'R' || y === 'R') return 'renamed' if (x === 'C' || y === 'C') return 'copied' if (x === 'A') return 'added' if (x === 'M' || y === 'M') return 'modified' return 'modified' } /** * Get the full repository status including branch info. */ export async function getRepoStatus(repoPath: string): Promise { const [statusResult, branchResult] = await Promise.all([ execGit(['status', '--porcelain=v1', '-u', '--renames'], { cwd: repoPath }), execGit(['status', '--branch', '--porcelain=v1'], { cwd: repoPath }), ]) // Parse branch info from first line of --branch output let branch: string | null = null let detachedHead = false let ahead = 0 let behind = 0 const branchLines = branchResult.stdout.split('\n') const branchLine = branchLines[0] ?? '' if (branchLine.startsWith('## HEAD (no branch)')) { detachedHead = true } else if (branchLine.startsWith('## ')) { let branchInfo = branchLine.slice(3).trim() if (branchInfo.startsWith('No commits yet on ')) { branchInfo = branchInfo.replace('No commits yet on ', '') } const aheadMatch = branchInfo.match(/\[ahead (\d+)/) const behindMatch = branchInfo.match(/behind (\d+)/) if (aheadMatch) ahead = parseInt(aheadMatch[1], 10) if (behindMatch) behind = parseInt(behindMatch[1], 10) branch = branchInfo.split('...')[0].split(' [')[0].trim() } const headResult = await execGit(['rev-parse', 'HEAD'], { cwd: repoPath }) const headCommit = headResult.exitCode === 0 ? headResult.stdout.trim() : null const files = parseStatus(statusResult.stdout) // Detect in-progress operations const mergeInProgress = (await execGit(['rev-parse', '--verify', 'MERGE_HEAD'], { cwd: repoPath })).exitCode === 0 const rebaseInProgress = (await execGit(['rev-parse', '--verify', 'REBASE_HEAD'], { cwd: repoPath })).exitCode === 0 const cherryPickInProgress = (await execGit(['rev-parse', '--verify', 'CHERRY_PICK_HEAD'], { cwd: repoPath })).exitCode === 0 const revertInProgress = (await execGit(['rev-parse', '--verify', 'REVERT_HEAD'], { cwd: repoPath })).exitCode === 0 return { branch, detachedHead, headCommit, ahead, behind, staged: files.filter((f) => f.staged && f.status !== 'conflicted'), unstaged: files.filter((f) => f.unstaged && !f.staged && f.status !== 'untracked' && f.status !== 'conflicted'), untracked: files.filter((f) => f.status === 'untracked'), conflicted: files.filter((f) => f.status === 'conflicted'), mergeInProgress, rebaseInProgress, cherryPickInProgress, revertInProgress, } } /** Stage a file. */ export async function stageFile(repoPath: string, filePath: string): Promise { await execGitOrThrow(['add', '--', filePath], { cwd: repoPath }) } /** Unstage a file. */ export async function unstageFile(repoPath: string, filePath: string): Promise { const result = await execGit(['restore', '--staged', '--', filePath], { cwd: repoPath }) if (result.exitCode !== 0) { // Fallback for older git: reset HEAD await execGitOrThrow(['reset', 'HEAD', '--', filePath], { cwd: repoPath }) } } /** Stage all changes. */ export async function stageAll(repoPath: string): Promise { await execGitOrThrow(['add', '-A'], { cwd: repoPath }) } /** Unstage all changes. */ export async function unstageAll(repoPath: string): Promise { const result = await execGit(['restore', '--staged', '.'], { cwd: repoPath }) if (result.exitCode !== 0) { await execGitOrThrow(['reset', 'HEAD', '.'], { cwd: repoPath }) } } /** Discard changes to a file (restore working tree). */ export async function discardFile(repoPath: string, filePath: string): Promise { const result = await execGit(['restore', '--', filePath], { cwd: repoPath }) if (result.exitCode !== 0) { await execGitOrThrow(['checkout', 'HEAD', '--', filePath], { cwd: repoPath }) } } /** Discard all working tree changes. */ export async function discardAll(repoPath: string): Promise { await execGitOrThrow(['restore', '.'], { cwd: repoPath }) // Also clean untracked files? We'll ask in UI first. } /** Apply a patch to the index (for partial staging). */ export async function applyPatch(repoPath: string, patch: string, reverse = false): Promise { const args = ['apply', '--cached', '--whitespace=nowarn'] if (reverse) args.push('--reverse') args.push('-') await execGitOrThrow(args, { cwd: repoPath, stdin: patch }) }