flowgit/electron/git/changes.ts

174 lines
6.2 KiB
TypeScript

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<RepoStatus> {
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<void> {
await execGitOrThrow(['add', '--', filePath], { cwd: repoPath })
}
/** Unstage a file. */
export async function unstageFile(repoPath: string, filePath: string): Promise<void> {
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<void> {
await execGitOrThrow(['add', '-A'], { cwd: repoPath })
}
/** Unstage all changes. */
export async function unstageAll(repoPath: string): Promise<void> {
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<void> {
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<void> {
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<void> {
const args = ['apply', '--cached', '--whitespace=nowarn']
if (reverse) args.push('--reverse')
args.push('-')
await execGitOrThrow(args, { cwd: repoPath, stdin: patch })
}