Files
tea-sdlc/scripts/branch-prep.js
T
jiantw83andClaude Opus 5 5b740c236b feat(branch-prep): 一律在獨立的工作樹上開工,不在原地切換分支
同一份 clone 上同時持有多顆工作包時,原地切分支有三種損耗,一種比一種難查:
未提交的變更擋路、建置產物跨分支混淆,以及 agent 讀到不屬於它那顆工作包的
程式碼——agent 是非同步的,它可能在分支已經被切走之後才去讀檔,而且不會察覺,
產出看起來完全合理,只是接錯了上下文。前兩種人會當場發現,第三種不會,
所以工作樹一律建立,不是「有衝突才用」。

建不起來就中止,不退回原地切分支:靜默降級會讓使用者以為自己在隔離環境裡,
其實在原地改。

分支與工作樹合併為一個原子動作(fetch 後一次 worktree add),並補上回滾——
git 在 worktree add 失敗時仍會把分支留下來,那是最難查的半成品:下一次重跑
會走到「目標分支已存在」那條路,起點從此不再是遠端的來源分支。

起點一律取自 origin/{來源分支},遠端沒有就中止,不退回本機同名分支;
本機分支可能落後好幾天,而這件事從輸出上完全看不出來。原「來源分支在遠端
已存在時 pull 而非重建」那條,用更強的方式達成同一個目的:根本不碰本機分支,
就沒有覆蓋他人進度的可能。

不設 upstream:此刻遠端還沒有這個新分支,--track 會把 upstream 指到來源分支,
之後 git pull 會把來源分支的提交拉進來。留給第一次 push -u 自然建立。

路徑由 owner/repo/分支名 正規化後取雜湊推導(lib 的 worktreePath),不查表、
不寫狀態檔,換機器算出來一樣。取雜湊而不是把斜線攤平成 -,是因為攤平會讓
feat/a-b/main 與 feat/a/b/main 撞成同一個目錄,而現行的分支命名規則恰好讓
這種形狀有機會出現。

議題 #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:27:43 +08:00

