Files
development-branching-strat…/scripts/src/actions/next/action.ts
T
oleg-lukasonokandClaude Fable 5 c32c55cb4d Add branching-strategy skill with next and create actions
Version-aware branch naming (X.Y.Z.Q/{TASK_CODE}-{FEATURE}-{SEQ}),
branch creation from origin/test with origin/main fallback, and the
PR-into-test merge rule. Ships a TypeScript CLI (next, create) plus
shell modules (loggers, base, cli) run via Taskfile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:48:13 +03:00

122 lines
4.4 KiB
TypeScript

/**
* 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<string, string | undefined>
): { branch?: string; error?: string; parts?: Record<string, string> } {
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<string, string | undefined>): Record<string, unknown> {
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 } : {},
};
}