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>
178 lines
7.1 KiB
JavaScript
178 lines
7.1 KiB
JavaScript
#!/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);
|
||
}
|