/** * 手動清理工作樹。 * * 這一支的重點全在**守門**:清理會被 pr-watch 自動執行,而自動執行的東西只能做 * 可逆的事。工作樹裡還有沒提交的東西就中止,絕不 `--force`——刪掉的檔案救不回來。 * 移除只動工作樹,本機分支留著,使用者還能回頭看那段歷史。 * * 路徑由 `owner/repo/分支名` 推導,不查表也不讀狀態檔,所以這裡連帶驗它與 * branch-prep 算出來的是同一條。 * * git 不做替身:在臨時 git repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。 */ import test from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; 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'; /** * 備好一棵真的工作樹:路徑由 branch-prep 建,測試不自己算, * 兩支腳本推導出同一條路徑這件事本身就是要驗的東西。 */ async function withWorktree(t, slug = 'mine') { 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', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', slug], { env: { TEA_SDLC_HOME: home } }, ); assert.equal(json.ok, true, json.error?.message); return { repo, home, branch: json.data.branch, worktree: json.data.worktree }; } const run = (home, args) => runScript('worktree-remove.js', args, { env: { TEA_SDLC_HOME: home } }); // ── 乾淨時移除 ───────────────────────────────────────────────────── test('工作樹乾淨時移除它,本機分支留著', async (t) => { const { repo, home, branch, worktree } = await withWorktree(t); const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); assert.equal(code, 0, json.error?.message); assert.equal(json.data.removed, true); assert.equal(existsSync(worktree), false, '工作樹要真的不見了'); assert.equal( repo.git('branch', '--list', branch).trim().replace(/^\*?\s*/, ''), branch, '本機分支要留著:不佔什麼空間,而使用者還會回頭看那段歷史', ); }); test('回報的路徑就是 branch-prep 建的那一條', async (t) => { const { home, branch, worktree } = await withWorktree(t); const { json } = await run(home, ['--repo', REPO, '--branch', branch]); assert.equal(json.data.worktree, worktree, '兩支腳本要從同一個輸入推導出同一條路徑'); }); // ── 守門:有未提交變更就中止 ─────────────────────────────────────── test('工作樹裡有未提交的變更時中止,並報出路徑', async (t) => { const { home, branch, worktree } = await withWorktree(t); writeFileSync(join(worktree, 'README.md'), '改到一半的東西\n'); const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); assert.equal(code, 1); assert.equal(json.error.code, 'WORKTREE_DIRTY'); assert.match(json.error.message, new RegExp(worktree), '要指名是哪一條路徑,人才找得到它'); assert.match(json.error.message, /README\.md/, '也要說出是哪些檔案擋住了'); assert.equal(existsSync(worktree), true, '擋下來就不該已經刪掉'); assert.equal(existsSync(join(worktree, 'README.md')), true); }); test('未追蹤的檔案同樣算未提交:它一樣會被刪掉', async (t) => { const { home, branch, worktree } = await withWorktree(t); writeFileSync(join(worktree, 'notes.txt'), '還沒加進版控的筆記\n'); const { json } = await run(home, ['--repo', REPO, '--branch', branch]); assert.equal(json.error.code, 'WORKTREE_DIRTY'); assert.match(json.error.message, /notes\.txt/); }); // ── 冪等 ─────────────────────────────────────────────────────────── test('工作樹本來就不在時安靜地成功,重跑不會失敗', async (t) => { const { home, branch } = await withWorktree(t); await run(home, ['--repo', REPO, '--branch', branch]); const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); assert.equal(code, 0, json.error?.message); assert.equal(json.data.removed, false); assert.equal(json.data.已經不在, true, '要說清楚是本來就不在,不是這次刪的'); }); // ── --dry-run ───────────────────────────────────────────────────── test('--dry-run 印出將執行的 git 指令,且不移除任何東西', async (t) => { const { home, branch, worktree } = await withWorktree(t); const { code, json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']); assert.equal(code, 0); assert.equal(json.data.dryRun, true); assert.deepEqual(json.data.commands, [`git worktree remove ${worktree}`]); assert.equal( json.data.commands.some((c) => c.includes('--force')), false, '絕不 --force:自動執行的東西只能做可逆的事', ); assert.equal(existsSync(worktree), true, '試跑不該真的刪掉'); }); test('--dry-run 也照樣把守門的結果說出來', async (t) => { // 試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差 const { home, branch, worktree } = await withWorktree(t); writeFileSync(join(worktree, 'wip.txt'), '做到一半\n'); const { json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']); assert.equal(json.error.code, 'WORKTREE_DIRTY'); }); // ── flag ────────────────────────────────────────────────────────── test('缺 --branch 時指名缺的是哪一個', async (t) => { const { home } = await withWorktree(t); const { json } = await run(home, ['--repo', REPO]); assert.equal(json.error.code, 'MISSING_FLAG'); assert.match(json.error.message, /--branch/); }); test('--repo 格式不是 owner/name 時失敗', async (t) => { const { home } = await withWorktree(t); const { json } = await run(home, ['--repo', 'tea-sdlc', '--branch', 'feat/mine/main']); assert.equal(json.error.code, 'BAD_REPO'); }); test('推導出來的路徑上是別人的東西時不碰它', async (t) => { const { repo, home, branch, worktree } = await withWorktree(t); repo.git('worktree', 'remove', worktree); mkdirSync(worktree, { recursive: true }); writeFileSync(join(worktree, '別人的東西.txt'), 'x\n'); const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); assert.equal(code, 1); assert.equal(json.error.code, 'NOT_A_WORKTREE'); assert.equal(existsSync(join(worktree, '別人的東西.txt')), true); });