Files
ci/src/core/git-branch.ts
T
Jon Chery ab6af144b7 feat(P06): 3-tier versioning, branch hierarchy enforcement, ARCHITECTURE-PLAN synthesis
---ci---
phase: 6
milestone: v0.5
status: complete
decisions:
  - id: D-006
    decision: Research as intermediate work product
    rationale: Conclusions update .ci/ files; full research doc intentionally not preserved
    confidence: 0.90
  - id: D-007
    decision: Branch hierarchy enforcement: main > milestone > phase
    rationale: Prevents out-of-order merges and semantically wrong tags
    confidence: 0.92
  - id: D-008
    decision: 3-tier versioning: NFR/feature/schema-breaking
    rationale: Patch per phase (NFR/feature) or minor per phase (schema-breaking); milestone gets minor (feature) or major (schema-breaking)
    confidence: 0.95
requirements:
  covered: [VER-06, BRANCH-01, BRANCH-02, ARCH-01]
---/ci---

- Synthesize ARCHITECTURE-PLAN.md into .ci/ci/ARCHITECTURE.md (expanded 51→230 lines)
- Add D-006/D-007/D-008 to .ci/ci/PROJECT.md key decisions table
- Delete ARCHITECTURE-PLAN.md after synthesis
- Rewrite ship.md with 3-tier versioning model + branch hierarchy merge flows
- Rewrite branch-strategy.md with 3-tier versioning + branch hierarchy + version validation
- Add MilestoneType to config types
- Replace isNfrMilestone() with getMilestoneType() returning nfr|feature|schema-breaking
- Add validateMergeOrder(), mergeMilestoneBranch(), computeMilestoneTag() to GitBranch
- Add computeShipVersion(), validateVersionOrder(), resolveMergeTarget() to ship command
- Remove hardcoded v0.5. from error-recovery rollback
- Create .githooks/pre-push for semver ordering + branch hierarchy validation
- Add 15 new tests (370 total, all passing)
2026-05-29 17:18:10 +00:00

316 lines
9.4 KiB
TypeScript

