v0.2.0: Git-native architecture (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-05-29 12:59:45 +00:00
parent 9cf5c000d9
commit 6e637e4af0
50 changed files with 5852 additions and 135 deletions
+190
View File
@@ -0,0 +1,190 @@
import { execSync } from "node:child_process";
import { GitContext, BranchInfo } from "./git-context.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;
constructor(projectPath: string) {
this.projectPath = projectPath;
this.gitContext = new GitContext(projectPath);
}
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 branchName = `phase/${padded}-${slug}`;
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 branchName = `milestone/${version}-${slug}`;
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 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})`,
};
}
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 !== "";
}
}