ab6af144b7
---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)
316 lines
9.4 KiB
TypeScript
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 !== "";
|
|
}
|
|
} |