fix/pr-head-after-merge/main #51

Merged
admin merged 1 commits from fix/pr-head-after-merge/main into master 2026-09-17 09:19:26 +00:00
2 changed files with 77 additions and 7 deletions
+25 -5
View File
@@ -64,13 +64,13 @@ main(async () => {
const state = stateOf(pull);
const 未處理留言數 = unhandledCount(await readPullComments(login, repo, index, me));
// 工作樹由 PR 自己的 head 分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑
const branch = pull.head?.ref;
if (!branch) {
// 工作樹由 PR 自己的來源分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑
const branch = headBranch(pull);
if (branch === '') {
throw new ScriptError(
'PULL_HEAD_MISSING',
`PR #${index} 讀不到 head 分支(來源分支可能已經被刪掉),推導不出工作樹在哪;` +
'請改用 worktree-remove --branch 指名要清哪一棵',
`PR #${index} 讀不到來源分支名,推導不出工作樹在哪;` +
'請改用 worktree-remove --repo <owner/name> --branch <分支名> 指名要清哪一棵',
);
}
const worktree = worktreePath(repo, branch);
@@ -119,6 +119,26 @@ main(async () => {
});
/**
* PR 的來源分支名。
*
* **不能只看 `head.ref`。** PR 合併而來源分支被刪掉之後,Gitea 會把 `head.ref` 換成
* `refs/pull/{編號}/head`,分支名退到 `head.label`——而合併正是唯一該清理工作樹的時機。
* 拿那個 ref 去推導會算出一條根本不存在的路徑,然後回報「工作樹不在、沒事要做」:
* 自動清理於是在真實情況下從來不會成立,而且靜悄悄的,沒有人會發現。
*
* fork 來的 PR 的 label 是 `owner:branch`,只取分支那一段——工作樹是以分支名推導的。
*/
function headBranch(pull) {
const label = (pull.head?.label ?? '').trim();
const 分支 = label.includes(':') ? label.slice(label.indexOf(':') + 1) : label;
if (分支 !== '') return 分支;
// label 缺席時才退回 ref,而且 refs/pull/… 不是分支名,寧可說讀不到
const ref = (pull.head?.ref ?? '').trim();
return /^refs\/pull\//.test(ref) ? '' : ref;
}
/**
* PR 的四種狀態。
* Gitea 的 `state` 只有 open/closed,合併與草稿各是另一個布林值——三個欄位湊成一種
+52 -2
View File
@@ -28,7 +28,7 @@ const BRANCH = `feat/${SLUG}/main`;
* 一顆 PR 的現場:狀態由 options 決定,留言預設沒有。
* 一般留言只有在沒有自己打的 `+1` 時才算未處理。
*/
function routes({ state = 'open', merged = false, draft = false, general = [] } = {}) {
function routes({ state = 'open', merged = false, draft = false, general = [], head } = {}) {
const base = healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: {
status: 200,
@@ -39,7 +39,9 @@ function routes({ state = 'open', merged = false, draft = false, general = [] }
state,
merged,
draft,
head: { ref: BRANCH },
// 預設是「PR 還開著」的樣子:head.ref 就是分支名。合併之後 Gitea 會換一種樣子,
// 那一種由測試自己指定。
head: head ?? { ref: BRANCH, label: BRANCH },
},
},
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
@@ -250,6 +252,54 @@ test('清不掉的路徑,--dry-run 不會預告一行實跑會拒絕的指令'
assert.equal(json.data.工作樹.是工作樹, false);
});
// ── 合併之後的 head ───────────────────────────────────────────────
test('來源分支在合併時被刪掉,仍要推導出正確的工作樹並清掉它', async (t) => {
// Gitea 在這種情況下把 head.ref 換成 refs/pull/{編號}/head,分支名退到 head.label。
// 拿 ref 去推導會算出一條不存在的路徑,然後靜靜回報「沒事要做」——而合併正是唯一
// 該動手清理的時機,等於自動清理在真實情況下從來不會成立。
const { run, worktree } = await withScene(t, {
state: 'closed',
merged: true,
head: { ref: `refs/pull/${INDEX}/head`, label: BRANCH },
});
const { code, json } = await run();
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.branch, BRANCH, '分支名要取自 head.label');
assert.equal(json.data.工作樹.路徑, worktree);
assert.equal(json.data.cleaned, true);
assert.equal(existsSync(worktree), false);
});
test('fork 來的 PR:head.label 是 owner:branch,只取分支那一段', async (t) => {
const { run, worktree } = await withScene(t, {
state: 'closed',
merged: true,
head: { ref: `refs/pull/${INDEX}/head`, label: `someone:${BRANCH}` },
});
const { json } = await run();
assert.equal(json.data.branch, BRANCH);
assert.equal(existsSync(worktree), false, '推導出來的仍是同一條路徑');
});
test('分支名兩邊都取不到時明確中止,不拿空字串去推導路徑', async (t) => {
const { run } = await withScene(t, {
state: 'closed',
merged: true,
head: { ref: `refs/pull/${INDEX}/head` },
});
const { code, json } = await run();
assert.equal(code, 1);
assert.equal(json.error.code, 'PULL_HEAD_MISSING');
assert.match(json.error.message, /worktree-remove/, '要指出手動出口怎麼指名那一棵');
});
// ── 回報內容 ───────────────────────────────────────────────────────
test('回報內容含 PR 狀態、未處理留言數與工作樹現況', async (t) => {