feat/pr-watch-and-cleanup/main #48

Merged
admin merged 5 commits from feat/pr-watch-and-cleanup/main into master 2026-09-17 09:09:54 +00:00
3 changed files with 310 additions and 0 deletions
Showing only changes of commit 9e049580bb - Show all commits
+53
View File
@@ -446,6 +446,59 @@ export function openGitRepo(path) {
return (...args) => runGit(args, { cwd: path });
}
/**
* 看一棵工作樹現在是什麼狀況。
*
* 清理前要先知道三件事:它還在不在、裡面有沒有沒提交的東西、以及那條路徑上的東西
* 到底是不是一棵工作樹(別的 clone 也可能在同一條路徑上留下目錄——路徑由
* owner/repo/分支名 推導,不含本機 clone 的位置)。
*
* @param {string} worktree 推導出的工作樹路徑
* @returns {{path: string, exists: boolean, worktree: boolean, dirty: boolean, files: string[]}}
*/
export function inspectWorktree(worktree) {
const 空的 = { path: worktree, exists: false, worktree: false, dirty: false, files: [] };
if (!existsSync(worktree)) return 空的;
// 工作樹的 .git 是一個檔案(指回主 repo),不是目錄;沒有它就不是 git 認得的工作樹
if (!existsSync(join(worktree, '.git'))) {
return { ...空的, exists: true };
}
const files = runGit(['status', '--porcelain'], { cwd: worktree })
.split('\n')
.filter((line) => line !== '')
// 狀態欄是一到兩個字元,後面接空白才是檔名。不能固定切掉前三個字元——
// runGit 修掉了整段輸出的前後空白,第一行的「已修改」那個前導空白也跟著沒了,
// 切太多會讓檔名少一個字(README.md 變成 EADME.md),人照著去找會找不到。
.map((line) => line.replace(/^\s*\S{1,2}\s+/, ''));
return { path: worktree, exists: true, worktree: true, dirty: files.length > 0, files };
}
/**
* 移除一棵工作樹。自動清理與手動出口共用這一份實作,不互相開子行程。
*
* **絕不 `--force`。** 這件事會被 pr-watch 自動執行,而自動執行的東西只能做可逆的事:
* 工作樹重建得回來,被刪掉的未提交變更救不回來。所以有東西沒提交時就回報擋下的原因,
* 由呼叫端決定要報成錯誤(手動清理)還是一個待處理的建議(自動監看)。
*
* 只移除工作樹,**本機分支與遠端分支都保留**:本機分支不佔什麼空間,留著讓使用者
* 還能回頭看那段歷史;遠端分支要不要刪是 Gitea 合併時的選項,由使用者自己決定。
*
* @param {string} worktree 推導出的工作樹路徑
* @returns {{removed: boolean, reason: 'removed'|'missing'|'dirty'|'foreign', files: string[], path: string}}
*/
export function removeWorktree(worktree) {
const state = inspectWorktree(worktree);
if (!state.exists) return { ...state, removed: false, reason: 'missing' };
if (!state.worktree) return { ...state, removed: false, reason: 'foreign' };
if (state.dirty) return { ...state, removed: false, reason: 'dirty' };
// 在工作樹自己裡面執行:它的 .git 指得回主 repo,呼叫端因此不必知道主 clone 在哪
runGit(['worktree', 'remove', worktree], { cwd: worktree });
return { ...state, removed: true, reason: 'removed' };
}
// ── 四層前置檢查 ───────────────────────────────────────────────────
/**
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* 手動清掉一棵工作樹。
*
* pr-watch 會在 PR 合併或關閉時自動清理,這一支是給那些**永遠不會被合併也不會被關閉**
* 的 PR 用的出口——沒有它,那些工作樹只能靠使用者自己記得去刪。
*
* 兩條路共用 lib 的 `removeWorktree`,不互相開子行程:守門的規則只有一份,
* 自動的那條與手動的這條不該長出兩種行為。
*
* 只移除工作樹,本機分支與遠端分支都留著。工作樹裡還有沒提交的東西就中止並報出路徑,
* **絕不 `--force`**:工作樹重建得回來,被刪掉的未提交變更救不回來。
*
* 路徑由 `owner/repo/分支名` 推導,所以輸入是這兩個而不是一條路徑——要刪哪一棵由
* 「哪顆工作包」決定,使用者不必自己去記 12 碼的雜湊目錄名。
*
* 用法:
* node scripts/worktree-remove.js --repo owner/name --branch <分支名> [--dry-run]
*/
import {
ScriptError,
inspectWorktree,
main,
parseFlags,
parseRepo,
removeWorktree,
worktreePath,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'branch'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const branch = flags.branch;
const worktree = worktreePath(repo, branch);
// 試跑與實跑走同一條守門:試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差
if (flags['dry-run']) {
const state = inspectWorktree(worktree);
checkRemovable({ ...state, reason: reasonOf(state) }, worktree);
return {
dryRun: true,
repo,
branch,
worktree,
已經不在: !state.exists,
commands: state.exists ? [`git worktree remove ${worktree}`] : [],
};
}
const result = removeWorktree(worktree);
checkRemovable(result, worktree);
return {
repo,
branch,
worktree,
removed: result.removed,
// 本來就不在不算失敗:重跑這一支是常態,而結果一樣是「那棵樹不在了」
已經不在: result.reason === 'missing',
};
});
/** inspectWorktree 的結果換算成 removeWorktree 用的同一組原因,讓試跑與實跑擋在同一處 */
function reasonOf(state) {
if (!state.exists) return 'missing';
if (!state.worktree) return 'foreign';
return state.dirty ? 'dirty' : 'removed';
}
/** 擋下來的兩種情況各有各的下一步,錯誤碼要分得開 */
function checkRemovable(result, worktree) {
if (result.reason === 'dirty') {
throw new ScriptError(
'WORKTREE_DIRTY',
`工作樹 ${worktree} 裡還有沒提交的東西(${result.files.join('、')});` +
'請先提交、暫存(git stash)或確認可以丟掉再自己刪除——' +
'本工具不會加 --force,刪掉的未提交變更救不回來',
);
}
if (result.reason === 'foreign') {
throw new ScriptError(
'NOT_A_WORKTREE',
`${worktree} 上有東西,但它不是一棵 git 工作樹(可能是別的 clone 留下的);` +
'請自己確認裡面沒有還沒保存的東西之後移除它',
);
}
}
+166
View File
@@ -0,0 +1,166 @@
/**
* 手動清理工作樹。
*
* 這一支的重點全在**守門**:清理會被 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);
});