import { exec } from "node:child_process"; import { promisify } from "node:util"; const execAsync = promisify(exec); export interface GitCommit { hash: string; date: Date; message: string; author: string; authorEmail: string; filesChanged: string[]; additions: number; deletions: number; } /** * Get the current git user's email (for filtering commits) */ export async function getGitUserEmail(): Promise { try { const { stdout } = await execAsync("git config --global user.email", { timeout: 5000, }); return stdout.trim() || null; } catch { return null; } } /** * Get git commits from a repository */ export async function getGitCommits( repoPath: string, options: { since?: Date; until?: Date; authorEmail?: string; maxCommits?: number; } = {}, ): Promise { const { since, until, authorEmail, maxCommits = 1000 } = options; // Build git log command const args = [ "log", "--all", `--max-count=${maxCommits}`, "--format=COMMIT_START%n%H%n%aI%n%ae%n%an%n%s%nCOMMIT_END", "--numstat", ]; if (since) { args.push(`--since="${since.toISOString()}"`); } if (until) { args.push(`--until="${until.toISOString()}"`); } if (authorEmail) { args.push(`--author="${authorEmail}"`); } try { const { stdout } = await execAsync(`git ${args.join(" ")}`, { cwd: repoPath, maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large repos timeout: 60000, // 60 second timeout }); return parseGitLog(stdout); } catch (error) { console.error(`Failed to get git commits from ${repoPath}:`, error); return []; } } /** * Parse git log output into structured commits */ function parseGitLog(output: string): GitCommit[] { const commits: GitCommit[] = []; const blocks = output.split("COMMIT_START\n").filter(Boolean); for (const block of blocks) { const endIndex = block.indexOf("COMMIT_END"); if (endIndex === -1) continue; const headerPart = block.slice(0, endIndex); const statsPart = block.slice(endIndex + "COMMIT_END\n".length); const lines = headerPart.split("\n"); if (lines.length < 5) continue; const [hash, dateStr, authorEmail, authorName, ...messageParts] = lines; const message = messageParts.join("\n").trim(); // Parse numstat (additions deletions filepath) const filesChanged: string[] = []; let additions = 0; let deletions = 0; for (const statLine of statsPart.split("\n")) { if (!statLine.trim()) continue; const [add, del, filepath] = statLine.split("\t"); if (filepath) { filesChanged.push(filepath); // Binary files show as "-" for additions/deletions if (add !== "-") additions += parseInt(add, 10) || 0; if (del !== "-") deletions += parseInt(del, 10) || 0; } } commits.push({ hash, date: new Date(dateStr), message, author: authorName, authorEmail, filesChanged, additions, deletions, }); } return commits; } /** * Calculate estimated work time based on commit complexity * Returns estimated minutes */ export function estimateCommitMinutes(commit: GitCommit): number { const { filesChanged, additions, deletions } = commit; // Base complexity score const linesChanged = additions + deletions; const fileCount = filesChanged.length; // Scoring factors const lineScore = Math.min(linesChanged / 50, 10); // 50 lines = 1 point, max 10 const fileScore = Math.min(fileCount * 0.5, 5); // 0.5 per file, max 5 // Base 15 minutes per commit, scaled by complexity const complexityMultiplier = 1 + (lineScore + fileScore) / 10; const minutes = Math.round(15 * complexityMultiplier); // Cap at 4 hours per commit (large refactors, etc.) return Math.min(minutes, 240); } /** * Get git commits grouped by date */ export async function getCommitsByDate( repoPath: string, options: { since?: Date; until?: Date; authorEmail?: string; } = {}, ): Promise> { const commits = await getGitCommits(repoPath, options); const byDate = new Map(); for (const commit of commits) { const dateKey = commit.date.toISOString().split("T")[0]; // YYYY-MM-DD const existing = byDate.get(dateKey) || []; existing.push(commit); byDate.set(dateKey, existing); } return byDate; }