v0.2.0: Git-native architecture (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-05-29 12:59:45 +00:00
parent 9cf5c000d9
commit 6e637e4af0
50 changed files with 5852 additions and 135 deletions
+314
View File
@@ -0,0 +1,314 @@
import { execSync } from "node:child_process";
import {
ParsedCiCommit,
CiMetadata,
CommitDecision,
} from "../types/commit-meta.js";
import { parseCommitMessage } from "./commit-parser.js";
import { PipelineStage } from "../types/pipeline.js";
export interface ProjectState {
currentPhase: number;
currentMilestone: string;
currentStage: PipelineStage;
phasesCompleted: number[];
phaseBranches: BranchInfo[];
milestoneBranches: string[];
lastCommit: ParsedCiCommit | null;
}
export interface BranchInfo {
name: string;
type: "phase" | "milestone" | "hotfix" | "other";
phaseNumber?: number;
milestone?: string;
merged: boolean;
}
export class GitContext {
private projectPath: string;
constructor(projectPath: string) {
this.projectPath = projectPath;
}
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): ParsedCiCommit[] {
const format = "%H%x00%s%x00%B%x01";
const raw = this.git(`log --max-count=${count} --format="${format}"`);
if (!raw) return [];
const commits: ParsedCiCommit[] = [];
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(): ParsedCiCommit | 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),
};
const phaseMatch = cleanName.match(/^phase\/(\d+)-(.+)/);
if (phaseMatch) {
info.type = "phase";
info.phaseNumber = parseInt(phaseMatch[1], 10);
return info;
}
const milestoneMatch = cleanName.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: ParsedCiCommit[], 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<string>();
const partial = new Set<string>();
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): ParsedCiCommit[] {
const commits = this.getRecentCommits(200);
return commits.filter(
(c) => c.scope === `P${String(phase).padStart(2, "0")}` || c.ci?.phase === phase
);
}
getCommitsForBranch(branch: string): ParsedCiCommit[] {
const format = "%H%x00%s%x00%B%x01";
const raw = this.git(`log ${branch} --max-count=100 --format="${format}"`);
if (!raw) return [];
const commits: ParsedCiCommit[] = [];
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;
}
}