| |
| |
| |
|
|
| 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); |
|
|
| |
| |
| |
| |
| function getStatusText(indexStatus: string, workTreeStatus: string): string { |
| |
| if (indexStatus === '?' && workTreeStatus === '?') { |
| return 'Untracked'; |
| } |
|
|
| |
| if (indexStatus === '!' && workTreeStatus === '!') { |
| return 'Ignored'; |
| } |
|
|
| |
| const primaryStatus = indexStatus !== ' ' && indexStatus !== '?' ? indexStatus : workTreeStatus; |
|
|
| |
| if ( |
| indexStatus !== ' ' && |
| indexStatus !== '?' && |
| workTreeStatus !== ' ' && |
| workTreeStatus !== '?' |
| ) { |
| |
| 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'; |
| } |
|
|
| |
| |
| |
| export async function isGitRepo(repoPath: string): Promise<boolean> { |
| try { |
| await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath }); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export function parseGitStatus(statusOutput: string): FileStatus[] { |
| return statusOutput |
| .split('\n') |
| .filter(Boolean) |
| .map((line) => { |
| |
| |
| |
| const indexStatus = line[0] || ' '; |
| const workTreeStatus = line[1] || ' '; |
|
|
| |
| let filePath = line.slice(3); |
|
|
| |
| if (indexStatus === 'R' || workTreeStatus === 'R') { |
| const arrowIndex = filePath.indexOf(' -> '); |
| if (arrowIndex !== -1) { |
| filePath = filePath.slice(arrowIndex + 4); |
| } |
| } |
|
|
| |
| |
| let primaryStatus: string; |
| if (indexStatus === '?' && workTreeStatus === '?') { |
| primaryStatus = '?'; |
| } else if (indexStatus !== ' ' && indexStatus !== '?') { |
| primaryStatus = indexStatus; |
| } else { |
| primaryStatus = workTreeStatus; |
| } |
|
|
| |
| |
| const isMergeAffected = |
| indexStatus === 'U' || |
| workTreeStatus === 'U' || |
| (indexStatus === 'A' && workTreeStatus === 'A') || |
| (indexStatus === 'D' && workTreeStatus === 'D'); |
|
|
| 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 }), |
| }; |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function detectMergeCommit( |
| repoPath: string |
| ): Promise<{ isMergeCommit: boolean; mergeAffectedFiles: string[] }> { |
| try { |
| |
| |
| try { |
| await execAsync('git rev-parse --verify "HEAD^2"', { cwd: repoPath }); |
| } catch { |
| |
| return { isMergeCommit: false, mergeAffectedFiles: [] }; |
| } |
|
|
| |
| 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 { |
| |
| } |
|
|
| return { isMergeCommit: true, mergeAffectedFiles }; |
| } catch { |
| return { isMergeCommit: false, mergeAffectedFiles: [] }; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function detectMergeState(repoPath: string): Promise<MergeStateInfo> { |
| const defaultState: MergeStateInfo = { |
| isMerging: false, |
| mergeOperationType: null, |
| isCleanMerge: false, |
| mergeAffectedFiles: [], |
| conflictFiles: [], |
| }; |
|
|
| try { |
| |
| const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { cwd: repoPath }); |
| const gitDir = path.resolve(repoPath, gitDirRaw.trim()); |
|
|
| |
| 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 { |
| |
| } |
| } |
|
|
| if (!mergeOperationType) { |
| return defaultState; |
| } |
|
|
| |
| 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 { |
| |
| } |
|
|
| |
| 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 { |
| |
| } |
|
|
| return { |
| isMerging: true, |
| mergeOperationType, |
| isCleanMerge: conflictFiles.length === 0, |
| mergeAffectedFiles, |
| conflictFiles, |
| }; |
| } catch { |
| return defaultState; |
| } |
| } |
|
|