#!/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 寫入之前判斷完:試跑印得出 * 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。 * * 工作樹路徑由 `owner/repo/分支名` 純函式推導(見 lib 的 worktreePath), * 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。 * * 用法: * node scripts/branch-prep.js --repo --source <來源分支> --slug <英文-kebab> * [--type feat] [--path <目標專案>] [--dry-run] */ import { existsSync, realpathSync } from 'node:fs'; import { join } from 'node:path'; import { ScriptError, main, openGitRepo, parseFlags, parseRepo, 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); if (!git('remote').split('\n').includes('origin')) { throw new ScriptError( 'NO_ORIGIN', `${path} 沒有 origin 遠端;工作樹的起點一律取自 origin/{來源分支},請先設定 origin`, ); } const plan = planWorktree(git, { repo, 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(' ')}`) }; } 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) } }; }); /** * 依來源分支的型態組出新分支名。 * 純字串規則,不碰 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); } /** * 算出要把這棵工作樹弄到手需要哪幾個 git 指令。 * * 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令, * 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成, * 所以試跑與實跑在同一個地方被擋下來。 * * @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了 */ 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 (!既有 && 目錄還在) { 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 || resolve(a) === resolve(b); } /** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */ function resolve(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:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */ function quietly(git, args) { try { git(...args); } catch { // 清不掉就算了:原本的錯誤比清理的錯誤重要 } } /** * 這棵工作樹要怎麼把依賴裝起來。 * 偵測不到就回空陣列,不亂猜——猜錯的指令比沒有指令更浪費時間。 */ function installHints(worktree) { return INSTALL_HINTS.filter(([file]) => existsSync(join(worktree, file))).map(([, command]) => command); }