203 lines
5.7 KiB
TypeScript
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 !== "";
|
|
}
|
|
} |