/** * Action: next * * Computes the next branch name for any git repository following the convention: * * X.Y.Z.Q/{TASK_MANAGEMENT_CODE}-{FEATURE_X}-{SEQ} * * - X.Y.Z.Q — version, read from git tags ONLY (latest semver tag; * 3-part tags get Q=0). No tags → hard error. * - TASK_MANAGEMENT_CODE — task/ticket identifier, e.g. IIAA-123 (--task-key) * - FEATURE_X — kebab-case feature/focus topic (--topic) * - SEQ — zero-padded 3-digit sequence, auto-incremented by scanning * existing local + remote branches with the same prefix * * Repository-independent: operates on --repo-dir (default: current directory). * * Usage: * branch-tools --action next --task-key IIAA-123 --topic api-cleanup [--repo-dir /path] */ import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const TAG_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$/; export function git(repoDir: string, args: string[]): string { return execFileSync("git", ["-C", repoDir, ...args], { encoding: "utf-8" }).trim(); } export function detectVersion(repoDir: string): string | undefined { const tags = git(repoDir, ["tag", "--list", "--sort=-v:refname"]) .split("\n") .map((t) => t.trim()) .filter((t) => TAG_PATTERN.test(t)); if (tags.length === 0) return undefined; const m = tags[0].match(TAG_PATTERN)!; return `${m[1]}.${m[2]}.${m[3]}.${m[4] ?? "0"}`; } export function detectBaseBranch(repoDir: string): string | undefined { for (const candidate of ["origin/test", "origin/main"]) { try { git(repoDir, ["show-ref", "--verify", "--quiet", `refs/remotes/${candidate}`]); return candidate; } catch { // not present — try next } } return undefined; } export function nextSequence(repoDir: string, prefix: string): string { const branches = git(repoDir, ["branch", "-a", "--format=%(refname:short)"]) .split("\n") .map((b) => b.trim().replace(/^origin\//, "")); let max = 0; for (const branch of branches) { if (!branch.startsWith(`${prefix}-`)) continue; const seq = branch.slice(prefix.length + 1); if (/^\d+$/.test(seq)) max = Math.max(max, parseInt(seq, 10)); } return String(max + 1).padStart(3, "0"); } export function computeBranchName( options: Record ): { branch?: string; error?: string; parts?: Record } { const taskKey = options["task-key"]; if (!taskKey) return { error: "Missing required option: --task-key (e.g. IIAA-123)" }; if (!/^[A-Z][A-Z0-9]*-[A-Za-z0-9]+$/.test(taskKey)) { return { error: `'task-key' must look like PROJECT-ID (e.g. IIAA-123). Got: '${taskKey}'` }; } const topic = options["topic"]; if (!topic) return { error: "Missing required option: --topic (kebab-case feature topic)" }; if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(topic)) { return { error: `'topic' must be kebab-case (a-z, 0-9, single hyphens). Got: '${topic}'` }; } const repoDir = resolve(options["repo-dir"] ?? process.cwd()); try { git(repoDir, ["rev-parse", "--git-dir"]); } catch { return { error: `Not a git repository: ${repoDir}` }; } const version = detectVersion(repoDir); if (!version) { return { error: `No version tag found in ${repoDir} — the version (X.Y.Z.Q) is read from git tags only. Create one first, e.g.: git tag 4.0.0.0` }; } const prefix = `${version}/${taskKey}-${topic}`; const seq = nextSequence(repoDir, prefix); return { branch: `${prefix}-${seq}`, parts: { version, "task-key": taskKey, topic, seq }, }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export function run(options: Record): Record { const { branch, error, parts } = computeBranchName(options); if (error) return { action: "next", success: false, error }; const repoDir = resolve(options["repo-dir"] ?? process.cwd()); const base = detectBaseBranch(repoDir); return { action: "next", success: true, branch, "base-branch": base ?? "NONE — remote has neither test nor main; ask the user", ...parts ? { parts } : {}, }; }