Files
tea-sdlc/scripts/branch-prep.js
T
jiantw83andClaude Opus 5 7196385c5e refactor(branch-prep): 建立工作樹的「算」與「做」收進 lib
重建工作樹(下一個 commit 的 worktree-ensure)要走的是與開工時完全同一套:一樣先
git fetch 更新遠端引用,一樣不設 upstream,分支已經存在就接上去而不是長一棵空的。
兩邊各寫一份,遲早會在「起點取自哪裡」這種地方分岔——而那種分岔要等到有人的進度
不見了才會被發現。

planWorktree 多接受一種用法:不給來源分支就是「重建既有分支的工作樹」,沒有「從來源
長一支新的」那條路,走到那裡就是 BRANCH_NOT_FOUND。branch-prep 的行為完全不變。

議題 #42

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

182 lines
7.3 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 寫入之前判斷完:試跑印得出
* 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。
*
* 算指令、跑指令與失敗回滾都在 lib 的 `planWorktree`/`createWorktree`——重建工作樹
* (`worktree-ensure`)走的是同一套,兩邊各寫一份遲早會在「起點取自哪裡」這種地方分岔。
*
* 工作樹路徑由 `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 } from 'node:fs';
import { join } from 'node:path';
import {
ScriptError,
createWorktree,
main,
openGitRepo,
parseFlags,
parseRepo,
planWorktree,
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, { 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(' ')}`) };
}
createWorktree(git, plan, { worktree, branch });
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);
}
/**
* 這棵工作樹要怎麼把依賴裝起來。
* 偵測不到就回空陣列,不亂猜——猜錯的指令比沒有指令更浪費時間。
*/
function installHints(worktree) {
return INSTALL_HINTS.filter(([file]) => existsSync(join(worktree, file))).map(([, command]) => command);
}