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 { GitBranch } from "../core/git-branch.js"; function createTempRepo(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-branch-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" }); fs.writeFileSync(path.join(dir, "file.txt"), "initial", "utf-8"); execSync("git add . && git commit -m 'initial'", { cwd: dir, stdio: "pipe" }); return dir; } function cleanup(dir: string): void { fs.rmSync(dir, { recursive: true, force: true }); } describe("GitBranch", () => { let repoDir: string; beforeEach(() => { repoDir = createTempRepo(); }); afterEach(() => { cleanup(repoDir); }); describe("createPhaseBranch", () => { it("creates a phase branch with correct naming", () => { const gitBranch = new GitBranch(repoDir); const result = gitBranch.createPhaseBranch(1, "authentication"); expect(result.created).toBe(true); expect(result.name).toBe("phase/01-authentication"); }); it("detects already existing phase branch", () => { const gitBranch = new GitBranch(repoDir); gitBranch.createPhaseBranch(1, "authentication"); const result = gitBranch.createPhaseBranch(1, "authentication"); expect(result.alreadyExisted).toBe(true); expect(result.created).toBe(false); }); it("zero-pads phase numbers", () => { const gitBranch = new GitBranch(repoDir); const result = gitBranch.createPhaseBranch(3, "real-time-notifications"); expect(result.name).toBe("phase/03-real-time-notifications"); }); it("creates project-prefixed phase branch when projectSlug is set", () => { const gitBranch = new GitBranch(repoDir, "task-api"); const result = gitBranch.createPhaseBranch(1, "authentication"); expect(result.created).toBe(true); expect(result.name).toBe("task-api/phase/01-authentication"); }); it("updates project prefix after setProjectSlug", () => { const gitBranch = new GitBranch(repoDir); gitBranch.setProjectSlug("auth-svc"); const result = gitBranch.createPhaseBranch(2, "token-rotation"); expect(result.name).toBe("auth-svc/phase/02-token-rotation"); }); }); describe("createMilestoneBranch", () => { it("creates a milestone branch with correct naming", () => { const gitBranch = new GitBranch(repoDir); const result = gitBranch.createMilestoneBranch("v1.0", "mvp"); expect(result.created).toBe(true); expect(result.name).toBe("milestone/v1.0-mvp"); }); it("detects already existing milestone branch", () => { const gitBranch = new GitBranch(repoDir); gitBranch.createMilestoneBranch("v1.0", "mvp"); const result = gitBranch.createMilestoneBranch("v1.0", "mvp"); expect(result.alreadyExisted).toBe(true); }); it("creates project-prefixed milestone branch when projectSlug is set", () => { const gitBranch = new GitBranch(repoDir, "task-api"); const result = gitBranch.createMilestoneBranch("v1.0", "mvp"); expect(result.created).toBe(true); expect(result.name).toBe("task-api/milestone/v1.0-mvp"); }); }); describe("listPhases", () => { it("lists phase branches with status", () => { const gitBranch = new GitBranch(repoDir); gitBranch.createPhaseBranch(1, "auth"); gitBranch.createPhaseBranch(2, "tasks"); const phases = gitBranch.listPhases(); expect(phases.length).toBeGreaterThanOrEqual(2); const phase1 = phases.find((p) => p.phaseNumber === 1); expect(phase1).toBeDefined(); expect(phase1!.status).toBe("active"); }); }); describe("getPhaseStatus", () => { it("returns status for existing phase branch", () => { const gitBranch = new GitBranch(repoDir); gitBranch.createPhaseBranch(1, "auth"); const status = gitBranch.getPhaseStatus(1); expect(status).not.toBeNull(); expect(status!.phaseNumber).toBe(1); expect(status!.status).toBe("active"); }); it("returns null for non-existent phase", () => { const gitBranch = new GitBranch(repoDir); const status = gitBranch.getPhaseStatus(99); expect(status).toBeNull(); }); }); describe("slugify", () => { it("creates correct branch slugs", () => { const gitBranch = new GitBranch(repoDir); const result = gitBranch.createPhaseBranch(1, "Real-Time Notifications & Stuff!"); expect(result.name).toMatch(/^phase\/01-/); }); }); describe("validateMergeOrder", () => { it("rejects phase → main when milestone branch exists", () => { const gitBranch = new GitBranch(repoDir); gitBranch.createMilestoneBranch("v0.5", "baseline"); gitBranch.createPhaseBranch(1, "auth"); execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" }); const result = gitBranch.validateMergeOrder("phase/01-auth", "main"); expect(result.valid).toBe(false); expect(result.reason).toContain("milestone"); }); it("allows phase → milestone branch", () => { const gitBranch = new GitBranch(repoDir); gitBranch.createMilestoneBranch("v0.5", "baseline"); gitBranch.createPhaseBranch(1, "auth"); execSync(`git checkout milestone/v0.5-baseline`, { cwd: repoDir, stdio: "pipe" }); const result = gitBranch.validateMergeOrder("phase/01-auth", "milestone/v0.5-baseline"); expect(result.valid).toBe(true); }); it("allows phase → main when no milestone branch exists", () => { const gitBranch = new GitBranch(repoDir); gitBranch.createPhaseBranch(1, "auth"); execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" }); const result = gitBranch.validateMergeOrder("phase/01-auth", "main"); expect(result.valid).toBe(true); }); it("allows hotfix → main", () => { const gitBranch = new GitBranch(repoDir); gitBranch.createMilestoneBranch("v0.5", "baseline"); execSync(`git checkout -b hotfix/critical-fix main`, { cwd: repoDir, stdio: "pipe" }); fs.writeFileSync(path.join(repoDir, "fix.txt"), "fix"); execSync(`git add . && git commit -m "hotfix: critical fix"`, { cwd: repoDir, stdio: "pipe" }); execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" }); const result = gitBranch.validateMergeOrder("hotfix/critical-fix", "main"); expect(result.valid).toBe(true); }); }); describe("computeMilestoneTag", () => { it("computes next minor for feature milestone", () => { execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" }); execSync(`git tag -a v0.5.2 -m "v0.5.2"`, { cwd: repoDir, stdio: "pipe" }); const gitBranch = new GitBranch(repoDir); const tag = gitBranch.computeMilestoneTag("feature"); expect(tag).toBe("v0.6.0"); }); it("computes next major for major milestone", () => { execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" }); const gitBranch = new GitBranch(repoDir); const tag = gitBranch.computeMilestoneTag("major"); expect(tag).toBe("v1.0.0"); }); it("starts from v0.1.0 when no tags exist", () => { const gitBranch = new GitBranch(repoDir); const tag = gitBranch.computeMilestoneTag("feature"); expect(tag).toBe("v0.1.0"); }); }); describe("mergeMilestoneBranch", () => { it("rejects merge when unmerged phase branches remain", () => { const gitBranch = new GitBranch(repoDir); gitBranch.createMilestoneBranch("v0.5", "baseline"); gitBranch.createPhaseBranch(1, "auth"); execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" }); const result = gitBranch.mergeMilestoneBranch("milestone/v0.5-baseline", "main", true); expect(result.success).toBe(false); expect(result.message).toContain("unmerged"); }); }); });