Files
tea-sdlc/scripts/branch-prep.js
T
jiantw83andClaude Opus 5 66064a751e fix(lib): 補回搬家時掉了的 rmSync,並讓 origin 檢查只有一份
review 抓到一個沉默的失效:rollback 最後那一手「git 清不乾淨時把目錄本身也清掉」呼叫
rmSync,但把這段從 branch-prep 搬進 lib 時,import 留在了原處。那一行落在自己的
try/catch 裡,所以 ReferenceError 被吞掉——註解承諾的事從來沒發生過,而且測試全綠。
下一次重跑會撞上 WORKTREE_PATH_TAKEN,人看到的是一句與真正原因無關的錯誤。

順手收掉兩支腳本各寫一次的 origin 檢查(只有「為什麼需要它」那一句不同,由呼叫端給),
並把 lib 檔頭「負責六件事」改成七件——工作樹的一生現在整個住在這裡。

正本三處跟著改:
- 查現況那一行補上 --dry-run。pr-watch 在 PR 已終止時會順手清掉工作樹,而「我想看一下
  留言」不該把清理順便做掉——清不清理是使用者的決定。
- 拿掉「使用者堅持要繼續就繼續」:它與同一份正本的邊界(已合併或關閉時不繼續處理留言)
  直接矛盾,而在一棵該被清掉的工作樹上改東西,那些改動不會進到任何 PR 裡。
- worktree-ensure 的指令補上 --path:目標專案多半不是當前目錄,而「不必先手動 cd」
  正是這一步要解決的問題。

議題 #42

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

178 lines
7.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
/**
* 備妥開工的分支與工作樹。
*
* 每顆工作包在一棵屬於自己的工作樹上開工,不在原地切換分支。這樣同時持有幾顆工作包
* 都互不干擾:各自的未提交變更、各自的建置產物,而 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,
requireOrigin,
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);
requireOrigin(git, path, '工作樹的起點一律取自 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);
}