import { execGit, execGitOrThrow } from './executor' import type { Commit, CommitDetail, CommitFile, GraphData, GraphNode, GraphEdge } from '../../shared/types/git.types' import { createLogger } from '../logger' const logger = createLogger('git:history') const LOG_FORMAT = '%H%n%h%n%s%n%b%n%aN%n%aE%n%aI%n%cN%n%cE%n%cI%n%P%n%D%n---COMMIT---' export interface LogOptions { skip?: number limit?: number ref?: string author?: string search?: string path?: string } /** * Get commit log. */ export async function getLog(repoPath: string, options: LogOptions = {}): Promise { const args = ['log', `--format=${LOG_FORMAT}`, '--no-walk=unsorted'] if (options.limit) args.push(`-${options.limit}`) if (options.skip) args.push(`--skip=${options.skip}`) if (options.author) args.push(`--author=${options.author}`) if (options.search) args.push(`--grep=${options.search}`, '--regexp-ignore-case') if (options.ref) args.push(options.ref) else args.push('--all') if (options.path) args.push('--', options.path) // Remove --no-walk for actual log const actualArgs = args.filter(a => a !== '--no-walk=unsorted') actualArgs.splice(1, 0, '--all') const result = await execGit( ['log', `--format=${LOG_FORMAT}`, '--all', ...(options.limit ? [`-${options.limit}`] : []), ...(options.skip ? [`--skip=${options.skip}`] : []), ...(options.author ? [`--author=${options.author}`] : []), ...(options.search ? [`--grep=${options.search}`, '--regexp-ignore-case'] : []), ...(options.ref ? [options.ref] : []), ...(options.path ? ['--', options.path] : []), ], { cwd: repoPath } ) if (result.exitCode !== 0) return [] return parseLog(result.stdout) } function parseLog(output: string): Commit[] { const commits: Commit[] = [] const entries = output.split('---COMMIT---\n').filter(Boolean) for (const entry of entries) { const lines = entry.split('\n') if (lines.length < 11) continue const hash = lines[0].trim() const shortHash = lines[1].trim() const message = lines[2].trim() const body = lines[3].trim() const author = lines[4].trim() const authorEmail = lines[5].trim() const authorDate = lines[6].trim() const committer = lines[7].trim() const committerEmail = lines[8].trim() const committerDate = lines[9].trim() const parents = lines[10].trim().split(' ').filter(Boolean) const refStr = lines[11]?.trim() ?? '' const refs = refStr ? refStr.split(', ').map(r => r.trim()).filter(Boolean) : [] if (!hash) continue commits.push({ hash, shortHash, message, body, author, authorEmail, authorDate, committer, committerEmail, committerDate, parents, refs, isMerge: parents.length > 1, }) } return commits } /** * Get detailed commit info including file stats. */ export async function getCommitDetail(repoPath: string, hash: string): Promise { const [infoResult, filesResult] = await Promise.all([ execGit(['log', '--format=%H%n%h%n%s%n%b%n%aN%n%aE%n%aI%n%cN%n%cE%n%cI%n%P%n%D', '-1', hash], { cwd: repoPath }), execGit(['diff-tree', '--no-commit-id', '-r', '--name-status', '-M', '-C', '--numstat', hash], { cwd: repoPath }), ]) if (infoResult.exitCode !== 0) return null const infoLines = infoResult.stdout.split('\n') const hash_ = infoLines[0].trim() const files = parseCommitFiles(filesResult.stdout) const stats = { additions: files.reduce((s, f) => s + f.additions, 0), deletions: files.reduce((s, f) => s + f.deletions, 0), files: files.length, } return { hash: hash_, shortHash: infoLines[1].trim(), message: infoLines[2].trim(), body: infoLines[3].trim(), author: infoLines[4].trim(), authorEmail: infoLines[5].trim(), authorDate: infoLines[6].trim(), committer: infoLines[7].trim(), committerEmail: infoLines[8].trim(), committerDate: infoLines[9].trim(), parents: infoLines[10].trim().split(' ').filter(Boolean), refs: infoLines[11]?.trim().split(', ').filter(Boolean) ?? [], isMerge: infoLines[10].trim().split(' ').filter(Boolean).length > 1, files, stats, } } function parseCommitFiles(output: string): CommitFile[] { const files: CommitFile[] = [] const lines = output.trim().split('\n').filter(Boolean) for (const line of lines) { const parts = line.split('\t') if (parts.length < 2) continue const stats = parts[0].split('\t') // numstat: additions\tdeletions\tpath or additions\tdeletions\t{old => new} if (parts.length === 3 && /^\d+/.test(parts[0])) { const additions = parseInt(parts[0], 10) || 0 const deletions = parseInt(parts[1], 10) || 0 const path = parts[2] files.push({ path, status: 'modified', additions, deletions }) } } return files } // BRANCH COLOR PALETTE — distinct, dark-theme friendly const BRANCH_COLORS = [ '#4a9eff', // blue '#da5c2c', // ember (FlowGit accent) '#50c878', // green '#b47aff', // purple '#ff9f43', // orange '#ff6b9d', // pink '#26c6da', // cyan '#ffeb3b', // yellow ] /** * Build a graph data structure for the Blueprint-inspired visualization. * Uses a topological column-assignment algorithm. */ export async function getGraphData(repoPath: string, limit = 500): Promise { const result = await execGit( ['log', '--all', '--format=%H%n%P%n%s%n%aN%n%aI%n%D', `-${limit}`], { cwd: repoPath } ) if (result.exitCode !== 0 || !result.stdout.trim()) { return { nodes: [], edges: [], columns: 0, totalRows: 0 } } // Parse simplified log interface RawCommit { hash: string parents: string[] message: string author: string date: string refs: string[] } const rawCommits: RawCommit[] = [] const blocks = result.stdout.trim().split('\n\n').filter(Boolean) for (const block of blocks) { const lines = block.split('\n') rawCommits.push({ hash: lines[0]?.trim() ?? '', parents: (lines[1]?.trim() ?? '').split(' ').filter(Boolean), message: lines[2]?.trim() ?? '', author: lines[3]?.trim() ?? '', date: lines[4]?.trim() ?? '', refs: (lines[5]?.trim() ?? '').split(', ').filter(Boolean), }) } // Also parse with our main log parser for robustness const commits = await getLog(repoPath, { limit }) if (!commits.length) return { nodes: [], edges: [], columns: 0, totalRows: 0 } // Get HEAD const headResult = await execGit(['rev-parse', 'HEAD'], { cwd: repoPath }) const headHash = headResult.exitCode === 0 ? headResult.stdout.trim() : null // Assign columns using a simple lane algorithm const nodes: GraphNode[] = [] const edges: GraphEdge[] = [] const hashToRow = new Map() const hashToColumn = new Map() const lanes: (string | null)[] = [] // lane[col] = hash of commit currently occupying that lane // Assign branch colors based on refs const branchColorMap = new Map() let colorIndex = 0 function getBranchColor(branchName: string): string { if (!branchColorMap.has(branchName)) { branchColorMap.set(branchName, BRANCH_COLORS[colorIndex % BRANCH_COLORS.length]) colorIndex++ } return branchColorMap.get(branchName)! } for (let row = 0; row < commits.length; row++) { const commit = commits[row] hashToRow.set(commit.hash, row) // Find which lane this commit is in (if a child already reserved a lane for it) let col = hashToColumn.get(commit.hash) ?? -1 if (col === -1) { // Find first empty lane col = lanes.findIndex((l) => l === null) if (col === -1) col = lanes.length lanes[col] = commit.hash hashToColumn.set(commit.hash, col) } // Determine color const branchRef = commit.refs.find(r => !r.startsWith('HEAD') && !r.startsWith('tag:')) const color = branchRef ? getBranchColor(branchRef) : BRANCH_COLORS[col % BRANCH_COLORS.length] nodes.push({ hash: commit.hash, shortHash: commit.shortHash, message: commit.message, author: commit.author, authorDate: commit.authorDate, parents: commit.parents, children: [], refs: commit.refs, column: col, row, color, isMerge: commit.isMerge, isHead: commit.hash === headHash, }) // Mark lane as free if no parents in this lane if (commit.parents.length === 0) { lanes[col] = null } // Assign lanes for parents for (let pi = 0; pi < commit.parents.length; pi++) { const parent = commit.parents[pi] if (!hashToColumn.has(parent)) { if (pi === 0) { // First parent stays in same lane hashToColumn.set(parent, col) lanes[col] = parent } else { // Subsequent parents (merge) get new lane const newCol = lanes.findIndex((l) => l === null) const mergeCol = newCol === -1 ? lanes.length : newCol hashToColumn.set(parent, mergeCol) lanes[mergeCol] = parent } } } } // Build child relationships and edges const nodeMap = new Map(nodes.map(n => [n.hash, n])) for (const node of nodes) { for (const parentHash of node.parents) { const parentNode = nodeMap.get(parentHash) if (parentNode) { parentNode.children.push(node.hash) } edges.push({ from: node.hash, to: parentHash, color: node.color, type: node.isMerge && node.parents.indexOf(parentHash) > 0 ? 'merge' : 'parent', }) } } const maxCol = nodes.reduce((m, n) => Math.max(m, n.column), 0) return { nodes, edges, columns: maxCol + 1, totalRows: nodes.length, } }