import { execSync } from "node:child_process"; import * as os from "node:os"; import * as path from "node:path"; import * as fs from "node:fs"; import { GitContext } from "../core/git-context.js"; function createTempRepo(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-test-")); execSync("git init", { cwd: dir, stdio: "pipe" }); execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" }); execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" }); return dir; } function commit(repoDir: string, message: string): void { const filePath = path.join(repoDir, `file-${Date.now()}.txt`); fs.writeFileSync(filePath, "test", "utf-8"); execSync(`git add "${filePath}"`, { cwd: repoDir, stdio: "pipe" }); execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: repoDir, stdio: "pipe" }); } function cleanup(dir: string): void { fs.rmSync(dir, { recursive: true, force: true }); } describe("GitContext", () => { let repoDir: string; beforeEach(() => { repoDir = createTempRepo(); }); afterEach(() => { cleanup(repoDir); }); describe("isGitRepo", () => { it("returns true for a git repo", () => { const ctx = new GitContext(repoDir); expect(ctx.isGitRepo()).toBe(true); }); it("returns false for non-git directory", () => { const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-nongit-")); const ctx = new GitContext(nonGit); expect(ctx.isGitRepo()).toBe(false); cleanup(nonGit); }); }); describe("getCurrentBranch", () => { it("returns current branch name", () => { const ctx = new GitContext(repoDir); commit(repoDir, "initial"); expect(ctx.getCurrentBranch()).toBeTruthy(); }); }); describe("projectSlug", () => { it("defaults to undefined", () => { const ctx = new GitContext(repoDir); expect(ctx.getProjectSlug()).toBeUndefined(); }); it("accepts project slug in constructor", () => { const ctx = new GitContext(repoDir, "task-api"); expect(ctx.getProjectSlug()).toBe("task-api"); }); it("setProjectSlug updates slug", () => { const ctx = new GitContext(repoDir); ctx.setProjectSlug("auth-svc"); expect(ctx.getProjectSlug()).toBe("auth-svc"); }); }); describe("getRecentCommits", () => { it("returns parsed commits with ci blocks", () => { commit(repoDir, `docs(init): initialize project ---ci--- phase: 0 milestone: v1.0 status: specify ---/ci--- Initial commit`); const ctx = new GitContext(repoDir); const commits = ctx.getRecentCommits(5); expect(commits.length).toBeGreaterThanOrEqual(1); const ciCommit = commits.find((c) => c.ci !== null); expect(ciCommit).toBeDefined(); expect(ciCommit!.ci!.phase).toBe(0); expect(ciCommit!.ci!.milestone).toBe("v1.0"); }); it("returns commits without ci blocks as null ci", () => { commit(repoDir, "feat: some feature"); const ctx = new GitContext(repoDir); const commits = ctx.getRecentCommits(5); expect(commits.length).toBeGreaterThanOrEqual(1); expect(commits[0].ci).toBeNull(); }); }); describe("reconstructState", () => { it("reconstructs state from latest ci commit", () => { commit(repoDir, `docs(init): initialize project ---ci--- phase: 1 milestone: v1.0 status: execute ---/ci---`); const ctx = new GitContext(repoDir); const state = ctx.reconstructState(); expect(state.currentPhase).toBe(1); expect(state.currentMilestone).toBe("v1.0"); expect(state.currentStage).toBe("execute"); }); it("returns default state without ci commits", () => { commit(repoDir, "feat: some regular feature"); const ctx = new GitContext(repoDir); const state = ctx.reconstructState(); expect(state.currentPhase).toBe(0); expect(state.currentStage).toBe("specify"); }); }); describe("getDecisionsFromCommits", () => { it("extracts decisions from parsed commits", () => { commit(repoDir, `feat(P01): add auth ---ci--- phase: 1 milestone: v1.0 status: execute decisions: - id: D-001 decision: Use bcrypt rationale: Industry standard confidence: 0.90 alternatives: [argon2] ---/ci---`); const ctx = new GitContext(repoDir); const commits = ctx.getRecentCommits(5); const decisions = ctx.getDecisionsFromCommits(commits); expect(decisions).toHaveLength(1); expect(decisions[0].id).toBe("D-001"); expect(decisions[0].decision).toBe("Use bcrypt"); }); }); describe("getLessons", () => { it("extracts lessons from commits", () => { commit(repoDir, `docs(P01): complete phase ---ci--- phase: 1 milestone: v1.0 status: complete lessons: - Never use sync bcrypt - Always check JWT expiry first ---/ci---`); const ctx = new GitContext(repoDir); const commits = ctx.getRecentCommits(10); const lessons: string[] = []; for (const commit of commits) { if (commit.ci?.lessons) lessons.push(...commit.ci.lessons); } expect(lessons).toContain("Never use sync bcrypt"); expect(lessons).toContain("Always check JWT expiry first"); }); }); describe("getBranches", () => { it("lists branches with type info", () => { commit(repoDir, "initial"); execSync("git checkout -b phase/01-auth", { cwd: repoDir, stdio: "pipe" }); commit(repoDir, "feat: auth work"); execSync("git checkout -b milestone/v1.0-mvp", { cwd: repoDir, stdio: "pipe" }); commit(repoDir, "feat: milestone work"); const ctx = new GitContext(repoDir); const branches = ctx.getBranches(); const phaseBranches = branches.filter((b) => b.type === "phase"); const milestoneBranches = branches.filter((b) => b.type === "milestone"); expect(phaseBranches.length).toBeGreaterThanOrEqual(1); expect(milestoneBranches.length).toBeGreaterThanOrEqual(1); expect(phaseBranches[0].phaseNumber).toBe(1); }); it("strips project prefix when projectSlug is set", () => { commit(repoDir, "initial"); execSync("git checkout -b task-api/phase/01-auth", { cwd: repoDir, stdio: "pipe" }); commit(repoDir, "feat: auth work"); const ctx = new GitContext(repoDir, "task-api"); const branches = ctx.getBranches(); const phaseBranches = branches.filter((b) => b.type === "phase"); expect(phaseBranches.length).toBeGreaterThanOrEqual(1); expect(phaseBranches[0].phaseNumber).toBe(1); expect(phaseBranches[0].name).toBe("task-api/phase/01-auth"); }); }); describe("detectProjectFromCommit", () => { it("detects project from ci block project field", () => { commit(repoDir, `feat(P01): task work ---ci--- phase: 1 milestone: v1.0 project: task-api status: execute ---/ci---`); const ctx = new GitContext(repoDir); expect(ctx.detectProjectFromCommit()).toBe("task-api"); }); it("detects project from branch prefix", () => { commit(repoDir, "initial"); execSync("git checkout -b auth-svc/phase/01-auth", { cwd: repoDir, stdio: "pipe" }); commit(repoDir, "feat: auth work"); const ctx = new GitContext(repoDir); expect(ctx.detectProjectFromCommit()).toBe("auth-svc"); }); it("returns null when no project detected", () => { commit(repoDir, "feat: some work"); const ctx = new GitContext(repoDir); expect(ctx.detectProjectFromCommit()).toBeNull(); }); }); describe("isNfrMilestone", () => { it("returns true when no feat commits exist", () => { commit(repoDir, `chore(P01): cleanup ---ci--- phase: 1 milestone: v0.1.1 status: execute ---/ci---`); const ctx = new GitContext(repoDir); expect(ctx.isNfrMilestone()).toBe(true); }); it("returns false when feat commits exist", () => { commit(repoDir, `feat(P01): add feature ---ci--- phase: 1 milestone: v1.0 status: execute ---/ci---`); const ctx = new GitContext(repoDir); expect(ctx.isNfrMilestone()).toBe(false); }); }); describe("getMilestoneType", () => { it("returns nfr when only NFR commits exist", () => { commit(repoDir, `chore(P01): cleanup ---ci--- phase: 1 milestone: v0.1.1 status: execute ---/ci---`); const ctx = new GitContext(repoDir); expect(ctx.getMilestoneType()).toBe("nfr"); }); it("returns feature when feat commits exist", () => { commit(repoDir, `feat(P01): add feature ---ci--- phase: 1 milestone: v1.0 status: execute ---/ci---`); const ctx = new GitContext(repoDir); expect(ctx.getMilestoneType()).toBe("feature"); }); it("returns schema-breaking when refactor commits exist", () => { commit(repoDir, `refactor(P01): rewrite core ---ci--- phase: 1 milestone: v0.5 status: execute ---/ci---`); const ctx = new GitContext(repoDir); expect(ctx.getMilestoneType()).toBe("schema-breaking"); }); }); });