flowgit/electron/git/history.ts

320 lines
9.6 KiB
TypeScript

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<Commit[]> {
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<CommitDetail | null> {
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<GraphData> {
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<string, number>()
const hashToColumn = new Map<string, number>()
const lanes: (string | null)[] = [] // lane[col] = hash of commit currently occupying that lane
// Assign branch colors based on refs
const branchColorMap = new Map<string, string>()
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,
}
}