Files
tea-sdlc/scripts/branch-prep.js
T
jiantw83 95e6026950 refactor(lib): 開啟目標專案 git repo 的那幾行收進 lib
「路徑不是 repo 就報 NOT_A_GIT_REPO」加上「把 cwd 綁進 runGit」原本在 branch-prep 與
commit-split 各寫一份。錯誤碼要一致,而這件事寫第三遍就該收起來了。
2026-09-17 08:23:26 +00:00

225 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 { ScriptError, main, openGitRepo, parseFlags } 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);
const git = openGitRepo(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;
}
}