From 7196385c5e8c77f22bce6f2d3b8143fa9e719ff9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 17:26:26 +0800 Subject: [PATCH] =?UTF-8?q?refactor(branch-prep):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=A8=B9=E7=9A=84=E3=80=8C=E7=AE=97=E3=80=8D?= =?UTF-8?q?=E8=88=87=E3=80=8C=E5=81=9A=E3=80=8D=E6=94=B6=E9=80=B2=20lib?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重建工作樹(下一個 commit 的 worktree-ensure)要走的是與開工時完全同一套:一樣先 git fetch 更新遠端引用,一樣不設 upstream,分支已經存在就接上去而不是長一棵空的。 兩邊各寫一份,遲早會在「起點取自哪裡」這種地方分岔——而那種分岔要等到有人的進度 不見了才會被發現。 planWorktree 多接受一種用法:不給來源分支就是「重建既有分支的工作樹」,沒有「從來源 長一支新的」那條路,走到那裡就是 BRANCH_NOT_FOUND。branch-prep 的行為完全不變。 議題 #42 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/branch-prep.js | 162 ++++----------------------------------- scripts/lib.js | 170 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 148 deletions(-) diff --git a/scripts/branch-prep.js b/scripts/branch-prep.js index f6d100b..fca6390 100644 --- a/scripts/branch-prep.js +++ b/scripts/branch-prep.js @@ -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 --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/`:`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 { - // 清不掉就算了:原本的錯誤比清理的錯誤重要 - } -} - /** * 這棵工作樹要怎麼把依賴裝起來。 * 偵測不到就回空陣列,不亂猜——猜錯的指令比沒有指令更浪費時間。 diff --git a/scripts/lib.js b/scripts/lib.js index bc97400..6647e02 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -13,7 +13,7 @@ */ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs'; +import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -518,6 +518,174 @@ export function removeWorktree(worktree) { return { ...state, removed: true, reason: 'removed' }; } +/** + * 算出要把這棵工作樹弄到手需要哪幾個 git 指令。 + * + * 建立與**重建**共用這一份:換一台機器接手時工作樹本來就不存在,而重建若另寫一套, + * 兩邊遲早會在「起點取自哪裡」「要不要設 upstream」這種地方分岔。 + * + * 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令, + * 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成, + * 所以試跑與實跑在同一個地方被擋下來。 + * + * @param {(...args: string[]) => string} git 綁在目標專案上的 git 執行器 + * @param {{source?: string, branch: string, worktree: string}} 目標 + * 不給 `source` 就是「重建既有分支的工作樹」:分支必須已經存在, + * 沒有「從來源長一支新的」這條路。 + * @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了 + */ +export 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 (source !== undefined && !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, 動作: '接上遠端既有' }; + } + if (source === undefined) { + // 重建的路走到這裡代表那一支分支已經不見了——憑空長一棵空的只會讓人以為進度還在 + throw new ScriptError( + 'BRANCH_NOT_FOUND', + `分支 ${branch} 在本機與遠端都不存在,重建不出工作樹;` + + '請確認分支名,或先把它推上遠端', + ); + } + commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${source}`]); + return { commands, 動作: '從來源建立' }; +} + +/** + * 照計畫把工作樹建起來,失敗時回到原狀。 + * 與 `planWorktree` 成對:算歸算、做歸做,而試跑只跑前半段。 + * @param {{commands: string[][]}} plan planWorktree 的結果 + */ +export function createWorktree(git, plan, { worktree, branch }) { + if (plan.commands.length === 0) return; + + // 既有的本地分支不是這次建的,回滾時不能連它一起刪掉 + const 分支本來就在 = onLocal(git, branch); + try { + for (const args of plan.commands) git(...args); + } catch (error) { + rollback(git, { worktree, branch, 保留分支: 分支本來就在 }); + throw error; + } +} + +/** + * 這個 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/`:`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 { + // 清不掉就算了:原本的錯誤比清理的錯誤重要 + } +} + // ── 四層前置檢查 ─────────────────────────────────────────────────── /**