307 lines
12 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
/**
* 備妥開工的分支與工作樹。
*
* 每顆工作包在一棵屬於自己的工作樹上開工,不在原地切換分支。這樣同時持有幾顆工作包
* 都互不干擾:各自的未提交變更、各自的建置產物,而 agent 無論什麼時候去讀檔,
* 都只會讀到它該讀的那份程式碼——agent 是非同步的,它不會察覺自己讀到的是別顆工作包
* 的內容,產出看起來完全合理,只是接錯了上下文。
*
* 工作樹一律建立,沒有例外。建不起來就明確中止,不默默退回原地切分支:靜默降級會讓
* 使用者以為自己在隔離環境裡,其實在原地改。
*
* 命名規則(議題 #1 的正本):
* - 從開發分支長出 → `{類型}/{英文-kebab-需求描述}/main`
* - 從功能分支長出 → `{類型}/{需求描述}/{功能描述}`,前兩段沿用來源,
* 子分支才會留在同一棵樹下。
*
* 需求描述由議題標題翻譯——那是 agent 的事,不是腳本的事,所以這裡只收 `--slug`
* 並驗格式:英文 kebab、≤40 字元。中文分支名會讓 CI 與 URL 出問題,擋在建立之前。
*
* 建分支與建工作樹是同一個原子動作:先 `git fetch` 更新遠端引用,再以一次
* `git worktree add` 完成。起點一律取自 `origin/{來源分支}`,遠端沒有就中止,
* 不退回本機同名分支——那是靜默降級,而且後果隱蔽:使用者以為自己從最新的遠端狀態
* 開工,實際上起點可能落後好幾天。
*
* **不設 upstream**。此刻遠端還沒有這個新分支,`--track` 會把 upstream 指到*來源分支*,
* 之後 `git pull` 會把來源分支的提交拉進來,幾乎一定不是使用者要的。
* upstream 留給第一次 `push -u` 自然建立。
*
* 兩處「不弄丟別人的東西」:目標分支已存在時接上去而不是從來源蓋掉;推導出的路徑被
* 別的東西佔住時中止而不是硬蓋過去。兩者都在任何 git 寫入之前判斷完:試跑印得出
* 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。
*
* 工作樹路徑由 `owner/repo/分支名` 純函式推導(見 lib 的 worktreePath),
* 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。
*
* 用法:
* node scripts/branch-prep.js --repo <owner/name> --source <來源分支> --slug <英文-kebab>
* [--type feat] [--path <目標專案>] [--dry-run]
*/
import { existsSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { ScriptError, main, openGitRepo, parseFlags, parseRepo, worktreePath } from './lib.js';
/** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */
const SLUG_MAX = 40;
/** 功能分支長這樣:三段、前兩段非空。開發分支(master/main/develop)不合這個樣式。 */
const FEATURE_BRANCH = /^([a-z]+)\/([^/]+)\/([^/]+)$/;
/**
* 專案檔 → 把依賴裝起來的指令。
* 工作樹是乾淨的,這份對照表只用來提示使用者該跑什麼,腳本自己不執行安裝——
* 在別人的機器上裝東西應該是他自己的決定。
*/
const INSTALL_HINTS = [
['package.json', 'npm install'],
['composer.json', 'composer install'],
['requirements.txt', 'pip install -r requirements.txt'],
['go.mod', 'go mod download'],
['Gemfile', 'bundle install'],
['Cargo.toml', 'cargo fetch'],
];
const CLEAN_WORKTREE =
'這是一棵乾淨的工作樹:沒有安裝依賴,也沒有任何建置產物。' +
'.env 這類機密檔案一律不自動複製,需要的話請自己放一份。';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'source', 'slug'],
optional: ['type', 'path'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const path = flags.path ?? process.cwd();
const source = flags.source;
const branch = buildBranchName(source, flags.slug, flags.type);
const worktree = worktreePath(repo, branch);
const git = openGitRepo(path);
if (!git('remote').split('\n').includes('origin')) {
throw new ScriptError(
'NO_ORIGIN',
`${path} 沒有 origin 遠端;工作樹的起點一律取自 origin/{來源分支},請先設定 origin`,
);
}
const plan = planWorktree(git, { repo, source, branch, worktree });
const 報告 = { path, repo, source, branch, worktree, 分支: { 動作: plan.動作 } };
if (flags['dry-run']) {
return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) };
}
if (plan.commands.length > 0) {
// 既有的本地分支不是這次建的,回滾時不能連它一起刪掉
const 分支本來就在 = onLocal(git, branch);
try {
for (const args of plan.commands) git(...args);
} catch (error) {
rollback(git, { worktree, branch, 保留分支: 分支本來就在 });
throw error;
}
}
return { ...報告, 提示: { 訊息: CLEAN_WORKTREE, 安裝指令: installHints(worktree) } };
});
/**
* 依來源分支的型態組出新分支名。
* 純字串規則,不碰 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);
}
/**
* 算出要把這棵工作樹弄到手需要哪幾個 git 指令。
*
* 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令,
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成,
* 所以試跑與實跑在同一個地方被擋下來。
*
* @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了
*/
function planWorktree(git, { repo, source, branch, worktree }) {
const 既有 = listWorktrees(git).find((entry) => samePath(entry.path, worktree));
const 目錄還在 = existsSync(worktree);
if (既有 && 目錄還在) {
if (既有.branch !== branch) {
throw new ScriptError(
'WORKTREE_PATH_TAKEN',
`${worktree} 已經是 ${既有.branch} 的工作樹;請先 git worktree remove 它再重跑`,
);
}
// 冪等:中斷重跑時接上既有那一棵,不碰裡面還沒提交的東西
return { commands: [], 動作: '沿用既有工作樹' };
}
if (!既有 && 目錄還在) {
throw new ScriptError(
'WORKTREE_PATH_TAKEN',
`${worktree} 已經有東西了,但它不是這個 repo 的工作樹(可能是別的 clone 留下的);` +
'請確認裡面沒有還沒保存的東西之後移除它,再重跑',
);
}
// 起點一律取自遠端:本機同名分支可能落後好幾天,靜默拿它當起點的後果太隱蔽
if (!onRemote(git, source)) {
throw new ScriptError(
'SOURCE_NOT_FOUND',
`遠端沒有來源分支 ${source};請先把它推上去(git push origin ${source}),` +
'或改指定一個已經存在於遠端的來源分支',
);
}
// 目錄被刪掉但中繼資料還在時先清乾淨,否則 git 會說這條路徑已經註冊過
const commands = 既有 ? [['worktree', 'prune']] : [];
commands.push(['fetch', 'origin']);
if (onLocal(git, branch)) {
// 已經有的分支接上去,不從來源蓋掉:上面可能有做到一半的進度
commands.push(['worktree', 'add', worktree, branch]);
return { commands, 動作: '接上本地既有' };
}
if (onRemote(git, branch)) {
commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${branch}`]);
return { commands, 動作: '接上遠端既有' };
}
commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${source}`]);
return { commands, 動作: '從來源建立' };
}
/**
* 這個 repo 目前有哪幾棵工作樹。
* `--porcelain` 的輸出是以空行分隔的區塊,每塊第一行是 `worktree <路徑>`,
* 分支則是 `branch refs/heads/<名字>`;detached 的工作樹沒有 branch 那一行。
*/
function listWorktrees(git) {
return git('worktree', 'list', '--porcelain')
.split('\n\n')
.map((block) => {
const path = block.match(/^worktree (.+)$/m)?.[1];
const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1] ?? null;
return path ? { path, branch } : null;
})
.filter(Boolean);
}
/**
* 兩條路徑指的是不是同一個地方。
* git 印出來的是解析過符號連結的真實路徑,而推導出來的那一條可能經過連結
* (家目錄本身就常是一條連結),逐字比對會把同一棵工作樹判成兩棵。
*/
function samePath(a, b) {
return a === b || resolve(a) === resolve(b);
}
/** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */
function resolve(path) {
try {
return realpathSync(path);
} catch {
return path;
}
}
/**
* 遠端有沒有這一支分支。
*
* 比對用全名 `refs/heads/<ref>`:`ls-remote --heads origin main` 的樣式比對吃的是
* ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對,
* 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。
*/
function onRemote(git, ref) {
return git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== '';
}
function onLocal(git, ref) {
return git('branch', '--list', ref).trim() !== '';
}
/**
* 建立失敗時把半成品清掉。
*
* `git worktree add` 失敗時仍會把新分支留下來,而那是最難查的半成品:下一次重跑會走到
* 「目標分支已存在」那條路,起點從此不再是遠端的來源分支。本來就存在的分支不能碰——
* 上面可能有別人的進度。
*/
function rollback(git, { worktree, branch, 保留分支 }) {
quietly(git, ['worktree', 'remove', '--force', worktree]);
quietly(git, ['worktree', 'prune']);
if (!保留分支) quietly(git, ['branch', '-D', branch]);
}
/** 清理用的 git:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */
function quietly(git, args) {
try {
git(...args);
} catch {
// 清不掉就算了:原本的錯誤比清理的錯誤重要
}
}
/**
* 這棵工作樹要怎麼把依賴裝起來。
* 偵測不到就回空陣列,不亂猜——猜錯的指令比沒有指令更浪費時間。
*/
function installHints(worktree) {
return INSTALL_HINTS.filter(([file]) => existsSync(join(worktree, file))).map(([, command]) => command);
}