Files
ci/src/core/git-branch.ts
T

203 lines
5.7 KiB
TypeScript

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;
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 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 !== "";
}
}