Files
tea-sdlc/test/worktree-remove.test.js
T
jiantw83andClaude Opus 5 617a3eef72 fix(pr-watch): 試跑的預告與實跑的守門對齊,並說出路徑被佔住這件事
review 抓到一處落差:試跑判「終止且工作樹乾淨」就預告 git worktree remove,
但實跑多判一件事——那條路徑上的東西是不是真的一棵工作樹。別的 clone 在同一條路徑上
留下目錄時(路徑由 owner/repo/分支名 推導,不含本機 clone 的位置),試跑會預告一行
實跑必然拒絕的指令,而回報裡完全看不出原因。

判斷收進 lib 的 inspectWorktree,由它直接給出 reason(missing/foreign/dirty/
removable):試跑與實跑、自動與手動四條路徑從此擋在同一個判斷上,worktree-remove 裡
那份重算的副本也跟著刪掉。回報多一個「是工作樹」欄位,手動出口的 NOT_A_WORKTREE
不再是使用者第一次聽到這件事。

順帶兩件同源的修正:
- 「是不是工作樹」改認 .git 為**檔案**。獨立 clone 的 .git 是目錄,先前會被當成工作樹,
  然後在 git worktree remove 那一步炸出一句原始錯誤。
- 兩支腳本的 --dry-run 請求預告收進 pr-threads 的 plannedRequests,並補上 pr-watch
  先前漏掉的那句說明(逐則 reaction 與逐個 review 的行內留言事前列不完)。預告與實際
  發出的請求分開寫,加一個端點就會有一邊忘了改。
- PR 讀不到 head 分支時給出 PULL_HEAD_MISSING,而不是拿空字串去推導一條路徑。

議題 #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:06:07 +08:00

179 lines
7.7 KiB
JavaScript

/**
* 手動清理工作樹。
*
* 這一支的重點全在**守門**:清理會被 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('路徑上是一個獨立的 clone 時也擋下:對它下 worktree remove 只會得到一句 git 的原始錯誤', async (t) => {
const { repo, home, branch, worktree } = await withWorktree(t);
repo.git('worktree', 'remove', worktree);
mkdirSync(join(worktree, '.git'), { recursive: true });
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, '.git')), true);
});
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);
});