From 865561c63bd5617bbdbd0241c2ea7ab4661f3d2b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 15:54:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(branch-prep):=20=E4=B8=80=E5=BE=8B?= =?UTF-8?q?=E5=9C=A8=E7=8D=A8=E7=AB=8B=E7=9A=84=E5=B7=A5=E4=BD=9C=E6=A8=B9?= =?UTF-8?q?=E4=B8=8A=E9=96=8B=E5=B7=A5=EF=BC=8C=E4=B8=8D=E5=9C=A8=E5=8E=9F?= =?UTF-8?q?=E5=9C=B0=E5=88=87=E6=8F=9B=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一份 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) --- scripts/branch-prep.js | 300 ++++++++++++++++++++------------ scripts/lib.js | 76 ++++++++ test/branch-prep.test.js | 348 ++++++++++++++++++++++++++----------- test/worktree-path.test.js | 131 ++++++++++++++ 4 files changed, 646 insertions(+), 209 deletions(-) create mode 100644 test/worktree-path.test.js diff --git a/scripts/branch-prep.js b/scripts/branch-prep.js index bee193a..4d2571c 100644 --- a/scripts/branch-prep.js +++ b/scripts/branch-prep.js @@ -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 --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/`:`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/`:`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); +} diff --git a/scripts/lib.js b/scripts/lib.js index 9380f47..9ac8553 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -12,6 +12,7 @@ * 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。 */ import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { accessSync, constants, existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -51,6 +52,50 @@ export function promptsDir() { return join(pluginRoot(), 'prompts'); } +/** + * tea-sdlc 在使用者家目錄底下的家。工作樹集中放在這裡,清理時只有一個地方要看。 + * `TEA_SDLC_HOME` 只是測試與 CI 的覆寫出口,正常使用不必設。 + */ +export function teaSdlcHome() { + return process.env.TEA_SDLC_HOME?.trim() || join(homedir(), '.tea-sdlc'); +} + +/** 所有工作樹的集中處 */ +export function worktreesRoot() { + return join(teaSdlcHome(), 'worktrees'); +} + +/** + * 由「哪顆工作包」純函式推導出「它的工作樹在哪」。 + * + * 不查表、不讀狀態檔:任何流程(開工、處理留言、清理)都要算得出同一條路徑, + * 換一台機器或換一個 agent 也一樣,不存在就重建。 + * + * 目錄名取雜湊而不是把分支名的斜線攤平成 `-`:攤平會讓 `feat/a-b/main` 與 + * `feat/a/b/main` 撞成同一個目錄,而本專案的分支命名規則恰好讓這種形狀有機會出現。 + * 可讀性的缺口由 `git worktree list` 補上——它本來就會把分支名印在路徑旁邊。 + * + * 推導前先正規化:沒有它,同一棵工作樹會因為輸入多一個空白或大小寫不同而被推導成兩條路徑。 + * + * @param {string} repo owner/name + * @param {string} branch 分支名,可含斜線 + * @returns {string} ~/.tea-sdlc/worktrees/{sha256 前 12 碼} + */ +export function worktreePath(repo, branch) { + const key = `${normalizeRef(repo)}/${normalizeRef(branch)}`; + const hash = createHash('sha256').update(key).digest('hex').slice(0, 12); + return join(worktreesRoot(), hash); +} + +/** 逐段修掉空白再轉小寫:`Plugins / Tea-SDLC` 與 `plugins/tea-sdlc` 是同一個東西。 */ +function normalizeRef(value) { + return value + .split('/') + .map((segment) => segment.trim()) + .join('/') + .toLowerCase(); +} + // ── 套件 manifest ───────────────────────────────────────────────── /** @@ -584,6 +629,37 @@ export async function countUnmergedComments(login, repo, index) { return unmerged; } +// ── 碼錶 ─────────────────────────────────────────────────────────── + +/** + * 目前跑在自己身上的碼錶。 + * + * Gitea 只讓人讀自己的碼錶,看不到別人的——所以這份清單的語意永遠是「**我**的錶」, + * 它用來發現自己忘了停上一顆,不是用來判斷別人有沒有在做(那看 assignee)。 + * @param {{base: string, token: string}} login + * @returns {Promise} + */ +export async function listStopwatches(login) { + const path = '/user/stopwatches'; + return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; +} + +/** + * 這些碼錶裡,跑在指定議題上的那一顆。 + * 比對要連 repo 一起看:不同 repo 的同號議題是兩件事。 + * @param {object[]} watches listStopwatches 的結果 + * @param {string} repo owner/name + * @param {number} index + * @returns {object|null} + */ +export function stopwatchOnIssue(watches, repo, index) { + return ( + watches.find( + (watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index, + ) ?? null + ); +} + // ── 標籤 ─────────────────────────────────────────────────────────── /** diff --git a/test/branch-prep.test.js b/test/branch-prep.test.js index 6ae8fa0..76f956c 100644 --- a/test/branch-prep.test.js +++ b/test/branch-prep.test.js @@ -1,33 +1,52 @@ /** - * 備妥開工的分支。 + * 備妥開工的工作樹。 * - * 兩件事各自要驗: + * 三件事各自要驗: * 1. **分支命名**是純字串規則,表格驅動,成本最低、回歸價值最高。 * 規則錯了會一路帶到 PR 標題與 CI,事後改名很痛。 - * 2. **不覆蓋他人進度**。來源分支在遠端已存在時要 pull 而不是重建, - * 目標分支已存在時要切過去而不是從來源蓋掉。這兩件事沒有真的遠端就驗不出來, - * 所以測試在臨時 repo 上跑真的 git。 + * 2. **一律在獨立的工作樹上開工**。主工作區一個字都不該被動到——包含它未提交的變更, + * 以及它現在停在哪一支分支上。 + * 3. **原子性與不覆蓋他人進度**。分支與工作樹是同一個動作,失敗時不留半成品; + * 起點一律取自遠端,目標分支已存在時接上去而不是蓋掉。 + * + * git 不做替身:在臨時 repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。 + * 工作樹則以 TEA_SDLC_HOME 改指到測試暫存,不落到開發者真正的家目錄。 */ import test from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { writeFileSync } from 'node:fs'; import { runScript, tmpRoot } from './helpers/run-script.js'; import { makeTempRepoWithRemote } from './helpers/temp-repo.js'; -/** 開一個有遠端的臨時 repo,並登記在測試結束時清掉 */ +const REPO = 'plugins/tea-sdlc'; + +/** 開一個有遠端的臨時 repo 與一個空的工作樹家,並登記在測試結束時清掉 */ function withRepo(t) { const repo = makeTempRepoWithRemote(); t.after(() => repo.cleanup()); - return repo; + mkdirSync(tmpRoot, { recursive: true }); + const home = mkdtempSync(join(tmpRoot, 'home-')); + t.after(() => { + // 工作樹裡有 .git 檔指回主 repo,直接刪目錄即可;主 repo 隨後也會被刪掉 + rmSync(home, { recursive: true, force: true }); + }); + return { ...repo, home }; } -const run = (repo, args) => runScript('branch-prep.js', ['--path', repo.dir, ...args]); +const run = (repo, args) => + runScript('branch-prep.js', ['--repo', REPO, '--path', repo.dir, ...args], { + env: { TEA_SDLC_HOME: repo.home }, + }); -/** 目前 checkout 在哪一支 */ +/** 主工作區目前停在哪一支 */ const currentBranch = (repo) => repo.git('rev-parse', '--abbrev-ref', 'HEAD'); +/** 工作樹裡 checkout 出來的是哪一支 */ +const branchIn = (dir) => + execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); + // ── 分支命名規則 ─────────────────────────────────────────────────── const NAMING = [ @@ -66,13 +85,14 @@ const NAMING = [ for (const { name, source, args, expected } of NAMING) { test(`命名:${name}`, async (t) => { const repo = withRepo(t); - if (source !== 'master') repo.git('checkout', '-q', '-B', source); + // 來源分支一律取自遠端,所以先讓遠端有這一支 + if (source !== 'master') repo.pushFromElsewhere(source, `${expected.replace(/\//g, '-')}.txt`, '來源\n'); const { code, json } = await run(repo, ['--source', source, ...args]); assert.equal(code, 0, json.error?.message); assert.equal(json.data.branch, expected); - assert.equal(currentBranch(repo), expected); + assert.equal(branchIn(json.data.worktree), expected, '工作樹裡 checkout 出來的要是新分支'); }); } @@ -128,7 +148,7 @@ test('從開發分支長出卻沒給 --type 時,指名缺的是哪一個', asy test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵樹下', async (t) => { const repo = withRepo(t); - repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main'); + repo.pushFromElsewhere('feat/wp-extract-contract/main', 'src.txt', '來源\n'); const { json } = await run(repo, [ '--source', 'feat/wp-extract-contract/main', '--type', 'fix', '--slug', 'crlf', @@ -138,41 +158,72 @@ test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵 assert.match(json.error.message, /feat/); }); -// ── 來源分支:pull 而不是重建 ───────────────────────────────────── +// ── 一律建立工作樹 ───────────────────────────────────────────────── -test('來源分支在遠端已存在時執行 pull,帶進別人的進度', async (t) => { +test('分支與工作樹一起建立,主工作區完全不被動到', async (t) => { + const repo = withRepo(t); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 0, json.error?.message); + assert.equal(existsSync(json.data.worktree), true, '工作樹要真的在磁碟上'); + assert.equal(branchIn(json.data.worktree), 'feat/mine/main'); + assert.equal(currentBranch(repo), 'master', '主工作區不該被切走:agent 會在那裡讀到不屬於它的程式碼'); +}); + +test('主工作區有未提交的變更時照樣開得了工,那正是工作樹要解決的事', async (t) => { + const repo = withRepo(t); + writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n'); + writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n'); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 0, json.error?.message); + assert.equal( + repo.git('status', '--porcelain').includes('stray.txt'), + true, + '未提交的變更要原封不動留在主工作區,不被帶到新分支上', + ); + assert.equal(existsSync(join(json.data.worktree, 'stray.txt')), false, '也不該跟到工作樹裡'); +}); + +test('工作樹路徑在集中的家底下,不長在目標專案裡也不長在它的兄弟目錄', async (t) => { + const repo = withRepo(t); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(json.data.worktree.startsWith(join(repo.home, 'worktrees')), true); + assert.equal(repo.git('status', '--porcelain'), '', '工作樹不該出現在目標專案的 git status 裡'); +}); + +// ── 起點一律取自遠端 ─────────────────────────────────────────────── + +test('本機落後時,工作樹仍從遠端的最新狀態長出', async (t) => { const repo = withRepo(t); repo.pushFromElsewhere('master', 'theirs.txt', '別人的進度\n'); const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); assert.equal(code, 0, json.error?.message); - assert.ok(existsSync(join(repo.dir, 'theirs.txt')), '別人推上去的檔案要被帶進來'); - assert.equal(json.data.來源.動作, 'pull'); + assert.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true, '起點要是遠端的最新狀態'); + assert.equal(existsSync(join(repo.dir, 'theirs.txt')), false, '主工作區不必被順便更新'); }); -test('來源分支只在本地時照樣可用,不會因為遠端沒有就報錯', async (t) => { +test('遠端沒有來源分支時明確中止,不退回本機同名分支', async (t) => { const repo = withRepo(t); - repo.git('checkout', '-q', '-B', 'feat/local-only/main'); - repo.git('checkout', '-q', 'master'); + repo.git('branch', 'feat/local-only/main'); - const { code, json } = await run(repo, [ - '--source', 'feat/local-only/main', '--slug', 'sub-feature', - ]); - - assert.equal(code, 0, json.error?.message); - assert.equal(json.data.來源.動作, '用本地既有'); -}); - -test('來源分支本地與遠端都沒有時,回可區分的錯誤碼', async (t) => { - const repo = withRepo(t); - - const { code, json } = await run(repo, [ - '--source', 'feat/不存在/main', '--slug', 'whatever', - ]); + const { code, json } = await run(repo, ['--source', 'feat/local-only/main', '--slug', 'sub-feature']); assert.equal(code, 1); assert.equal(json.error.code, 'SOURCE_NOT_FOUND'); + assert.match(json.error.message, /推/, '要指出下一步是把來源分支推上去'); + assert.match(json.error.message, /來源/, '或改指定一個已存在的來源分支'); + assert.equal( + repo.git('branch', '--list', 'feat/local-only/sub-feature'), + '', + '擋下來就不該已經建好分支', + ); }); test('遠端分支的比對是全名,不是尾段', async (t) => { @@ -188,95 +239,150 @@ test('遠端分支的比對是全名,不是尾段', async (t) => { assert.equal(json.error.code, 'SOURCE_NOT_FOUND', '遠端沒有 main 這一支,不該被 feat/x/main 冒名頂替'); }); -// ── 開工前的工作區必須乾淨 ───────────────────────────────────────── - -test('工作區有未提交的改動時擋下,不把它們帶進新分支', async (t) => { +test('不為新分支設定 upstream:此刻遠端還沒有這一支,設了會誤指到來源分支', async (t) => { const repo = withRepo(t); - writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n'); - - const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); - - assert.equal(code, 1); - assert.equal(json.error.code, 'DIRTY_WORKTREE'); - assert.equal(currentBranch(repo), 'master', '擋下來就不該已經切過分支'); - assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '也不該已經建好分支'); -}); - -test('未追蹤的檔案同樣算不乾淨:它會跟著被帶到新分支上', async (t) => { - const repo = withRepo(t); - writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n'); const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); - assert.equal(json.error.code, 'DIRTY_WORKTREE'); - assert.match(json.error.message, /stray\.txt/, '要指名是哪些檔案擋住了'); + assert.equal( + repo.git('for-each-ref', '--format=%(upstream)', 'refs/heads/feat/mine/main'), + '', + 'upstream 指到來源分支的話,之後 git pull 會把來源分支的提交拉進來', + ); + assert.equal(json.data.branch, 'feat/mine/main'); }); -test('工作區不乾淨時,--dry-run 也要照樣說出來', async (t) => { - // 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差 +// ── 原子性:失敗不留半成品 ───────────────────────────────────────── + +test('工作樹建不起來時,不留下那一支已經建好的分支', async (t) => { + // git worktree add 失敗時仍會把分支留下來,那是最難查的半成品: + // 下一次重跑會走到「目標分支已存在」那條路,起點從此不是遠端的來源分支 const repo = withRepo(t); - writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n'); + const blocked = join(repo.home, 'blocker'); + writeFileSync(blocked, '這是一個檔案,不是目錄\n'); - const { json } = await run(repo, [ - '--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run', - ]); - - assert.equal(json.error.code, 'DIRTY_WORKTREE'); -}); - -// ── 來源分支與遠端分歧 ───────────────────────────────────────────── - -test('本地來源分支與遠端分歧時,回可區分的錯誤碼而不是 git 的原始訊息', async (t) => { - const repo = withRepo(t); - // 本地有一顆沒推的 commit,遠端也往前走了一顆 - writeFileSync(join(repo.dir, 'mine.txt'), '我的\n'); - repo.git('add', '-A'); - repo.git('commit', '-qm', '本地未推的 commit'); - repo.pushFromElsewhere('master', 'theirs.txt', '別人的\n'); - - const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + const { code, json } = await runScript( + 'branch-prep.js', + ['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', 'mine'], + { env: { TEA_SDLC_HOME: blocked } }, + ); assert.equal(code, 1); - assert.equal(json.error.code, 'SOURCE_DIVERGED'); - assert.match(json.error.message, /master/); - assert.equal(currentBranch(repo), 'master', '不該留在半途的狀態'); + assert.equal(json.error.code, 'GIT_FAILED'); + assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '分支不該留下來'); + assert.equal( + repo.git('worktree', 'list', '--porcelain').includes('feat/mine/main'), + false, + '也不該留下工作樹的中繼資料', + ); }); -// ── 目標分支:已存在就切過去,不覆蓋 ─────────────────────────────── +// ── 目標分支已存在:接上去,不覆蓋 ───────────────────────────────── -test('目標分支已在遠端時切過去,保留上面已有的進度', async (t) => { +test('目標分支已在遠端時接上去,保留上面已有的進度', async (t) => { const repo = withRepo(t); repo.pushFromElsewhere('feat/mine/main', 'progress.txt', '已經做了一半\n'); const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); assert.equal(code, 0, json.error?.message); - assert.equal(currentBranch(repo), 'feat/mine/main'); - assert.ok( - existsSync(join(repo.dir, 'progress.txt')), + assert.equal(json.data.分支.動作, '接上遠端既有'); + assert.equal( + existsSync(join(json.data.worktree, 'progress.txt')), + true, '遠端已有的分支要接上去,不是從來源重建一個空的蓋掉', ); - assert.equal(json.data.分支.動作, '接上遠端既有'); }); -test('目標分支只在本地時切過去,不重建', async (t) => { +test('目標分支只在本地時接上去,不重建', async (t) => { const repo = withRepo(t); - repo.git('checkout', '-q', '-b', 'feat/mine/main'); - repo.git('checkout', '-q', 'master'); + repo.git('branch', 'feat/mine/main'); const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); - assert.equal(json.data.分支.動作, '切換到本地既有'); - assert.equal(currentBranch(repo), 'feat/mine/main'); + assert.equal(json.data.分支.動作, '接上本地既有'); + assert.equal(branchIn(json.data.worktree), 'feat/mine/main'); }); -test('目標分支不存在時從來源建立', async (t) => { +test('目標分支不存在時從遠端的來源分支建立', async (t) => { const repo = withRepo(t); const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'brand-new']); assert.equal(json.data.分支.動作, '從來源建立'); - assert.equal(currentBranch(repo), 'feat/brand-new/main'); +}); + +// ── 冪等:工作樹已經在了 ─────────────────────────────────────────── + +test('工作樹已經在了就沿用,不動裡面還沒提交的東西', async (t) => { + const repo = withRepo(t); + const first = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + writeFileSync(join(first.json.data.worktree, 'wip.txt'), '做到一半\n'); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.worktree, first.json.data.worktree, '推導出來的是同一條路徑'); + assert.equal(json.data.分支.動作, '沿用既有工作樹'); + assert.equal(existsSync(join(json.data.worktree, 'wip.txt')), true, '重跑不該把做到一半的東西刷掉'); +}); + +test('推導出來的路徑被別的東西佔住時明確中止,不硬蓋過去', async (t) => { + const repo = withRepo(t); + const { json: first } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + // 造出「別的 clone 留下的目錄」:本 repo 的 git 已經不認得它了,但路徑還在 + repo.git('worktree', 'remove', '--force', first.data.worktree); + mkdirSync(first.data.worktree, { recursive: true }); + writeFileSync(join(first.data.worktree, '別人的東西.txt'), 'x\n'); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 1); + assert.equal(json.error.code, 'WORKTREE_PATH_TAKEN'); + assert.match(json.error.message, new RegExp(first.data.worktree), '要指名是哪一條路徑被佔住'); + assert.equal(existsSync(join(first.data.worktree, '別人的東西.txt')), true, '不得動到裡面的東西'); +}); + +// ── 乾淨的工作樹 ─────────────────────────────────────────────────── + +test('建好後說明這是一棵乾淨的工作樹,並依專案檔給出安裝指令', async (t) => { + const repo = withRepo(t); + repo.pushFromElsewhere('master', 'package.json', '{"name":"demo"}\n'); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.deepEqual(json.data.提示.安裝指令, ['npm install']); + assert.match(json.data.提示.訊息, /乾淨/); +}); + +test('偵測不到專案檔時不亂猜指令,只說明它是乾淨的', async (t) => { + const repo = withRepo(t); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.deepEqual(json.data.提示.安裝指令, []); + assert.match(json.data.提示.訊息, /乾淨/); +}); + +test('多種語言的專案檔都偵測得到,各給各的指令', async (t) => { + const repo = withRepo(t); + repo.pushFromElsewhere('master', 'composer.json', '{}\n'); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.deepEqual(json.data.提示.安裝指令, ['composer install']); +}); + +test('不複製也不連結依賴、建置產物與機密檔案', async (t) => { + const repo = withRepo(t); + mkdirSync(join(repo.dir, 'node_modules', 'left-pad'), { recursive: true }); + writeFileSync(join(repo.dir, 'node_modules', 'left-pad', 'index.js'), '\n'); + writeFileSync(join(repo.dir, '.env'), 'TOKEN=秘密\n'); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(existsSync(join(json.data.worktree, 'node_modules')), false, 'symlink 會把隔離接回去'); + assert.equal(existsSync(join(json.data.worktree, '.env')), false, '機密一律由使用者自己放'); }); // ── 不留本機狀態檔 ───────────────────────────────────────────────── @@ -286,12 +392,11 @@ test('跑完不在目標專案裡留下任何狀態檔', async (t) => { await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'no-state']); - const status = repo.git('status', '--porcelain'); - assert.equal(status, '', '工作區要是乾淨的:進度只從 Gitea 推導,不寫本機狀態檔'); + assert.equal(repo.git('status', '--porcelain'), '', '進度只從 Gitea 與 git 本身推導,不寫本機狀態檔'); }); test('git 自己的進度訊息不漏到 stderr', async (t) => { - // checkout 與 fetch 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、 + // fetch 與 worktree add 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、 // stderr 乾淨」,漏出去的話呼叫端就得去分辨哪幾行是雜訊。 const repo = withRepo(t); @@ -300,22 +405,43 @@ test('git 自己的進度訊息不漏到 stderr', async (t) => { assert.equal(stderr, ''); }); -// ── 路徑 ─────────────────────────────────────────────────────────── +// ── 路徑與 flag ─────────────────────────────────────────────────── test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => { const { json } = await runScript('branch-prep.js', [ - '--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever', + '--repo', REPO, '--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever', ]); assert.equal(json.error.code, 'NOT_A_GIT_REPO'); }); +test('缺 --repo 時擋下:沒有它推導不出工作樹在哪', async (t) => { + const repo = withRepo(t); + + const { json } = await runScript('branch-prep.js', [ + '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', 'mine', + ], { env: { TEA_SDLC_HOME: repo.home } }); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--repo/); +}); + +test('目標專案沒有 origin 時擋下:起點一律取自遠端', async (t) => { + const repo = withRepo(t); + repo.git('remote', 'remove', 'origin'); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(json.error.code, 'NO_ORIGIN'); +}); + test('回報實際動到的是哪一個目錄,讓人確認沒搞錯專案', async (t) => { const repo = withRepo(t); const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); assert.equal(json.data.path, repo.dir); + assert.equal(json.data.repo, REPO); }); // ── --dry-run ───────────────────────────────────────────────────── @@ -331,23 +457,47 @@ test('--dry-run 印出將執行的 git 指令,且完全不動 repo', async (t) assert.equal(code, 0); assert.equal(json.data.dryRun, true); assert.equal(json.data.branch, 'feat/mine/main'); - assert.ok(json.data.commands.length > 0); assert.ok( json.data.commands.every((c) => c.startsWith('git ')), '預覽的是 git 指令本身,不是自創的描述', ); - assert.equal(currentBranch(repo), 'master', '試跑不該切分支'); assert.equal(repo.git('rev-parse', 'HEAD'), before); assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '試跑不該建分支'); + assert.equal(existsSync(json.data.worktree), false, '試跑不該建工作樹'); }); -test('--dry-run 連分支命名都先算出來,看得到才叫預覽', async (t) => { +test('--dry-run 把 fetch 與建立工作樹兩步都印出來,順序不顛倒', async (t) => { const repo = withRepo(t); - repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main'); + + const { json } = await run(repo, [ + '--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run', + ]); + + assert.deepEqual(json.data.commands, [ + 'git fetch origin', + `git worktree add --no-track -b feat/mine/main ${json.data.worktree} origin/master`, + ], '建分支與建工作樹是同一個指令,不拆成兩步'); +}); + +test('--dry-run 連工作樹路徑都先算出來,看得到才叫預覽', async (t) => { + const repo = withRepo(t); + repo.pushFromElsewhere('feat/wp-extract-contract/main', 'src.txt', '來源\n'); const { json } = await run(repo, [ '--source', 'feat/wp-extract-contract/main', '--slug', 'crlf-fix', '--dry-run', ]); assert.equal(json.data.branch, 'feat/wp-extract-contract/crlf-fix'); + assert.match(json.data.worktree, /worktrees\/[0-9a-f]{12}$/); +}); + +test('遠端沒有來源分支時,--dry-run 也要照樣說出來', async (t) => { + // 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差 + const repo = withRepo(t); + + const { json } = await run(repo, [ + '--source', 'feat/沒有這支/main', '--slug', 'mine', '--dry-run', + ]); + + assert.equal(json.error.code, 'SOURCE_NOT_FOUND'); }); diff --git a/test/worktree-path.test.js b/test/worktree-path.test.js new file mode 100644 index 0000000..8377bb1 --- /dev/null +++ b/test/worktree-path.test.js @@ -0,0 +1,131 @@ +/** + * 工作樹路徑的推導。 + * + * 這是一個純函式,而且是整套工作樹機制的地基:任何流程都要能從「這是哪顆工作包」 + * 算出「它的工作樹在哪」,不查表、不讀狀態檔,換一台機器算出來也要一樣。 + * 算錯的代價是找不到既有的工作樹而重建一棵,或兩顆工作包撞進同一個目錄。 + * + * 這一支直接 import lib,是本專案第二個這麼做的測試:大小寫與空白的變體沒辦法 + * 穿過 CLI 的 kebab 驗證送進去,而表格驅動正是這種純規則最划算的驗法。 + * 最後一則測試把它與 CLI 實際印出的路徑釘在一起,避免兩邊各自為政。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { worktreePath } from '../scripts/lib.js'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { makeTempRepoWithRemote } from './helpers/temp-repo.js'; + +/** 推導只看輸入,不看環境;家目錄則由 TEA_SDLC_HOME 決定,測試固定一個值好比對 */ +const HOME = '/home/tester/.tea-sdlc'; +const derive = (repo, branch) => { + process.env.TEA_SDLC_HOME = HOME; + try { + return worktreePath(repo, branch); + } finally { + delete process.env.TEA_SDLC_HOME; + } +}; + +test('路徑長在固定的家底下,目錄名是 12 碼十六進位', () => { + const path = derive('plugins/tea-sdlc', 'feat/worktree-branch-prep/main'); + + assert.match(path, new RegExp(`^${HOME}/worktrees/[0-9a-f]{12}$`)); +}); + +// ── 同一顆工作包只能推導出一條路徑 ───────────────────────────────── + +const SAME = [ + { + name: '大小寫不同', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: ['Plugins/Tea-SDLC', 'Feat/Mine/Main'], + }, + { + name: '前後有空白', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: [' plugins/tea-sdlc ', ' feat/mine/main '], + }, + { + name: '斜線兩側有空白', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: ['plugins / tea-sdlc', 'feat / mine / main'], + }, +]; + +for (const { name, a, b } of SAME) { + test(`同一顆工作包:${name}的變體推導出同一條路徑`, () => { + assert.equal(derive(...a), derive(...b), '正規化沒做,同一棵工作樹會被推導成兩個目錄'); + }); +} + +// ── 不同的工作包不能撞在一起 ─────────────────────────────────────── + +const DIFFERENT = [ + { + name: '分支名的斜線位置不同:feat/a-b/main 與 feat/a/b/main', + a: ['plugins/tea-sdlc', 'feat/a-b/main'], + b: ['plugins/tea-sdlc', 'feat/a/b/main'], + }, + { + name: '同名分支在不同的 repo 上', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: ['plugins/別的專案', 'feat/mine/main'], + }, + { + name: '同名 repo 在不同的 owner 底下', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: ['someone/tea-sdlc', 'feat/mine/main'], + }, + { + name: '同一個 repo 的不同分支', + a: ['plugins/tea-sdlc', 'feat/mine/main'], + b: ['plugins/tea-sdlc', 'feat/yours/main'], + }, +]; + +for (const { name, a, b } of DIFFERENT) { + test(`不同的工作包:${name},不得撞名`, () => { + assert.notEqual(derive(...a), derive(...b)); + }); +} + +test('分支名含斜線時路徑仍是單層目錄,不會長出巢狀結構', () => { + const path = derive('plugins/tea-sdlc', 'feat/a/b/c/main'); + + assert.equal(path.startsWith(`${HOME}/worktrees/`), true); + assert.equal(path.slice(`${HOME}/worktrees/`.length).includes('/'), false); +}); + +test('換一台機器只有家目錄會變,目錄名不變', () => { + process.env.TEA_SDLC_HOME = '/somewhere/else/.tea-sdlc'; + const elsewhere = worktreePath('plugins/tea-sdlc', 'feat/mine/main'); + delete process.env.TEA_SDLC_HOME; + + const here = derive('plugins/tea-sdlc', 'feat/mine/main'); + + assert.equal(elsewhere.split('/').at(-1), here.split('/').at(-1)); +}); + +// ── 與 CLI 釘在一起 ─────────────────────────────────────────────── + +test('branch-prep 印出的工作樹路徑就是這個函式算出來的那一條', async (t) => { + const repo = makeTempRepoWithRemote(); + t.after(() => repo.cleanup()); + mkdirSync(tmpRoot, { recursive: true }); + const home = mkdtempSync(join(tmpRoot, 'home-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + + const { json } = await runScript( + 'branch-prep.js', + ['--repo', 'plugins/tea-sdlc', '--path', repo.dir, '--source', 'master', + '--type', 'feat', '--slug', 'mine', '--dry-run'], + { env: { TEA_SDLC_HOME: home } }, + ); + + process.env.TEA_SDLC_HOME = home; + const expected = worktreePath('plugins/tea-sdlc', 'feat/mine/main'); + delete process.env.TEA_SDLC_HOME; + assert.equal(json.data.worktree, expected); +});