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>
This commit is contained in:
2026-09-17 17:33:44 +08:00
co-authored by Claude Opus 5
parent 643293b21c
commit 7196385c5e
2 changed files with 184 additions and 148 deletions
+15 -147
View File
@@ -31,6 +31,9 @@
* 別的東西佔住時中止而不是硬蓋過去。兩者都在任何 git 寫入之前判斷完:試跑印得出
* 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。
*
* 算指令、跑指令與失敗回滾都在 lib 的 `planWorktree`/`createWorktree`——重建工作樹
* (`worktree-ensure`)走的是同一套,兩邊各寫一份遲早會在「起點取自哪裡」這種地方分岔。
*
* 工作樹路徑由 `owner/repo/分支名` 純函式推導(見 lib 的 worktreePath),
* 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。
*
@@ -38,9 +41,18 @@
* node scripts/branch-prep.js --repo <owner/name> --source <來源分支> --slug <英文-kebab>
* [--type feat] [--path <目標專案>] [--dry-run]
*/
import { existsSync, realpathSync, rmSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { ScriptError, main, openGitRepo, parseFlags, parseRepo, worktreePath } from './lib.js';
import {
ScriptError,
createWorktree,
main,
openGitRepo,
parseFlags,
parseRepo,
planWorktree,
worktreePath,
} from './lib.js';
/** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */
const SLUG_MAX = 40;
@@ -93,16 +105,7 @@ main(async () => {
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;
}
}
createWorktree(git, plan, { worktree, branch });
return { ...報告, 提示: { 訊息: CLEAN_WORKTREE, 安裝指令: installHints(worktree) } };
});
@@ -169,141 +172,6 @@ function isKebab(value) {
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(value);
}
/**
* 算出要把這棵工作樹弄到手需要哪幾個 git 指令。
*
* 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令,
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成,
* 所以試跑與實跑在同一個地方被擋下來。
*
* @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了
*/
function planWorktree(git, { 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 || realOrSelf(a) === realOrSelf(b);
}
/** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */
function realOrSelf(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 清不乾淨時把目錄本身也清掉:這條路徑在這次執行之前不存在(不存在是建立的前提),
// 裡面不可能有使用者的東西;留著它下一次重跑會直接撞上 WORKTREE_PATH_TAKEN
try {
rmSync(worktree, { recursive: true, force: true });
} catch {
// 連目錄都刪不掉就只能留著:原本的錯誤比清理的錯誤重要
}
}
/** 清理用的 git:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */
function quietly(git, args) {
try {
git(...args);
} catch {
// 清不掉就算了:原本的錯誤比清理的錯誤重要
}
}
/**
* 這棵工作樹要怎麼把依賴裝起來。
* 偵測不到就回空陣列,不亂猜——猜錯的指令比沒有指令更浪費時間。