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>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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 } : {},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user