From 2562555058c884b917676751658064dda6f73757 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 17:26:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(worktree-ensure):=20=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=85=E7=9A=84=E5=B7=A5=E4=BD=9C=E6=A8=B9?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=9C=A8=E5=B0=B1=E9=87=8D=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 處理 PR 留言的人不必先手動 cd 到正確的目錄:路徑由 owner/repo/分支名 純函式推導, 問這一支就知道該在哪裡動手。 「不在就重建」是常態不是防禦性程式設計。進度完全不寫在本機——換一台機器或換一個 agent 接手時,工作樹本來就不存在,而重建的成本就是一次 git worktree add。 兩種情況明確中止而不是硬幹:分支在本機與遠端都不見時報 BRANCH_NOT_FOUND(憑空長一棵 空的工作樹只會讓人以為進度還在),推導出的路徑上是別的東西時報 WORKTREE_PATH_TAKEN。 議題 #42 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/worktree-ensure.js | 65 ++++++++++++ test/worktree-ensure.test.js | 196 +++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 scripts/worktree-ensure.js create mode 100644 test/worktree-ensure.test.js diff --git a/scripts/worktree-ensure.js b/scripts/worktree-ensure.js new file mode 100644 index 0000000..9679bc7 --- /dev/null +++ b/scripts/worktree-ensure.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * 定位一顆工作包的工作樹,不在就重建。 + * + * 處理 PR 留言的人不必先手動 `cd` 到正確的目錄:路徑由 `owner/repo/分支名` 純函式推導 + * (見 lib 的 `worktreePath`),問這一支就知道該在哪裡動手。 + * + * **「不在就重建」是常態,不是防禦性程式設計。** 進度完全不寫在本機——換一台機器或 + * 換一個 agent 接手時,工作樹本來就不存在,而重建的成本就是一次 `git worktree add`。 + * + * 重建與建立走同一套(lib 的 `planWorktree`/`createWorktree`):一樣先 `git fetch` + * 更新遠端引用,一樣不設 upstream,分支已經存在就接上去而不是長一棵空的。兩邊各寫 + * 一份,遲早會在「起點取自哪裡」這種地方分岔,而那種分岔要等到有人的進度不見了 + * 才會被發現。 + * + * 分支在本機與遠端都不存在時明確中止:憑空長一棵空的工作樹,只會讓人以為進度還在。 + * 推導出的路徑上是別的東西時也中止,不盲目拿來用。 + * + * 用法: + * node scripts/worktree-ensure.js --repo owner/name --branch <分支名> + * [--path <目標專案>] [--dry-run] + */ +import { + ScriptError, + createWorktree, + main, + openGitRepo, + parseFlags, + parseRepo, + planWorktree, + worktreePath, +} from './lib.js'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'branch'], + optional: ['path'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const branch = flags.branch; + const path = flags.path ?? process.cwd(); + const worktree = worktreePath(repo, branch); + + const git = openGitRepo(path); + if (!git('remote').split('\n').includes('origin')) { + throw new ScriptError( + 'NO_ORIGIN', + `${path} 沒有 origin 遠端;重建工作樹要先能讀到 origin/${branch},請先設定 origin`, + ); + } + + // 不給 source:這裡只重建既有分支的工作樹,沒有「從來源長一支新的」那條路, + // 那是 branch-prep 的事——在這裡憑空開一支新分支,等於把 PR 的進度扔掉 + const plan = planWorktree(git, { branch, worktree }); + const 報告 = { path, repo, branch, worktree, 動作: plan.動作 }; + + if (flags['dry-run']) { + return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) }; + } + + createWorktree(git, plan, { worktree, branch }); + + return { ...報告, 重建: plan.commands.length > 0 }; +}); diff --git a/test/worktree-ensure.test.js b/test/worktree-ensure.test.js new file mode 100644 index 0000000..df2594d --- /dev/null +++ b/test/worktree-ensure.test.js @@ -0,0 +1,196 @@ +/** + * 定位一顆工作包的工作樹,不在就重建。 + * + * 「不在就重建」不是防禦性程式設計,而是最常見的情境:進度完全不寫在本機,換一台機器 + * 或換一個 agent 接手時,工作樹本來就不存在。重建的成本就是一次 `git worktree add`。 + * + * 重建走的是與 branch-prep 同一套推導與同一套建立方式(`lib` 的 `worktreePath` 與 + * `planWorktree`),所以這裡連帶驗兩件事:算出來的是同一條路徑,而且分支上已經有的 + * 進度會被接上,不是從頭長一棵空的。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { makeTempRepoWithRemote } from './helpers/temp-repo.js'; + +const REPO = 'plugins/tea-sdlc'; +const SLUG = 'mine'; +const BRANCH = `feat/${SLUG}/main`; + +/** 一個有遠端的臨時 repo 與一個空的工作樹家 */ +function withRepo(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 })); + return { ...repo, home }; +} + +/** 照正規流程開一棵工作樹,回傳它的路徑——測試不自己算路徑 */ +async function prep(repo) { + const { json } = await runScript( + 'branch-prep.js', + ['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', SLUG], + { env: { TEA_SDLC_HOME: repo.home } }, + ); + assert.equal(json.ok, true, json.error?.message); + return json.data.worktree; +} + +const run = (repo, args = []) => + runScript('worktree-ensure.js', ['--repo', REPO, '--path', repo.dir, '--branch', BRANCH, ...args], { + env: { TEA_SDLC_HOME: repo.home }, + }); + +const branchIn = (dir) => + execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); + +// ── 已經在了就沿用 ───────────────────────────────────────────────── + +test('工作樹已經在了就沿用,不碰裡面還沒提交的東西', async (t) => { + const repo = withRepo(t); + const worktree = await prep(repo); + writeFileSync(join(worktree, 'wip.txt'), '做到一半\n'); + + const { code, json } = await run(repo); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.worktree, worktree, '推導出的要是 branch-prep 建的那一條'); + assert.equal(json.data.動作, '沿用既有工作樹'); + assert.equal(existsSync(join(worktree, 'wip.txt')), true); +}); + +// ── 不在就重建 ───────────────────────────────────────────────────── + +test('工作樹不在時重建它,分支上的進度跟著回來', async (t) => { + // 換一台機器接手就是這個情形:進度不寫在本機,工作樹本來就不存在 + const repo = withRepo(t); + const worktree = await prep(repo); + writeFileSync(join(worktree, 'done.txt'), '已經提交的進度\n'); + execFileSync('git', ['add', '-A'], { cwd: worktree }); + execFileSync('git', ['commit', '-qm', '分支上的進度'], { cwd: worktree }); + repo.git('worktree', 'remove', worktree); + + const { code, json } = await run(repo); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.worktree, worktree, '同一顆工作包算出來的永遠是同一條路徑'); + assert.equal(json.data.動作, '接上本地既有'); + assert.equal(branchIn(worktree), BRANCH); + assert.equal(existsSync(join(worktree, 'done.txt')), true, '接上既有分支,不是長一棵空的'); +}); + +test('本機連分支都沒有時,從遠端那一支重建', async (t) => { + // 真正的新機器:clone 完什麼都沒有,分支只在遠端上 + const repo = withRepo(t); + repo.pushFromElsewhere(BRANCH, 'theirs.txt', '推上去的進度\n'); + + const { code, json } = await run(repo); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.動作, '接上遠端既有'); + assert.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true); + assert.equal( + repo.git('for-each-ref', '--format=%(upstream)', `refs/heads/${BRANCH}`), + '', + '重建與建立走同一套:一樣不設 upstream', + ); +}); + +test('中繼資料還在但目錄被砍掉時照樣重建', async (t) => { + const repo = withRepo(t); + const worktree = await prep(repo); + rmSync(worktree, { recursive: true, force: true }); + + const { code, json } = await run(repo); + + assert.equal(code, 0, json.error?.message); + assert.equal(existsSync(worktree), true); + assert.equal(branchIn(worktree), BRANCH); +}); + +// ── 擋下來的情況 ─────────────────────────────────────────────────── + +test('分支在本機與遠端都不存在時明確報錯,不憑空長一棵', async (t) => { + const repo = withRepo(t); + + const { code, json } = await run(repo); + + assert.equal(code, 1); + assert.equal(json.error.code, 'BRANCH_NOT_FOUND'); + assert.match(json.error.message, new RegExp(BRANCH)); + assert.equal(existsSync(json.data?.worktree ?? '/nonexistent'), false); +}); + +test('推導出的路徑上是別的東西時明確報錯,不盲目拿來用', async (t) => { + const repo = withRepo(t); + const worktree = await prep(repo); + repo.git('worktree', 'remove', worktree); + mkdirSync(worktree, { recursive: true }); + writeFileSync(join(worktree, '別人的東西.txt'), 'x\n'); + + const { code, json } = await run(repo); + + assert.equal(code, 1); + assert.equal(json.error.code, 'WORKTREE_PATH_TAKEN'); + assert.equal(existsSync(join(worktree, '別人的東西.txt')), true, '不是我們建的東西就不碰'); +}); + +test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => { + const { json } = await runScript('worktree-ensure.js', [ + '--repo', REPO, '--path', tmpRoot, '--branch', BRANCH, + ]); + + assert.equal(json.error.code, 'NOT_A_GIT_REPO'); +}); + +test('缺 --branch 時指名缺的是哪一個', async (t) => { + const repo = withRepo(t); + + const { json } = await runScript('worktree-ensure.js', ['--repo', REPO, '--path', repo.dir], { + env: { TEA_SDLC_HOME: repo.home }, + }); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--branch/); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出路徑與將執行的 git 指令,且不建任何東西', async (t) => { + const repo = withRepo(t); + repo.pushFromElsewhere(BRANCH, 'theirs.txt', '推上去的進度\n'); + + const { code, json } = await run(repo, ['--dry-run']); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.match(json.data.worktree, /worktrees\/[0-9a-f]{12}$/); + assert.deepEqual(json.data.commands, [ + 'git fetch origin', + `git worktree add --no-track -b ${BRANCH} ${json.data.worktree} origin/${BRANCH}`, + ]); + assert.equal(existsSync(json.data.worktree), false, '試跑不該真的建'); +}); + +test('--dry-run 在工作樹已經在時說沒事要做', async (t) => { + const repo = withRepo(t); + await prep(repo); + + const { json } = await run(repo, ['--dry-run']); + + assert.equal(json.data.動作, '沿用既有工作樹'); + assert.deepEqual(json.data.commands, []); +}); + +test('--dry-run 也照樣把擋下來的情況說出來', async (t) => { + const repo = withRepo(t); + + const { json } = await run(repo, ['--dry-run']); + + assert.equal(json.error.code, 'BRANCH_NOT_FOUND'); +});