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>
This commit is contained in:
2026-09-17 17:06:07 +08:00
co-authored by Claude Opus 5
parent 32edd65962
commit 617a3eef72
7 changed files with 152 additions and 60 deletions
+35 -16
View File
@@ -13,7 +13,7 @@
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -447,22 +447,22 @@ export function openGitRepo(path) {
}
/**
* 看一棵工作樹現在是什麼狀況。
* 看一棵工作樹現在是什麼狀況,並直接說出「能不能清掉、不能的話卡在哪」。
*
* 清理前要先知道三件事:它還在不在、裡面有沒有沒提交的東西、以及那條路徑上的東西
* 到底是不是一棵工作樹(別的 clone 也可能在同一條路徑上留下目錄——路徑由
* owner/repo/分支名 推導,不含本機 clone 的位置)。
* 判斷寫在這裡而不是各呼叫端:試跑與實跑、自動與手動都要擋在同一個地方,
* 兩份判斷遲早會分岔成「試跑說清得掉、實跑卻拒絕」。
*
* 那條路徑上的東西不一定是工作樹:路徑由 owner/repo/分支名 推導,不含本機 clone 的
* 位置,所以別的 clone 也可能在同一條路徑上留下東西。
*
* @param {string} worktree 推導出的工作樹路徑
* @returns {{path: string, exists: boolean, worktree: boolean, dirty: boolean, files: string[]}}
* @returns {{path: string, exists: boolean, isWorktree: boolean, dirty: boolean,
* files: string[], reason: 'missing'|'foreign'|'dirty'|'removable'}}
*/
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 空的 = { path: worktree, exists: false, isWorktree: false, dirty: false, files: [] };
if (!existsSync(worktree)) return { ...空的, reason: 'missing' };
if (!linkedWorktree(worktree)) return { ...空的, exists: true, reason: 'foreign' };
const files = runGit(['status', '--porcelain'], { cwd: worktree })
.split('\n')
@@ -472,7 +472,27 @@ export function inspectWorktree(worktree) {
// 切太多會讓檔名少一個字(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 };
return {
path: worktree,
exists: true,
isWorktree: true,
dirty: files.length > 0,
files,
reason: files.length > 0 ? 'dirty' : 'removable',
};
}
/**
* 這條路徑是不是一棵「連結出去的」工作樹。
* 認的是 `.git` 為**檔案**(裡面一行 gitdir 指回主 repo)——獨立 clone 的 `.git` 是目錄,
* 對它下 `git worktree remove` 只會得到一句 git 的原始錯誤,而那不是使用者要的答案。
*/
function linkedWorktree(worktree) {
try {
return statSync(join(worktree, '.git')).isFile();
} catch {
return false;
}
}
/**
@@ -487,12 +507,11 @@ export function inspectWorktree(worktree) {
*
* @param {string} worktree 推導出的工作樹路徑
* @returns {{removed: boolean, reason: 'removed'|'missing'|'dirty'|'foreign', files: string[], path: string}}
* reason 由 inspectWorktree 給,兩支腳本與試跑、實跑都擋在同一個判斷上
*/
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' };
if (state.reason !== 'removable') return { ...state, removed: false };
// 在工作樹自己裡面執行:它的 .git 指得回主 repo,呼叫端因此不必知道主 clone 在哪
runGit(['worktree', 'remove', worktree], { cwd: worktree });