/** * Git status parsing utilities */ import { exec } from 'child_process'; import { promisify } from 'util'; import fs from 'fs/promises'; import path from 'path'; import { GIT_STATUS_MAP, type FileStatus, type MergeStateInfo } from './types.js'; const execAsync = promisify(exec); /** * Get a readable status text from git status codes * Handles both single character and XY format status codes */ function getStatusText(indexStatus: string, workTreeStatus: string): string { // Untracked files if (indexStatus === '?' && workTreeStatus === '?') { return 'Untracked'; } // Ignored files if (indexStatus === '!' && workTreeStatus === '!') { return 'Ignored'; } // Prioritize staging area status, then working tree const primaryStatus = indexStatus !== ' ' && indexStatus !== '?' ? indexStatus : workTreeStatus; // Handle combined statuses if ( indexStatus !== ' ' && indexStatus !== '?' && workTreeStatus !== ' ' && workTreeStatus !== '?' ) { // Both staging and working tree have changes const indexText = GIT_STATUS_MAP[indexStatus] || 'Changed'; const workText = GIT_STATUS_MAP[workTreeStatus] || 'Changed'; if (indexText === workText) { return indexText; } return `${indexText} (staged), ${workText} (unstaged)`; } return GIT_STATUS_MAP[primaryStatus] || 'Changed'; } /** * Check if a path is a git repository */ export async function isGitRepo(repoPath: string): Promise { try { await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath }); return true; } catch { return false; } } /** * Parse the output of `git status --porcelain` into FileStatus array * Git porcelain format: XY PATH where X=staging area status, Y=working tree status * For renamed files: XY ORIG_PATH -> NEW_PATH */ export function parseGitStatus(statusOutput: string): FileStatus[] { return statusOutput .split('\n') .filter(Boolean) .map((line) => { // Git porcelain format uses two status characters: XY // X = status in staging area (index) // Y = status in working tree const indexStatus = line[0] || ' '; const workTreeStatus = line[1] || ' '; // File path starts at position 3 (after "XY ") let filePath = line.slice(3); // Handle renamed files (format: "R old_path -> new_path") if (indexStatus === 'R' || workTreeStatus === 'R') { const arrowIndex = filePath.indexOf(' -> '); if (arrowIndex !== -1) { filePath = filePath.slice(arrowIndex + 4); // Use new path } } // Determine the primary status character for backwards compatibility // Prioritize staging area status, then working tree let primaryStatus: string; if (indexStatus === '?' && workTreeStatus === '?') { primaryStatus = '?'; // Untracked } else if (indexStatus !== ' ' && indexStatus !== '?') { primaryStatus = indexStatus; // Staged change } else { primaryStatus = workTreeStatus; // Working tree change } // Detect merge-affected files: when both X and Y are 'U', or U appears in either position // In merge state, git uses 'U' (unmerged) to indicate merge-affected entries const isMergeAffected = indexStatus === 'U' || workTreeStatus === 'U' || (indexStatus === 'A' && workTreeStatus === 'A') || // both-added (indexStatus === 'D' && workTreeStatus === 'D'); // both-deleted (during merge) let mergeType: string | undefined; if (isMergeAffected) { if (indexStatus === 'U' && workTreeStatus === 'U') mergeType = 'both-modified'; else if (indexStatus === 'A' && workTreeStatus === 'U') mergeType = 'added-by-us'; else if (indexStatus === 'U' && workTreeStatus === 'A') mergeType = 'added-by-them'; else if (indexStatus === 'D' && workTreeStatus === 'U') mergeType = 'deleted-by-us'; else if (indexStatus === 'U' && workTreeStatus === 'D') mergeType = 'deleted-by-them'; else if (indexStatus === 'A' && workTreeStatus === 'A') mergeType = 'both-added'; else if (indexStatus === 'D' && workTreeStatus === 'D') mergeType = 'both-deleted'; else mergeType = 'unmerged'; } return { status: primaryStatus, path: filePath, statusText: getStatusText(indexStatus, workTreeStatus), indexStatus, workTreeStatus, ...(isMergeAffected && { isMergeAffected: true }), ...(mergeType && { mergeType }), }; }); } /** * Check if the current HEAD commit is a merge commit (has more than one parent). * This is used to detect completed merge commits so we can show what the merge changed. * * @param repoPath - Path to the git repository or worktree * @returns Object with isMergeCommit flag and the list of files affected by the merge */ export async function detectMergeCommit( repoPath: string ): Promise<{ isMergeCommit: boolean; mergeAffectedFiles: string[] }> { try { // Check how many parents HEAD has using rev-parse // For a merge commit, HEAD^2 exists (second parent); for non-merge commits it doesn't try { await execAsync('git rev-parse --verify "HEAD^2"', { cwd: repoPath }); } catch { // HEAD^2 doesn't exist — not a merge commit return { isMergeCommit: false, mergeAffectedFiles: [] }; } // HEAD is a merge commit - get the files it changed relative to first parent let mergeAffectedFiles: string[] = []; try { const { stdout: diffOutput } = await execAsync('git diff --name-only "HEAD~1" "HEAD"', { cwd: repoPath, }); mergeAffectedFiles = diffOutput .trim() .split('\n') .filter((f) => f.trim().length > 0); } catch { // Ignore errors getting affected files } return { isMergeCommit: true, mergeAffectedFiles }; } catch { return { isMergeCommit: false, mergeAffectedFiles: [] }; } } /** * Detect the current merge state of a git repository. * Checks for .git/MERGE_HEAD, .git/rebase-merge, .git/rebase-apply, * and .git/CHERRY_PICK_HEAD to determine if a merge/rebase/cherry-pick * is in progress. * * @param repoPath - Path to the git repository or worktree * @returns MergeStateInfo describing the current merge state */ export async function detectMergeState(repoPath: string): Promise { const defaultState: MergeStateInfo = { isMerging: false, mergeOperationType: null, isCleanMerge: false, mergeAffectedFiles: [], conflictFiles: [], }; try { // Find the actual .git directory (handles worktrees with .git file pointing to main repo) const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { cwd: repoPath }); const gitDir = path.resolve(repoPath, gitDirRaw.trim()); // Check for merge/rebase/cherry-pick indicators let mergeOperationType: 'merge' | 'rebase' | 'cherry-pick' | null = null; const checks = [ { file: 'MERGE_HEAD', type: 'merge' as const }, { file: 'rebase-merge', type: 'rebase' as const }, { file: 'rebase-apply', type: 'rebase' as const }, { file: 'CHERRY_PICK_HEAD', type: 'cherry-pick' as const }, ]; for (const check of checks) { try { await fs.access(path.join(gitDir, check.file)); mergeOperationType = check.type; break; } catch { // File doesn't exist, continue checking } } if (!mergeOperationType) { return defaultState; } // Get unmerged files (files with conflicts) let conflictFiles: string[] = []; try { const { stdout: diffOutput } = await execAsync('git diff --name-only --diff-filter=U', { cwd: repoPath, }); conflictFiles = diffOutput .trim() .split('\n') .filter((f) => f.trim().length > 0); } catch { // Ignore errors getting conflict files } // Get all files affected by the merge (staged files that came from the merge) let mergeAffectedFiles: string[] = []; try { const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: repoPath, }); const files = parseGitStatus(statusOutput); mergeAffectedFiles = files .filter((f) => f.isMergeAffected || (f.indexStatus !== ' ' && f.indexStatus !== '?')) .map((f) => f.path); } catch { // Ignore errors } return { isMerging: true, mergeOperationType, isCleanMerge: conflictFiles.length === 0, mergeAffectedFiles, conflictFiles, }; } catch { return defaultState; } }