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)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { GitContext, BranchInfo } from "./git-context.js";
|
||||
import { MilestoneType } from "../types/config.js";
|
||||
|
||||
export interface BranchCreateResult {
|
||||
name: string;
|
||||
@@ -108,6 +109,11 @@ export class GitBranch {
|
||||
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) {
|
||||
@@ -136,6 +142,113 @@ export class GitBranch {
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user