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:
2026-07-24 13:48:13 +03:00
co-authored by Claude Fable 5
parent 823baf8302
commit c32c55cb4d
46 changed files with 1426 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
/**
* Action: create
*
* Computes the next branch name (same rules as the `next` action) and creates
* the branch from the base branch:
*
* origin/test → origin/main → error (the caller must ask the user and pass --base)
*
* The caller MUST have confirmed the branch name with the user first (run `next`,
* show the name, get confirmation). Never pushes — publishing stays a human decision.
*
* Usage:
* branch-tools --action create --task-key IIAA-123 --topic api-cleanup [--base origin/develop] [--repo-dir /path]
*/
import { resolve } from "node:path";
import { computeBranchName, detectBaseBranch, git } from "../next/action.js";
export function run(options: Record<string, string | undefined>): Record<string, unknown> {
const { branch, error, parts } = computeBranchName(options);
if (error) return { action: "create", success: false, error };
const repoDir = resolve(options["repo-dir"] ?? process.cwd());
const base = options["base"] ?? detectBaseBranch(repoDir);
if (!base) {
return {
action: "create",
success: false,
branch,
error: "Remote has neither origin/test nor origin/main — ask the user which base branch to use and pass it via --base",
};
}
try {
git(repoDir, ["fetch", "--prune"]);
} catch {
// no remote or offline — branch from the local ref instead
}
try {
git(repoDir, ["checkout", "-b", branch!, base]);
} catch (err) {
return {
action: "create",
success: false,
branch,
base,
error: err instanceof Error ? err.message : String(err),
};
}
return {
action: "create",
success: true,
branch,
base,
...parts ? { parts } : {},
"repo-dir": repoDir,
note: "Branch created locally — push and PR into test are the user's decision",
};
}
+121
View File
@@ -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 } : {},
};
}
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env node
/**
* branch-tools CLI dispatcher
*
* Usage:
* branch-tools --action <action-name> [options]
*
* Actions:
* next — compute the next branch name (version from git tags only)
* create — compute and create the branch locally
*
* Output is always YAML to stdout. Exit 0 on success, 1 on error.
*/
import { parseArgs } from "node:util";
import * as yaml from "js-yaml";
// ---------------------------------------------------------------------------
// Action registry
// ---------------------------------------------------------------------------
type ActionModule = {
run: (options: Record<string, string | undefined>) => Record<string, unknown>;
};
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
next: () => import("../actions/next/action.js"),
create: () => import("../actions/create/action.js"),
};
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
let values: {
action?: string;
"task-key"?: string;
topic?: string;
base?: string;
"repo-dir"?: string;
help?: boolean;
};
try {
({ values } = parseArgs({
args: process.argv.slice(2),
options: {
action: { type: "string", short: "a" },
"task-key": { type: "string" },
topic: { type: "string" },
base: { type: "string" },
"repo-dir": { type: "string" },
help: { type: "boolean", short: "h" },
},
strict: true,
}));
} catch (err) {
printError(String(err));
process.exit(1);
}
if (values.help || !values.action) {
console.log(`branch-tools --action <action> [options]
Branch convention: X.Y.Z.Q/{TASK_MANAGEMENT_CODE}-{FEATURE_X}-{SEQ}
Version (X.Y.Z.Q) is read from git tags ONLY. Branches are cut from origin/test (fallback origin/main).
Actions:
next Compute the next branch name
create Compute and create the branch locally (never pushes)
Options (both actions):
--task-key <key> Task identifier, e.g. IIAA-123 (required)
--topic <topic> Kebab-case focus topic, e.g. api-cleanup (required)
--base <branch> Base branch for create (default: origin/test, then origin/main)
--repo-dir <path> Target repository (default: current directory)
Examples:
branch-tools --action next --task-key IIAA-123 --topic api-cleanup
branch-tools --action create --task-key IIAA-123 --topic api-cleanup --repo-dir /path/to/repo
`);
process.exit(values.help ? 0 : 1);
}
const actionName = values.action;
if (!ACTIONS[actionName]) {
printError(
`Unknown action '${actionName}'. Available: ${Object.keys(ACTIONS).join(", ")}`
);
process.exit(1);
}
try {
const mod = await ACTIONS[actionName]();
const result = mod.run({
"task-key": values["task-key"],
topic: values.topic,
base: values.base,
"repo-dir": values["repo-dir"],
});
process.stdout.write(
yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 })
);
const success = (result["success"] as boolean | undefined) ?? true;
process.exit(success ? 0 : 1);
} catch (err) {
const errorOutput = {
success: false,
action: actionName,
error: err instanceof Error ? err.message : String(err),
};
process.stdout.write(yaml.dump(errorOutput, { noRefs: true, sortKeys: false }));
process.exit(1);
}
}
function printError(msg: string): void {
process.stderr.write(`Error: ${msg}\n`);
process.stderr.write(
`Usage: branch-tools --action <${Object.keys(ACTIONS).join("|")}> [options]\n`
);
}
main();