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>
This commit is contained in:
+190
-110
@@ -1,6 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 備妥開工的分支。
|
||||
* 備妥開工的分支與工作樹。
|
||||
*
|
||||
* 每顆工作包在一棵屬於自己的工作樹上開工,不在原地切換分支。這樣同時持有幾顆工作包
|
||||
* 都互不干擾:各自的未提交變更、各自的建置產物,而 agent 無論什麼時候去讀檔,
|
||||
* 都只會讀到它該讀的那份程式碼——agent 是非同步的,它不會察覺自己讀到的是別顆工作包
|
||||
* 的內容,產出看起來完全合理,只是接錯了上下文。
|
||||
*
|
||||
* 工作樹一律建立,沒有例外。建不起來就明確中止,不默默退回原地切分支:靜默降級會讓
|
||||
* 使用者以為自己在隔離環境裡,其實在原地改。
|
||||
*
|
||||
* 命名規則(議題 #1 的正本):
|
||||
* - 從開發分支長出 → `{類型}/{英文-kebab-需求描述}/main`
|
||||
@@ -10,22 +18,29 @@
|
||||
* 需求描述由議題標題翻譯——那是 agent 的事,不是腳本的事,所以這裡只收 `--slug`
|
||||
* 並驗格式:英文 kebab、≤40 字元。中文分支名會讓 CI 與 URL 出問題,擋在建立之前。
|
||||
*
|
||||
* 三處「不弄丟別人的東西」:
|
||||
* - 工作區不乾淨就不動手,免得把不相干的改動帶進這顆工作包的分支。
|
||||
* - 來源分支在遠端已存在時 pull 而不是重建。
|
||||
* - 目標分支已存在時接上去而不是從來源蓋掉。
|
||||
* 三種情況都在任何 git 寫入之前判斷完:試跑印得出漂亮的計畫、實跑卻中途炸掉,
|
||||
* 是最難查的那種落差。
|
||||
* 建分支與建工作樹是同一個原子動作:先 `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 --source <來源分支> --slug <英文-kebab>
|
||||
* node scripts/branch-prep.js --repo <owner/name> --source <來源分支> --slug <英文-kebab>
|
||||
* [--type feat] [--path <目標專案>] [--dry-run]
|
||||
*/
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ScriptError, main, parseFlags, runGit } from './lib.js';
|
||||
import { ScriptError, main, parseFlags, parseRepo, runGit, worktreePath } from './lib.js';
|
||||
|
||||
/** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */
|
||||
const SLUG_MAX = 40;
|
||||
@@ -33,67 +48,67 @@ 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: ['source', 'slug'],
|
||||
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);
|
||||
|
||||
if (!existsSync(join(path, '.git'))) {
|
||||
throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`);
|
||||
}
|
||||
|
||||
const git = (...args) => runGit(args, { cwd: 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(' ')}`),
|
||||
};
|
||||
if (!git('remote').split('\n').includes('origin')) {
|
||||
throw new ScriptError(
|
||||
'NO_ORIGIN',
|
||||
`${path} 沒有 origin 遠端;工作樹的起點一律取自 origin/{來源分支},請先設定 origin`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const args of commands) runGitStep(git, args, source);
|
||||
const plan = planWorktree(git, { repo, source, branch, worktree });
|
||||
const 報告 = { path, repo, source, branch, worktree, 分支: { 動作: plan.動作 } };
|
||||
|
||||
return { path, source, branch, 來源, 分支 };
|
||||
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) } };
|
||||
});
|
||||
|
||||
|
||||
@@ -159,72 +174,137 @@ function isKebab(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、只有本地就切過去——
|
||||
* 差別只在「兩邊都沒有」時怎麼辦,所以那一段由呼叫端給。
|
||||
* 算出要把這棵工作樹弄到手需要哪幾個 git 指令。
|
||||
*
|
||||
* 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令,
|
||||
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。
|
||||
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成,
|
||||
* 所以試跑與實跑在同一個地方被擋下來。
|
||||
*
|
||||
* @param {(ref: string) => {commands: string[][], 動作: string}} handlers.missing 兩邊都沒有時
|
||||
* @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了
|
||||
*/
|
||||
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' };
|
||||
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 (onLocal) {
|
||||
return {
|
||||
commands: [['checkout', ref]],
|
||||
位置: '本地',
|
||||
動作: handlers.onLocal ?? '用本地既有',
|
||||
};
|
||||
if (!既有 && 目錄還在) {
|
||||
throw new ScriptError(
|
||||
'WORKTREE_PATH_TAKEN',
|
||||
`${worktree} 已經有東西了,但它不是這個 repo 的工作樹(可能是別的 clone 留下的);` +
|
||||
'請確認裡面沒有還沒保存的東西之後移除它,再重跑',
|
||||
);
|
||||
}
|
||||
return handlers.missing(ref);
|
||||
|
||||
// 起點一律取自遠端:本機同名分支可能落後好幾天,靜默拿它當起點的後果太隱蔽
|
||||
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, 動作: '從來源建立' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一個 git 步驟,並把已知會發生的失敗換成看得懂的錯誤碼。
|
||||
* `merge --ff-only` 失敗幾乎都是同一件事:本地有沒推上去的 commit,而遠端也往前走了。
|
||||
* 原始的 git 訊息說得不夠白,使用者需要知道下一步是 rebase 還是先推。
|
||||
* 這個 repo 目前有哪幾棵工作樹。
|
||||
* `--porcelain` 的輸出是以空行分隔的區塊,每塊第一行是 `worktree <路徑>`,
|
||||
* 分支則是 `branch refs/heads/<名字>`;detached 的工作樹沒有 branch 那一行。
|
||||
*/
|
||||
function runGitStep(git, args, source) {
|
||||
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 git(...args);
|
||||
} catch (error) {
|
||||
if (args[0] === 'merge') {
|
||||
throw new ScriptError(
|
||||
'SOURCE_DIVERGED',
|
||||
`${source} 的本地與遠端已經分歧,無法直接快轉;` +
|
||||
'請先把本地的 commit 推上去或 rebase 到遠端之後,再執行一次',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user