import { execSync } from "node:child_process";
import { GitContext, BranchInfo } from "./git-context.js";
import { MilestoneType } from "../types/config.js";
export interface BranchCreateResult {
name: string;
created: boolean;
alreadyExisted: boolean;
}
export interface BranchMergeResult {
success: boolean;
squash: boolean;
message: string;
}
export interface PhaseBranchInfo {
phaseNumber: number;
slug: string;
branchName: string;
status: "active" | "complete" | "unknown";
}
export interface MilestoneBranchInfo {
version: string;
slug: string;
branchName: string;
status: "active" | "complete" | "unknown";
}
export class GitBranch {
private projectPath: string;
private gitContext: GitContext;
private projectSlug?: string;
constructor(projectPath: string, projectSlug?: string) {
this.projectPath = projectPath;
this.projectSlug = projectSlug;
this.gitContext = new GitContext(projectPath, projectSlug);
}
setProjectSlug(slug: string | undefined): void {
this.projectSlug = slug;
this.gitContext.setProjectSlug(slug);
}
private prefix(name: string): string {
return this.projectSlug ? `${this.projectSlug}/${name}` : name;
}
private git(args: string): string {
try {
return execSync(`git ${args}`, {
cwd: this.projectPath,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch {
return "";
}
}
private slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
createPhaseBranch(phaseNumber: number, phaseName: string): BranchCreateResult {
const padded = String(phaseNumber).padStart(2, "0");
const slug = this.slugify(phaseName);
const baseName = `phase/${padded}-${slug}`;
const branchName = this.prefix(baseName);
const existing = this.gitContext.getBranches().find((b) => b.name === branchName);
if (existing) {
return { name: branchName, created: false, alreadyExisted: true };
}
const result = this.git(`checkout -b ${branchName}`);
if (!result && this.git(`checkout ${branchName}`)) {
return { name: branchName, created: false, alreadyExisted: true };
}
return { name: branchName, created: true, alreadyExisted: false };
}
createMilestoneBranch(version: string, milestoneName: string): BranchCreateResult {
const slug = this.slugify(milestoneName);
const baseName = `milestone/${version}-${slug}`;
const branchName = this.prefix(baseName);
const existing = this.gitContext.getBranches().find((b) => b.name === branchName);
if (existing) {
return { name: branchName, created: false, alreadyExisted: true };
}
const result = this.git(`checkout -b ${branchName}`);
if (!result && this.git(`checkout ${branchName}`)) {
return { name: branchName, created: false, alreadyExisted: true };
}
return { name: branchName, created: true, alreadyExisted: false };
}
mergePhaseBranch(
phaseBranchName: string,
targetBranch: string,
squash: boolean = true
): BranchMergeResult {
const validation = this.validateMergeOrder(phaseBranchName, targetBranch);
if (!validation.valid) {
return { success: false, squash, message: `Merge rejected: ${validation.reason}` };
}
const branches = this.gitContext.getBranches();
const phaseBranch = branches.find((b) => b.name === phaseBranchName);
if (!phaseBranch) {
return { success: false, squash, message: `Branch ${phaseBranchName} not found` };
}
this.git(`checkout ${targetBranch}`);
const mergeCmd = squash
? `merge --squash ${phaseBranchName}`
: `merge --no-ff ${phaseBranchName}`;
const result = this.git(mergeCmd);
if (result === "" && !squash) {
return { success: false, squash, message: `Merge conflict on ${phaseBranchName}` };
}
if (squash) {
this.git(`commit -m "docs: merge phase branch ${phaseBranchName}"`);
}
return {
success: true,
squash,
message: `Merged ${phaseBranchName} into ${targetBranch} (squash: ${squash})`,
};
}
mergeMilestoneBranch(
milestoneBranchName: string,
targetBranch: string,
squash: boolean = true
): BranchMergeResult {
const branches = this.gitContext.getBranches();
const milestoneBranch = branches.find((b) => b.name === milestoneBranchName);
if (!milestoneBranch) {
return { success: false, squash, message: `Branch ${milestoneBranchName} not found` };
}
const phaseBranches = branches.filter(
(b) => b.type === "phase" && !b.merged
);
if (phaseBranches.length > 0) {
return {
success: false,
squash,
message: `Cannot merge milestone: ${phaseBranches.length} unmerged phase branch(es) remain (${phaseBranches.map((b) => b.name).join(", ")})`,
};
}
this.git(`checkout ${targetBranch}`);
const mergeCmd = squash
? `merge --squash ${milestoneBranchName}`
: `merge --no-ff ${milestoneBranchName}`;
const result = this.git(mergeCmd);
if (result === "" && !squash) {
return { success: false, squash, message: `Merge conflict on ${milestoneBranchName}` };
}
if (squash) {
this.git(`commit -m "docs: merge milestone branch ${milestoneBranchName}"`);
}
return {
success: true,
squash,
message: `Merged ${milestoneBranchName} into ${targetBranch} (squash: ${squash})`,
};
}
validateMergeOrder(sourceBranch: string, targetBranch: string): { valid: boolean; reason: string } {
const branches = this.gitContext.getBranches();
const source = branches.find((b) => b.name === sourceBranch);
if (!source) return { valid: false, reason: `Source branch ${sourceBranch} not found` };
if (source.type === "hotfix") {
return { valid: true, reason: "Hotfix branches may merge to any target" };
}
if (source.type === "phase" && targetBranch === "main") {
const milestoneBranches = branches.filter(
(b) => b.type === "milestone" && !b.merged
);
if (milestoneBranches.length > 0) {
return {
valid: false,
reason: `Phase branch must merge into milestone branch first (active: ${milestoneBranches.map((b) => b.name).join(", ")}). Merge into main only through the milestone branch.`,
};
}
}
if (source.type === "milestone" && targetBranch === "main") {
const phaseBranches = branches.filter(
(b) => b.type === "phase" && !b.merged
);
if (phaseBranches.length > 0) {
return {
valid: false,
reason: `Milestone cannot merge to main: ${phaseBranches.length} unmerged phase branch(es) remain.`,
};
}
}
return { valid: true, reason: "Merge order is valid" };
}
computeMilestoneTag(milestoneType: MilestoneType): string {
const tags = this.git("tag -l").split("\n").map((t) => t.trim()).filter(Boolean);
let major = 0;
let minor = 0;
let patch = 0;
for (const tag of tags) {
const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/);
if (match) {
const m = parseInt(match[1]);
const n = parseInt(match[2]);
const p = parseInt(match[3]);
if (m > major || (m === major && n > minor) || (m === major && n === minor && p > patch)) {
major = m;
minor = n;
patch = p;
}
}
}
if (milestoneType === "schema-breaking") {
return `v${major + 1}.0.0`;
}
return `v${major}.${minor + 1}.0`;
}
getPhaseStatus(phaseNumber: number): PhaseBranchInfo | null {
const branches = this.gitContext.getBranches();
const phaseBranch = branches.find(
(b) => b.type === "phase" && b.phaseNumber === phaseNumber
);
if (!phaseBranch) return null;
const slug = phaseBranch.name.replace(/^phase\/\d+-/, "");
return {
phaseNumber,
slug,
branchName: phaseBranch.name,
status: phaseBranch.merged ? "complete" : "active",
};
}
getMilestoneStatus(version: string): MilestoneBranchInfo | null {
const branches = this.gitContext.getBranches();
const milestoneBranch = branches.find(
(b) => b.type === "milestone" && b.milestone?.startsWith(version)
);
if (!milestoneBranch) return null;
const slug = milestoneBranch.name.replace(/^milestone\//, "");
return {
version,
slug,
branchName: milestoneBranch.name,
status: milestoneBranch.merged ? "complete" : "active",
};
}
listPhases(): PhaseBranchInfo[] {
const branches = this.gitContext.getPhaseBranches();
return branches.map((b) => ({
phaseNumber: b.phaseNumber || 0,
slug: b.name.replace(/^phase\/\d+-/, ""),
branchName: b.name,
status: b.merged ? "complete" : "active" as const,
}));
}
listMilestones(): MilestoneBranchInfo[] {
const branches = this.gitContext.getMilestoneBranches();
return branches.map((b) => ({
version: b.milestone || "",
slug: b.name.replace(/^milestone\//, ""),
branchName: b.name,
status: b.merged ? "complete" : "active" as const,
}));
}
switchToBranch(branchName: string): boolean {
const result = this.git(`checkout ${branchName}`);
return result !== "";
}
deleteBranch(branchName: string, force: boolean = false): boolean {
const flag = force ? "-D" : "-d";
const result = this.git(`branch ${flag} ${branchName}`);
return result !== "";
}
}