import { execSync } from "node:child_process"; import { ParsedCIAgentCommit, CIAgentMetadata, CommitDecision, } from "../types/commit-meta.js"; import { parseCommitMessage } from "./commit-parser.js"; import { PipelineStage } from "../types/pipeline.js"; import { MilestoneType } from "../types/config.js"; export interface ProjectState { currentPhase: number; currentMilestone: string; currentStage: PipelineStage; phasesCompleted: number[]; phaseBranches: BranchInfo[]; milestoneBranches: string[]; lastCommit: ParsedCIAgentCommit | null; } export interface BranchInfo { name: string; type: "phase" | "milestone" | "hotfix" | "other"; phaseNumber?: number; milestone?: string; merged: boolean; } export class GitContext { private projectPath: string; private projectSlug?: string; constructor(projectPath: string, projectSlug?: string) { this.projectPath = projectPath; this.projectSlug = projectSlug; } setProjectSlug(slug: string | undefined): void { this.projectSlug = slug; } getProjectSlug(): string | undefined { return this.projectSlug; } private git(args: string): string { try { return execSync(`git ${args}`, { cwd: this.projectPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim(); } catch { return ""; } } private gitLines(args: string): string[] { const result = this.git(args); return result ? result.split("\n").filter(Boolean) : []; } isGitRepo(): boolean { return this.git("rev-parse --is-inside-work-tree") === "true"; } getCurrentBranch(): string { return this.git("rev-parse --abbrev-ref HEAD"); } getRecentCommits(count: number = 20): ParsedCIAgentCommit[] { const format = "%H%x00%s%x00%B%x01"; const raw = this.git(`log --max-count=${count} --format="${format}"`); if (!raw) return []; const commits: ParsedCIAgentCommit[] = []; const entries = raw.split("\x01").filter(Boolean); for (const entry of entries) { const parts = entry.split("\x00"); if (parts.length < 3) continue; const hash = parts[0].trim(); const subject = parts[1].trim(); const body = parts[2].trim(); const fullMessage = body || subject; commits.push(parseCommitMessage(hash, fullMessage)); } return commits; } getLatestCiCommit(): ParsedCIAgentCommit | null { const commits = this.getRecentCommits(1); return commits.length > 0 ? commits[0] : null; } getBranches(): BranchInfo[] { const branches = this.gitLines("branch -a --format='%(refname:short)'"); const mergedBranches = new Set(this.gitLines("branch --merged --format='%(refname:short)'")); return branches.map((name) => { const cleanName = name.replace(/^remotes\/origin\//, ""); const info: BranchInfo = { name: cleanName, type: "other", merged: mergedBranches.has(cleanName), }; let branchName = cleanName; const projectPrefix = this.projectSlug ? `${this.projectSlug}/` : ""; if (projectPrefix && cleanName.startsWith(projectPrefix)) { branchName = cleanName.slice(projectPrefix.length); } const phaseMatch = branchName.match(/^phase\/(\d+)-(.+)/); if (phaseMatch) { info.type = "phase"; info.phaseNumber = parseInt(phaseMatch[1], 10); return info; } const milestoneMatch = branchName.match(/^milestone\/(.+)/); if (milestoneMatch) { info.type = "milestone"; info.milestone = milestoneMatch[1]; return info; } if (cleanName.startsWith("hotfix/")) { info.type = "hotfix"; } return info; }); } getPhaseBranches(): BranchInfo[] { return this.getBranches().filter((b) => b.type === "phase"); } getMilestoneBranches(): BranchInfo[] { return this.getBranches().filter((b) => b.type === "milestone"); } reconstructState(): ProjectState { const latestCommit = this.getLatestCiCommit(); const branches = this.getBranches(); const phaseBranches = branches.filter((b) => b.type === "phase"); const milestoneBranches = branches.filter((b) => b.type === "milestone"); const phasesCompleted = phaseBranches .filter((b) => b.merged) .map((b) => b.phaseNumber!) .filter(Boolean); let currentPhase = 0; let currentMilestone = ""; let currentStage: PipelineStage = "specify"; if (latestCommit?.ci) { currentPhase = latestCommit.ci.phase; currentMilestone = latestCommit.ci.milestone; currentStage = latestCommit.ci.status; } if (!currentMilestone && milestoneBranches.length > 0) { const activeMilestone = milestoneBranches.find((b) => !b.merged); if (activeMilestone) currentMilestone = activeMilestone.milestone || ""; } return { currentPhase, currentMilestone, currentStage, phasesCompleted, phaseBranches, milestoneBranches: milestoneBranches.map((b) => b.name), lastCommit: latestCommit, }; } getDecisions(phase?: number): CommitDecision[] { const grepArg = phase !== undefined ? `--grep="phase: ${phase}"` : '--grep="decisions:"'; const raw = this.git(`log --all ${grepArg} --format="%B%x01"`); if (!raw) return []; const decisions: CommitDecision[] = []; const entries = raw.split("\x01").filter(Boolean); for (const entry of entries) { const commits = this.getRecentCommits(50); for (const commit of commits) { if (commit.ci?.decisions) { if (phase === undefined || commit.ci.phase === phase) { decisions.push(...commit.ci.decisions); } } } } return decisions; } getDecisionsFromCommits(commits: ParsedCIAgentCommit[], phase?: number): CommitDecision[] { const decisions: CommitDecision[] = []; for (const commit of commits) { if (commit.ci?.decisions) { if (phase === undefined || commit.ci.phase === phase) { decisions.push(...commit.ci.decisions); } } } return decisions; } getLessons(phase?: number): string[] { const commits = this.getRecentCommits(100); const lessons: string[] = []; for (const commit of commits) { if (commit.ci?.lessons) { if (phase === undefined || commit.ci.phase === phase) { lessons.push(...commit.ci.lessons); } } } return lessons; } getCompounds(category?: string): Array<{ category: string; problem: string; solution: string; phase: number; }> { const commits = this.getRecentCommits(100); const compounds: Array<{ category: string; problem: string; solution: string; phase: number }> = []; for (const commit of commits) { if (commit.ci?.compound) { if (!category || commit.ci.compound.category === category) { compounds.push({ ...commit.ci.compound, phase: commit.ci.phase, }); } } } return compounds; } getEscalations(): Array<{ id: string; type: string; description: string; resolution: string; phase: number; }> { const commits = this.getRecentCommits(100); const escalations: Array<{ id: string; type: string; description: string; resolution: string; phase: number }> = []; for (const commit of commits) { if (commit.ci?.escalations) { for (const esc of commit.ci.escalations) { escalations.push({ ...esc, phase: commit.ci.phase }); } } } return escalations; } getRequirementsCoverage(): { covered: string[]; partial: string[] } { const commits = this.getRecentCommits(100); const covered = new Set(); const partial = new Set(); for (const commit of commits) { if (commit.ci?.requirements) { for (const req of commit.ci.requirements.covered) covered.add(req); for (const req of commit.ci.requirements.partial) partial.add(req); } } for (const req of covered) { partial.delete(req); } return { covered: [...covered].sort(), partial: [...partial].sort(), }; } getCommitsForPhase(phase: number): ParsedCIAgentCommit[] { const commits = this.getRecentCommits(200); return commits.filter( (c) => c.scope === `P${String(phase).padStart(2, "0")}` || c.ci?.phase === phase ); } getCommitsForBranch(branch: string): ParsedCIAgentCommit[] { const format = "%H%x00%s%x00%B%x01"; const raw = this.git(`log ${branch} --max-count=100 --format="${format}"`); if (!raw) return []; const commits: ParsedCIAgentCommit[] = []; const entries = raw.split("\x01").filter(Boolean); for (const entry of entries) { const parts = entry.split("\x00"); if (parts.length < 3) continue; const hash = parts[0].trim(); const subject = parts[1].trim(); const body = parts[2].trim(); const fullMessage = body || subject; commits.push(parseCommitMessage(hash, fullMessage)); } return commits; } detectProjectFromCommit(): string | null { const commit = this.getLatestCiCommit(); if (commit?.ci?.project) return commit.ci.project; const branches = this.getBranches(); for (const branch of branches) { const projectMatch = branch.name.match(/^([a-z0-9-]+)\/(?:phase|milestone)\//); if (projectMatch) return projectMatch[1]; } return null; } getMilestoneType(): MilestoneType { const commits = this.getRecentCommits(100); let hasAnyCiCommit = false; for (const commit of commits) { if (!commit.ci) continue; hasAnyCiCommit = true; if (commit.type === "feat") return "feature"; if (commit.type === "refactor" || commit.scope === "init") return "schema-breaking"; } if (!hasAnyCiCommit) return "nfr"; return "nfr"; } isNfrMilestone(): boolean { return this.getMilestoneType() === "nfr"; } }