feat(branch-prep): 依命名規則備妥開工的分支
命名規則照議題 #1:從開發分支長出 {類型}/{需求描述}/main,從功能分支長出時前兩段沿用
來源,子分支才會留在同一棵樹下。需求描述由議題標題翻譯,那是 agent 的事,所以這裡只收
--slug 並驗格式:英文 kebab、≤40 字元,中文分支名會讓 CI 與 URL 出問題。
三處「不弄丟別人的東西」,而且全部在任何 git 寫入之前判斷完:
- 工作區不乾淨就不動手。未提交的改動與未追蹤的檔案都會跟著 checkout 走到新分支上,
混進這顆工作包的 commit;目標分支已存在時,git 還會拖到 checkout 那一步才拒絕,
屆時 fetch 與 merge 都已經跑掉了。
- 來源分支在遠端已存在時 pull 而不是重建。
- 目標分支已存在時接上去而不是從來源蓋掉。
遠端分支的比對用全名 refs/heads/<ref>:ls-remote 的樣式比對吃的是 ref 尾段,而本 repo
的命名慣例讓每一支分支都以 /main 結尾——用短名比對,拿 main 當開發分支的專案會把
feat/x/main 誤認成 main,整個判成「遠端已經有這一支」。
算與做分成兩段,--dry-run 印出的就是真正將執行的那幾行 git,不另外維護一份描述。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,230 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 備妥開工的分支。
|
||||||
|
*
|
||||||
|
* 命名規則(議題 #1 的正本):
|
||||||
|
* - 從開發分支長出 → `{類型}/{英文-kebab-需求描述}/main`
|
||||||
|
* - 從功能分支長出 → `{類型}/{需求描述}/{功能描述}`,前兩段沿用來源,
|
||||||
|
* 子分支才會留在同一棵樹下。
|
||||||
|
*
|
||||||
|
* 需求描述由議題標題翻譯——那是 agent 的事,不是腳本的事,所以這裡只收 `--slug`
|
||||||
|
* 並驗格式:英文 kebab、≤40 字元。中文分支名會讓 CI 與 URL 出問題,擋在建立之前。
|
||||||
|
*
|
||||||
|
* 三處「不弄丟別人的東西」:
|
||||||
|
* - 工作區不乾淨就不動手,免得把不相干的改動帶進這顆工作包的分支。
|
||||||
|
* - 來源分支在遠端已存在時 pull 而不是重建。
|
||||||
|
* - 目標分支已存在時接上去而不是從來源蓋掉。
|
||||||
|
* 三種情況都在任何 git 寫入之前判斷完:試跑印得出漂亮的計畫、實跑卻中途炸掉,
|
||||||
|
* 是最難查的那種落差。
|
||||||
|
*
|
||||||
|
* 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/branch-prep.js --source <來源分支> --slug <英文-kebab>
|
||||||
|
* [--type feat] [--path <目標專案>] [--dry-run]
|
||||||
|
*/
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { ScriptError, main, parseFlags, runGit } from './lib.js';
|
||||||
|
|
||||||
|
/** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */
|
||||||
|
const SLUG_MAX = 40;
|
||||||
|
|
||||||
|
/** 功能分支長這樣:三段、前兩段非空。開發分支(master/main/develop)不合這個樣式。 */
|
||||||
|
const FEATURE_BRANCH = /^([a-z]+)\/([^/]+)\/([^/]+)$/;
|
||||||
|
|
||||||
|
main(async () => {
|
||||||
|
const flags = parseFlags(process.argv.slice(2), {
|
||||||
|
required: ['source', 'slug'],
|
||||||
|
optional: ['type', 'path'],
|
||||||
|
booleans: ['dry-run'],
|
||||||
|
});
|
||||||
|
const path = flags.path ?? process.cwd();
|
||||||
|
const source = flags.source;
|
||||||
|
const branch = buildBranchName(source, flags.slug, flags.type);
|
||||||
|
|
||||||
|
if (!existsSync(join(path, '.git'))) {
|
||||||
|
throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = (...args) => runGit(args, { cwd: path });
|
||||||
|
checkClean(git, path);
|
||||||
|
|
||||||
|
const hasOrigin = git('remote').split('\n').includes('origin');
|
||||||
|
/**
|
||||||
|
* 比對用全名 `refs/heads/<ref>`:`ls-remote --heads origin main` 的樣式比對吃的是
|
||||||
|
* ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對,
|
||||||
|
* 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。
|
||||||
|
*/
|
||||||
|
const onRemote = (ref) =>
|
||||||
|
hasOrigin && git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== '';
|
||||||
|
const onLocal = (ref) => git('branch', '--list', ref).trim() !== '';
|
||||||
|
|
||||||
|
const sourcePlan = syncPlan(source, onRemote(source), onLocal(source), {
|
||||||
|
missing: () => {
|
||||||
|
throw new ScriptError(
|
||||||
|
'SOURCE_NOT_FOUND',
|
||||||
|
`來源分支 ${source} 在本地與遠端都不存在;請確認分支名,或先把它推上遠端`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const branchPlan = syncPlan(branch, onRemote(branch), onLocal(branch), {
|
||||||
|
// 目標分支不存在是常態:這就是開一支新分支
|
||||||
|
missing: () => ({ commands: [['checkout', '-b', branch]], 動作: '從來源建立' }),
|
||||||
|
onRemote: '接上遠端既有',
|
||||||
|
onLocal: '切換到本地既有',
|
||||||
|
});
|
||||||
|
|
||||||
|
const commands = [...sourcePlan.commands, ...branchPlan.commands];
|
||||||
|
const 來源 = { 位置: sourcePlan.位置, 動作: sourcePlan.動作 };
|
||||||
|
const 分支 = { 動作: branchPlan.動作 };
|
||||||
|
|
||||||
|
if (flags['dry-run']) {
|
||||||
|
return {
|
||||||
|
dryRun: true,
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
branch,
|
||||||
|
來源,
|
||||||
|
分支,
|
||||||
|
commands: commands.map((args) => `git ${args.join(' ')}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const args of commands) runGitStep(git, args, source);
|
||||||
|
|
||||||
|
return { path, source, branch, 來源, 分支 };
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依來源分支的型態組出新分支名。
|
||||||
|
* 純字串規則,不碰 git——命名錯了會一路帶到 PR 標題與 CI,這一段要能單獨驗。
|
||||||
|
*/
|
||||||
|
function buildBranchName(source, slug, type) {
|
||||||
|
checkSlug(slug);
|
||||||
|
const feature = source.match(FEATURE_BRANCH);
|
||||||
|
|
||||||
|
if (!feature) {
|
||||||
|
if (type === undefined) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'MISSING_FLAG',
|
||||||
|
`從開發分支 ${source} 長出新分支需要 --type(例如 feat、fix、chore)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
checkType(type);
|
||||||
|
return `${type}/${slug}/main`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, sourceType, requirement] = feature;
|
||||||
|
if (type !== undefined && type !== sourceType) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'TYPE_FROM_SOURCE',
|
||||||
|
`從功能分支 ${source} 長出的子分支沿用它的類型 ${sourceType},不接受 --type ${type};` +
|
||||||
|
'要換類型請改從開發分支長出',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${sourceType}/${requirement}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 英文 kebab,且不超過長度上限。中文、大寫、底線、頭尾或連續的連字號都不算。 */
|
||||||
|
function checkSlug(value) {
|
||||||
|
if (value.length > SLUG_MAX) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'BAD_SLUG',
|
||||||
|
`--slug 長度 ${value.length} 超過上限 ${SLUG_MAX};請換一個更短的說法,不要靠截斷`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isKebab(value)) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'BAD_SLUG',
|
||||||
|
`--slug 需為英文小寫 kebab(例如 claim-work-package),收到的是 ${value};` +
|
||||||
|
'中文或大寫會讓 CI 與 URL 出問題',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 類型是分支名的第一段,只認小寫英文——它同時是 commit 訊息的分類,不該長得千奇百怪。 */
|
||||||
|
function checkType(value) {
|
||||||
|
if (!/^[a-z]+$/.test(value)) {
|
||||||
|
throw new ScriptError(
|
||||||
|
'BAD_TYPE',
|
||||||
|
`--type 需為小寫英文(feat、fix、chore…),收到的是 ${value}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKebab(value) {
|
||||||
|
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作區必須乾淨才開工。
|
||||||
|
*
|
||||||
|
* 未提交的改動與未追蹤的檔案都會跟著 checkout 走到新分支上,混進這顆工作包的 commit
|
||||||
|
* 裡;而目標分支已存在時,git 還會在 checkout 那一步才拒絕,屆時 fetch 與 merge
|
||||||
|
* 都已經跑掉了,留下做到一半的狀態。寧可一開始就擋。
|
||||||
|
*/
|
||||||
|
function checkClean(git, path) {
|
||||||
|
const dirty = git('status', '--porcelain');
|
||||||
|
if (dirty !== '') {
|
||||||
|
const files = dirty
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.slice(3))
|
||||||
|
.join('、');
|
||||||
|
throw new ScriptError(
|
||||||
|
'DIRTY_WORKTREE',
|
||||||
|
`${path} 的工作區還有未處理的變更(${files});` +
|
||||||
|
'請先提交、暫存(git stash)或清掉,再來開分支',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算出要把一支 ref 弄到手需要哪幾個 git 指令。
|
||||||
|
*
|
||||||
|
* 來源分支與目標分支的處理是同一個形狀——遠端有就 pull、只有本地就切過去——
|
||||||
|
* 差別只在「兩邊都沒有」時怎麼辦,所以那一段由呼叫端給。
|
||||||
|
*
|
||||||
|
* 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令,
|
||||||
|
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。
|
||||||
|
*
|
||||||
|
* @param {(ref: string) => {commands: string[][], 動作: string}} handlers.missing 兩邊都沒有時
|
||||||
|
*/
|
||||||
|
function syncPlan(ref, onRemote, onLocal, handlers) {
|
||||||
|
if (onRemote) {
|
||||||
|
// 遠端已經有了就 pull,不重建:別人推上去的進度要帶進來
|
||||||
|
const commands = [['fetch', 'origin', ref]];
|
||||||
|
commands.push(onLocal ? ['checkout', ref] : ['checkout', '-b', ref, `origin/${ref}`]);
|
||||||
|
if (onLocal) commands.push(['merge', '--ff-only', `origin/${ref}`]);
|
||||||
|
return { commands, 位置: '遠端', 動作: handlers.onRemote ?? 'pull' };
|
||||||
|
}
|
||||||
|
if (onLocal) {
|
||||||
|
return {
|
||||||
|
commands: [['checkout', ref]],
|
||||||
|
位置: '本地',
|
||||||
|
動作: handlers.onLocal ?? '用本地既有',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return handlers.missing(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跑一個 git 步驟,並把已知會發生的失敗換成看得懂的錯誤碼。
|
||||||
|
* `merge --ff-only` 失敗幾乎都是同一件事:本地有沒推上去的 commit,而遠端也往前走了。
|
||||||
|
* 原始的 git 訊息說得不夠白,使用者需要知道下一步是 rebase 還是先推。
|
||||||
|
*/
|
||||||
|
function runGitStep(git, args, source) {
|
||||||
|
try {
|
||||||
|
return git(...args);
|
||||||
|
} catch (error) {
|
||||||
|
if (args[0] === 'merge') {
|
||||||
|
throw new ScriptError(
|
||||||
|
'SOURCE_DIVERGED',
|
||||||
|
`${source} 的本地與遠端已經分歧,無法直接快轉;` +
|
||||||
|
'請先把本地的 commit 推上去或 rebase 到遠端之後,再執行一次',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user