feat/sdlc-fix-worktree/main #53

Merged
admin merged 4 commits from feat/sdlc-fix-worktree/main into master 2026-09-17 09:35:41 +00:00
7 changed files with 570 additions and 166 deletions
+53 -6
View File
@@ -1,5 +1,5 @@
name: sdlc-fix
description: 僅由 /sdlc-fix 指令叫用。讀取 PR 上的三類留言,逐條處理並回覆,最後輸出修正摘要。
description: 僅由 /sdlc-fix 指令叫用。定位工作包的工作樹,讀取 PR 上的三類留言,逐條處理並回覆,最後輸出修正摘要。
# sdlc-fix
@@ -11,7 +11,52 @@ reviewer 留完意見,跑這一段,意見被逐條處理並回覆,不漏
一個 PR 編號。
## 1. 讀留言
## 1. 看 PR 現況,並定位工作樹
先問一次現況,再決定要不要動手:
```
node scripts/pr-watch.js --repo <owner/name> --index <PR 編號> --dry-run
```
**這裡要帶 `--dry-run`**:`pr-watch` 在 PR 已終止時會順手清掉工作樹,而這一步只是要
知道現況——清不清理是使用者的決定,不該由「我想看一下留言」這個動作順便做掉。
**`terminal` 為 `true`(PR 已合併或已關閉)時就停下來,不要繼續處理留言。** 那顆工作包
已經結束,在一棵該被清掉的工作樹上改東西是白做工,而且那些改動不會進到任何 PR 裡。
把 `suggestedAction` 的意思講給使用者聽,讓他決定下一步:
| 值 | 意思 |
| --- | --- |
| `nothing-to-do` | 沒事了;工作樹不在或已經清掉 |
| `cleanup` | 工作樹還在,可以用 `worktree-remove` 清掉 |
| `blocked-dirty` | 那棵工作樹裡還有沒提交的東西,要他自己處理 |
PR 還開著就定位工作樹。輸入是 `pr-watch` 給的 `branch`——路徑由「哪顆工作包」推導,
不必也不該由使用者自己去記那串雜湊目錄名:
```
node scripts/worktree-ensure.js --repo <owner/name> --path <目標專案路徑> \
--branch <pr-watch 給的 branch> --dry-run
```
`--path` 是目標專案在本機的位置(工作包 `repos` 列的那一顆),不給就用當前目錄——
而當前目錄多半不是它,這正是這一步要解決的問題。
試跑會印出推導出的路徑與將執行的 git 指令;確認無誤後拿掉旗標再跑一次。
**推導出的路徑不存在時它會重建,那是常態不是例外**:進度完全不寫在本機,換一台機器
或換一個 agent 接手時工作樹本來就不在,重建的成本就是一次 `git worktree add`。
重建走的是與開工時同一套建立方式,分支上已經有的進度會被接上,不是長一棵空的。
兩種會被擋下來的情況照實說,不要繞過去:`BRANCH_NOT_FOUND` 是那一支分支在本機與遠端
都不見了(多半是 PR 已經合併而分支被刪,回頭確認 PR 狀態);`WORKTREE_PATH_TAKEN` 是
推導出的路徑上有別的東西,請使用者自己確認後移除——**不要自己刪**。
**後面每一步都在那棵工作樹裡做**,不要回到主工作區:它可能停在別的分支上,在那裡改
會把改動落到別顆工作包的分支去。
## 2. 讀留言
```
node scripts/pr-comments.js --repo <owner/name> --index <PR 編號>
@@ -31,7 +76,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按
偶爾會遇到 `可標記` 是 `false` 的總評(在 timeline 上對不到它在 issue comment 表裡的
那一份)。那一則回覆照發,但沒有記號留得下來,**要在修正摘要裡單獨點出來**。
## 2. 分類:必改還是建議
## 3. 分類:必改還是建議
逐則判斷,**reviewer 不必逐則說明**。判斷依據是內容本身:
@@ -42,7 +87,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按
分類錯的代價不對稱——把必改當成建議會漏掉真的問題,所以拿不準時歸到必改那一邊,
並在下一步問清楚。
## 3. 不確定就問
## 4. 不確定就問
**一次問一題。** 下列情況不要自作主張:
@@ -57,7 +102,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按
**不確定卻硬改,比多問一題貴得多。** 改壞的地方 reviewer 下一輪才會看到。
## 4. 逐則處理並回覆
## 5. 逐則處理並回覆
一則一則來:先改,改完立刻回覆那一則,再處理下一則。**不要全部改完才一起回**——
中途斷掉的話,沒有人知道哪幾則已經處理過。
@@ -81,7 +126,7 @@ node scripts/pr-reply.js --repo <owner/name> --index <PR 編號> \
不必自己去翻 diff。決定不改的也要回,並說明理由——建議類的留言常常合理地不採納,
但沉默會讓 reviewer 以為被忽略了。
## 5. 修正摘要
## 6. 修正摘要
全部處理完後印一則摘要,讓 reviewer 不必逐串點開:
@@ -102,3 +147,5 @@ node scripts/pr-reply.js --repo <owner/name> --index <PR 編號> \
- 不在回覆沒成功時標記已處理。
- **不自動張貼修正摘要**,也不自動關閉或合併 PR。
- 不動 PR 的 review 狀態:回覆一則意見不該順手把整個 PR 標成通過或要求變更。
- **不在主工作區處理留言**,一律在 `worktree-ensure` 定位出來的那棵工作樹裡。
- PR 已經合併或關閉時不繼續處理留言,也不自己去刪推導路徑上的東西。
+17 -153
View File
@@ -31,6 +31,9 @@
* 別的東西佔住時中止而不是硬蓋過去。兩者都在任何 git 寫入之前判斷完:試跑印得出
* 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。
*
* 算指令、跑指令與失敗回滾都在 lib 的 `planWorktree`/`createWorktree`——重建工作樹
* (`worktree-ensure`)走的是同一套,兩邊各寫一份遲早會在「起點取自哪裡」這種地方分岔。
*
* 工作樹路徑由 `owner/repo/分支名` 純函式推導(見 lib 的 worktreePath),
* 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。
*
@@ -38,9 +41,19 @@
* node scripts/branch-prep.js --repo <owner/name> --source <來源分支> --slug <英文-kebab>
* [--type feat] [--path <目標專案>] [--dry-run]
*/
import { existsSync, realpathSync, rmSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { ScriptError, main, openGitRepo, parseFlags, parseRepo, worktreePath } from './lib.js';
import {
ScriptError,
createWorktree,
main,
openGitRepo,
parseFlags,
parseRepo,
planWorktree,
requireOrigin,
worktreePath,
} from './lib.js';
/** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */
const SLUG_MAX = 40;
@@ -79,12 +92,7 @@ main(async () => {
const worktree = worktreePath(repo, branch);
const git = openGitRepo(path);
if (!git('remote').split('\n').includes('origin')) {
throw new ScriptError(
'NO_ORIGIN',
`${path} 沒有 origin 遠端;工作樹的起點一律取自 origin/{來源分支},請先設定 origin`,
);
}
requireOrigin(git, path, '工作樹的起點一律取自 origin/{來源分支}');
const plan = planWorktree(git, { source, branch, worktree });
const 報告 = { path, repo, source, branch, worktree, 分支: { 動作: plan.動作 } };
@@ -93,16 +101,7 @@ main(async () => {
return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) };
}
if (plan.commands.length > 0) {
// 既有的本地分支不是這次建的,回滾時不能連它一起刪掉
const 分支本來就在 = onLocal(git, branch);
try {
for (const args of plan.commands) git(...args);
} catch (error) {
rollback(git, { worktree, branch, 保留分支: 分支本來就在 });
throw error;
}
}
createWorktree(git, plan, { worktree, branch });
return { ...報告, 提示: { 訊息: CLEAN_WORKTREE, 安裝指令: installHints(worktree) } };
});
@@ -169,141 +168,6 @@ function isKebab(value) {
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(value);
}
/**
* 算出要把這棵工作樹弄到手需要哪幾個 git 指令。
*
* 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令,
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成,
* 所以試跑與實跑在同一個地方被擋下來。
*
* @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了
*/
function planWorktree(git, { source, branch, worktree }) {
const 既有 = listWorktrees(git).find((entry) => samePath(entry.path, worktree));
const 目錄還在 = existsSync(worktree);
if (既有 && 目錄還在) {
if (既有.branch !== branch) {
throw new ScriptError(
'WORKTREE_PATH_TAKEN',
`${worktree} 已經是 ${既有.branch} 的工作樹;請先 git worktree remove 它再重跑`,
);
}
// 冪等:中斷重跑時接上既有那一棵,不碰裡面還沒提交的東西
return { commands: [], 動作: '沿用既有工作樹' };
}
if (!既有 && 目錄還在) {
throw new ScriptError(
'WORKTREE_PATH_TAKEN',
`${worktree} 已經有東西了,但它不是這個 repo 的工作樹(可能是別的 clone 留下的);` +
'請確認裡面沒有還沒保存的東西之後移除它,再重跑',
);
}
// 起點一律取自遠端:本機同名分支可能落後好幾天,靜默拿它當起點的後果太隱蔽
if (!onRemote(git, source)) {
throw new ScriptError(
'SOURCE_NOT_FOUND',
`遠端沒有來源分支 ${source};請先把它推上去(git push origin ${source}),` +
'或改指定一個已經存在於遠端的來源分支',
);
}
// 目錄被刪掉但中繼資料還在時先清乾淨,否則 git 會說這條路徑已經註冊過
const commands = 既有 ? [['worktree', 'prune']] : [];
commands.push(['fetch', 'origin']);
if (onLocal(git, branch)) {
// 已經有的分支接上去,不從來源蓋掉:上面可能有做到一半的進度
commands.push(['worktree', 'add', worktree, branch]);
return { commands, 動作: '接上本地既有' };
}
if (onRemote(git, branch)) {
commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${branch}`]);
return { commands, 動作: '接上遠端既有' };
}
commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${source}`]);
return { commands, 動作: '從來源建立' };
}
/**
* 這個 repo 目前有哪幾棵工作樹。
* `--porcelain` 的輸出是以空行分隔的區塊,每塊第一行是 `worktree <路徑>`,
* 分支則是 `branch refs/heads/<名字>`;detached 的工作樹沒有 branch 那一行。
*/
function listWorktrees(git) {
return git('worktree', 'list', '--porcelain')
.split('\n\n')
.map((block) => {
const path = block.match(/^worktree (.+)$/m)?.[1];
const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1] ?? null;
return path ? { path, branch } : null;
})
.filter(Boolean);
}
/**
* 兩條路徑指的是不是同一個地方。
* git 印出來的是解析過符號連結的真實路徑,而推導出來的那一條可能經過連結
* (家目錄本身就常是一條連結),逐字比對會把同一棵工作樹判成兩棵。
*/
function samePath(a, b) {
return a === b || realOrSelf(a) === realOrSelf(b);
}
/** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */
function realOrSelf(path) {
try {
return realpathSync(path);
} catch {
return path;
}
}
/**
* 遠端有沒有這一支分支。
*
* 比對用全名 `refs/heads/<ref>`:`ls-remote --heads origin main` 的樣式比對吃的是
* ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對,
* 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。
*/
function onRemote(git, ref) {
return git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== '';
}
function onLocal(git, ref) {
return git('branch', '--list', ref).trim() !== '';
}
/**
* 建立失敗時把半成品清掉。
*
* `git worktree add` 失敗時仍會把新分支留下來,而那是最難查的半成品:下一次重跑會走到
* 「目標分支已存在」那條路,起點從此不再是遠端的來源分支。本來就存在的分支不能碰——
* 上面可能有別人的進度。
*/
function rollback(git, { worktree, branch, 保留分支 }) {
quietly(git, ['worktree', 'remove', '--force', worktree]);
quietly(git, ['worktree', 'prune']);
if (!保留分支) quietly(git, ['branch', '-D', branch]);
// git 清不乾淨時把目錄本身也清掉:這條路徑在這次執行之前不存在(不存在是建立的前提),
// 裡面不可能有使用者的東西;留著它下一次重跑會直接撞上 WORKTREE_PATH_TAKEN
try {
rmSync(worktree, { recursive: true, force: true });
} catch {
// 連目錄都刪不掉就只能留著:原本的錯誤比清理的錯誤重要
}
}
/** 清理用的 git:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */
function quietly(git, args) {
try {
git(...args);
} catch {
// 清不掉就算了:原本的錯誤比清理的錯誤重要
}
}
/**
* 這棵工作樹要怎麼把依賴裝起來。
* 偵測不到就回空陣列,不亂猜——猜錯的指令比沒有指令更浪費時間。
+194 -5
View File
@@ -1,19 +1,28 @@
/**
* tea-sdlc 所有腳本的共用地基。
*
* 這一層負責六件事,其餘腳本只寫自己的業務:
* 這一層負責七件事,其餘腳本只寫自己的業務:
* 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}})
* 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口
* 3. git 執行 —— 全專案唯一的子行程出口
* 4. 四層前置檢查
* 5. 冪等查重
* 6. 兩支抽取腳本共用的議題讀取
* 4. 工作樹的一生 —— 路徑推導、建立(含重建)、現況、移除
* 5. 四層前置檢查
* 6. 冪等查重
* 7. 兩支抽取腳本共用的議題讀取
*
* 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
import {
accessSync,
constants,
existsSync,
readFileSync,
realpathSync,
rmSync,
statSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -430,6 +439,18 @@ export function runGit(args, { cwd } = {}) {
}
}
/**
* 要求目標專案有 origin 遠端。
* 工作樹的起點一律取自遠端,沒有 origin 就什麼都做不了——兩支腳本擋的是同一件事,
* 只有「為什麼需要它」那一句不同,所以那一句由呼叫端給。
* @param {string} 用途 出現在訊息裡的理由,例如「工作樹的起點一律取自 origin/{來源分支}」
*/
export function requireOrigin(git, path, 用途) {
if (!git('remote').split('\n').includes('origin')) {
throw new ScriptError('NO_ORIGIN', `${path} 沒有 origin 遠端;${用途},請先設定 origin`);
}
}
/**
* 開一個目標專案的 git repo,回傳綁在它身上的執行器。
*
@@ -518,6 +539,174 @@ export function removeWorktree(worktree) {
return { ...state, removed: true, reason: 'removed' };
}
/**
* 算出要把這棵工作樹弄到手需要哪幾個 git 指令。
*
* 建立與**重建**共用這一份:換一台機器接手時工作樹本來就不存在,而重建若另寫一套,
* 兩邊遲早會在「起點取自哪裡」「要不要設 upstream」這種地方分岔。
*
* 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令,
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成,
* 所以試跑與實跑在同一個地方被擋下來。
*
* @param {(...args: string[]) => string} git 綁在目標專案上的 git 執行器
* @param {{source?: string, branch: string, worktree: string}} 目標
* 不給 `source` 就是「重建既有分支的工作樹」:分支必須已經存在,
* 沒有「從來源長一支新的」這條路。
* @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了
*/
export function planWorktree(git, { source, branch, worktree }) {
const 既有 = listWorktrees(git).find((entry) => samePath(entry.path, worktree));
const 目錄還在 = existsSync(worktree);
if (既有 && 目錄還在) {
if (既有.branch !== branch) {
throw new ScriptError(
'WORKTREE_PATH_TAKEN',
`${worktree} 已經是 ${既有.branch} 的工作樹;請先 git worktree remove 它再重跑`,
);
}
// 冪等:中斷重跑時接上既有那一棵,不碰裡面還沒提交的東西
return { commands: [], 動作: '沿用既有工作樹' };
}
if (!既有 && 目錄還在) {
throw new ScriptError(
'WORKTREE_PATH_TAKEN',
`${worktree} 已經有東西了,但它不是這個 repo 的工作樹(可能是別的 clone 留下的);` +
'請確認裡面沒有還沒保存的東西之後移除它,再重跑',
);
}
// 起點一律取自遠端:本機同名分支可能落後好幾天,靜默拿它當起點的後果太隱蔽
if (source !== undefined && !onRemote(git, source)) {
throw new ScriptError(
'SOURCE_NOT_FOUND',
`遠端沒有來源分支 ${source};請先把它推上去(git push origin ${source}),` +
'或改指定一個已經存在於遠端的來源分支',
);
}
// 目錄被刪掉但中繼資料還在時先清乾淨,否則 git 會說這條路徑已經註冊過
const commands = 既有 ? [['worktree', 'prune']] : [];
commands.push(['fetch', 'origin']);
if (onLocal(git, branch)) {
// 已經有的分支接上去,不從來源蓋掉:上面可能有做到一半的進度
commands.push(['worktree', 'add', worktree, branch]);
return { commands, 動作: '接上本地既有' };
}
if (onRemote(git, branch)) {
commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${branch}`]);
return { commands, 動作: '接上遠端既有' };
}
if (source === undefined) {
// 重建的路走到這裡代表那一支分支已經不見了——憑空長一棵空的只會讓人以為進度還在
throw new ScriptError(
'BRANCH_NOT_FOUND',
`分支 ${branch} 在本機與遠端都不存在,重建不出工作樹;` +
'請確認分支名,或先把它推上遠端',
);
}
commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${source}`]);
return { commands, 動作: '從來源建立' };
}
/**
* 照計畫把工作樹建起來,失敗時回到原狀。
* 與 `planWorktree` 成對:算歸算、做歸做,而試跑只跑前半段。
* @param {{commands: string[][]}} plan planWorktree 的結果
*/
export function createWorktree(git, plan, { worktree, branch }) {
if (plan.commands.length === 0) return;
// 既有的本地分支不是這次建的,回滾時不能連它一起刪掉
const 分支本來就在 = onLocal(git, branch);
try {
for (const args of plan.commands) git(...args);
} catch (error) {
rollback(git, { worktree, branch, 保留分支: 分支本來就在 });
throw error;
}
}
/**
* 這個 repo 目前有哪幾棵工作樹。
* `--porcelain` 的輸出是以空行分隔的區塊,每塊第一行是 `worktree <路徑>`,
* 分支則是 `branch refs/heads/<名字>`;detached 的工作樹沒有 branch 那一行。
*/
function listWorktrees(git) {
return git('worktree', 'list', '--porcelain')
.split('\n\n')
.map((block) => {
const path = block.match(/^worktree (.+)$/m)?.[1];
const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1] ?? null;
return path ? { path, branch } : null;
})
.filter(Boolean);
}
/**
* 兩條路徑指的是不是同一個地方。
* git 印出來的是解析過符號連結的真實路徑,而推導出來的那一條可能經過連結
* (家目錄本身就常是一條連結),逐字比對會把同一棵工作樹判成兩棵。
*/
function samePath(a, b) {
return a === b || realOrSelf(a) === realOrSelf(b);
}
/** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */
function realOrSelf(path) {
try {
return realpathSync(path);
} catch {
return path;
}
}
/**
* 遠端有沒有這一支分支。
*
* 比對用全名 `refs/heads/<ref>`:`ls-remote --heads origin main` 的樣式比對吃的是
* ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對,
* 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。
*/
function onRemote(git, ref) {
return git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== '';
}
function onLocal(git, ref) {
return git('branch', '--list', ref).trim() !== '';
}
/**
* 建立失敗時把半成品清掉。
*
* `git worktree add` 失敗時仍會把新分支留下來,而那是最難查的半成品:下一次重跑會走到
* 「目標分支已存在」那條路,起點從此不再是遠端的來源分支。本來就存在的分支不能碰——
* 上面可能有別人的進度。
*/
function rollback(git, { worktree, branch, 保留分支 }) {
quietly(git, ['worktree', 'remove', '--force', worktree]);
quietly(git, ['worktree', 'prune']);
if (!保留分支) quietly(git, ['branch', '-D', branch]);
// git 清不乾淨時把目錄本身也清掉:這條路徑在這次執行之前不存在(不存在是建立的前提),
// 裡面不可能有使用者的東西;留著它下一次重跑會直接撞上 WORKTREE_PATH_TAKEN
try {
rmSync(worktree, { recursive: true, force: true });
} catch {
// 連目錄都刪不掉就只能留著:原本的錯誤比清理的錯誤重要
}
}
/** 清理用的 git:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */
function quietly(git, args) {
try {
git(...args);
} catch {
// 清不掉就算了:原本的錯誤比清理的錯誤重要
}
}
// ── 四層前置檢查 ───────────────────────────────────────────────────
/**
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* 定位一顆工作包的工作樹,不在就重建。
*
* 處理 PR 留言的人不必先手動 `cd` 到正確的目錄:路徑由 `owner/repo/分支名` 純函式推導
* (見 lib 的 `worktreePath`),問這一支就知道該在哪裡動手。
*
* **「不在就重建」是常態,不是防禦性程式設計。** 進度完全不寫在本機——換一台機器或
* 換一個 agent 接手時,工作樹本來就不存在,而重建的成本就是一次 `git worktree add`。
*
* 重建與建立走同一套(lib 的 `planWorktree`/`createWorktree`):一樣先 `git fetch`
* 更新遠端引用,一樣不設 upstream,分支已經存在就接上去而不是長一棵空的。兩邊各寫
* 一份,遲早會在「起點取自哪裡」這種地方分岔,而那種分岔要等到有人的進度不見了
* 才會被發現。
*
* 分支在本機與遠端都不存在時明確中止:憑空長一棵空的工作樹,只會讓人以為進度還在。
* 推導出的路徑上是別的東西時也中止,不盲目拿來用。
*
* 用法:
* node scripts/worktree-ensure.js --repo owner/name --branch <分支名>
* [--path <目標專案>] [--dry-run]
*/
import {
createWorktree,
main,
openGitRepo,
parseFlags,
parseRepo,
planWorktree,
requireOrigin,
worktreePath,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'branch'],
optional: ['path'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const branch = flags.branch;
const path = flags.path ?? process.cwd();
const worktree = worktreePath(repo, branch);
const git = openGitRepo(path);
requireOrigin(git, path, `重建工作樹要先能讀到 origin/${branch}`);
// 不給 source:這裡只重建既有分支的工作樹,沒有「從來源長一支新的」那條路,
// 那是 branch-prep 的事——在這裡憑空開一支新分支,等於把 PR 的進度扔掉
const plan = planWorktree(git, { branch, worktree });
const 報告 = { path, repo, branch, worktree, 動作: plan.動作 };
if (flags['dry-run']) {
return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) };
}
createWorktree(git, plan, { worktree, branch });
return { ...報告, 重建: plan.commands.length > 0 };
});
+1
View File
@@ -275,6 +275,7 @@ test('工作樹建不起來時,不留下那一支已經建好的分支', async
false,
'也不該留下工作樹的中繼資料',
);
assert.equal(existsSync(join(blocked, 'worktrees')), false, '半途建出來的目錄也不該留下來');
});
// ── 目標分支已存在:接上去,不覆蓋 ─────────────────────────────────
+49 -2
View File
@@ -11,6 +11,53 @@ import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
const prompt = readPrompt('sdlc-fix');
const steps = prompt.slice(prompt.indexOf('## 1.'), prompt.indexOf('## 邊界'));
/** 定位那一步,與後面處理留言的步驟分開看 */
const locate = steps.slice(0, steps.indexOf('## 2.'));
test('第一步先看 PR 現況,終止狀態就停下來不白做工', () => {
assert.match(locate, /pr-watch\.js/);
assert.match(locate, /terminal/);
assert.match(locate, /不要繼續處理留言/);
assert.match(locate, /白做工/, '要說明為什麼停:那棵工作樹已經該被清掉了');
});
test('查現況帶 --dry-run,看一下留言不該順手清掉工作樹', () => {
const 指令 = locate.slice(locate.indexOf('pr-watch.js'));
assert.match(指令.slice(0, 120), /--dry-run/);
assert.match(locate, /清不清理是使用者的決定/);
});
test('三個建議動作的意思都交代了,不留一個讓 agent 自由發揮', () => {
for (const 值 of ['nothing-to-do', 'cleanup', 'blocked-dirty']) {
assert.match(locate, new RegExp(值));
}
});
test('工作樹由工作包推導,不要求使用者自己切目錄', () => {
assert.match(locate, /worktree-ensure\.js/);
assert.match(locate, /--path/, '目標專案不是當前目錄時要指得出來,否則會找錯 repo');
assert.match(locate, /branch/, '輸入是 pr-watch 給的分支名');
assert.match(locate, /不必也不該由使用者自己去記/);
assert.match(locate, /都在那棵工作樹裡做/);
});
test('不存在就重建,並說明那是常態不是例外', () => {
assert.match(locate, /常態不是例外/);
assert.match(locate, /換一台機器/, '要說明為什麼工作樹常常不在');
assert.match(locate, /同一套建立方式/, '重建不另寫一套');
});
test('兩種擋下來的情況各自寫明處置,且不替使用者決定', () => {
assert.match(locate, /BRANCH_NOT_FOUND/);
assert.match(locate, /WORKTREE_PATH_TAKEN/);
assert.match(locate, /不要自己刪/);
});
test('邊界擋住「回主工作區處理留言」與「PR 結束了還繼續改」', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不在主工作區處理留言/);
assert.match(boundary, /已經合併或關閉時不繼續處理留言/);
});
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-fix');
@@ -71,7 +118,7 @@ test('一次問一題,選項含手動輸入', () => {
});
test('該問的情況有列舉,不是一句「不確定就問」', () => {
const section = steps.slice(steps.indexOf('## 3.'), steps.indexOf('## 4.'));
const section = steps.slice(steps.indexOf('## 4.'), steps.indexOf('## 5.'));
const bullets = section.match(/^- /gm) ?? [];
assert.ok(bullets.length >= 3, `該問的情況要列得出來,只找到 ${bullets.length} 條`);
assert.match(section, /推了新 commit/, '位置對不上是最常見的一種,要點名');
@@ -112,7 +159,7 @@ test('寫入前要求先試跑', () => {
// ── 摘要 ───────────────────────────────────────────────────────────
test('摘要要逐則列出,並單獨點出沒有記號的那幾則', () => {
const section = steps.slice(steps.indexOf('## 5.'));
const section = steps.slice(steps.indexOf('## 6.'));
assert.match(section, /逐則一行/);
assert.match(section, /沒有留下記號的那幾則/);
assert.match(section, /以為它們被跳過/, '要說出為什麼得單獨列');
+196
View File
@@ -0,0 +1,196 @@
/**
* 定位一顆工作包的工作樹,不在就重建。
*
* 「不在就重建」不是防禦性程式設計,而是最常見的情境:進度完全不寫在本機,換一台機器
* 或換一個 agent 接手時,工作樹本來就不存在。重建的成本就是一次 `git worktree add`。
*
* 重建走的是與 branch-prep 同一套推導與同一套建立方式(`lib` 的 `worktreePath` 與
* `planWorktree`),所以這裡連帶驗兩件事:算出來的是同一條路徑,而且分支上已經有的
* 進度會被接上,不是從頭長一棵空的。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
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';
const SLUG = 'mine';
const BRANCH = `feat/${SLUG}/main`;
/** 一個有遠端的臨時 repo 與一個空的工作樹家 */
function withRepo(t) {
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 }));
return { ...repo, home };
}
/** 照正規流程開一棵工作樹,回傳它的路徑——測試不自己算路徑 */
async function prep(repo) {
const { json } = await runScript(
'branch-prep.js',
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', SLUG],
{ env: { TEA_SDLC_HOME: repo.home } },
);
assert.equal(json.ok, true, json.error?.message);
return json.data.worktree;
}
const run = (repo, args = []) =>
runScript('worktree-ensure.js', ['--repo', REPO, '--path', repo.dir, '--branch', BRANCH, ...args], {
env: { TEA_SDLC_HOME: repo.home },
});
const branchIn = (dir) =>
execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();
// ── 已經在了就沿用 ─────────────────────────────────────────────────
test('工作樹已經在了就沿用,不碰裡面還沒提交的東西', async (t) => {
const repo = withRepo(t);
const worktree = await prep(repo);
writeFileSync(join(worktree, 'wip.txt'), '做到一半\n');
const { code, json } = await run(repo);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.worktree, worktree, '推導出的要是 branch-prep 建的那一條');
assert.equal(json.data.動作, '沿用既有工作樹');
assert.equal(existsSync(join(worktree, 'wip.txt')), true);
});
// ── 不在就重建 ─────────────────────────────────────────────────────
test('工作樹不在時重建它,分支上的進度跟著回來', async (t) => {
// 換一台機器接手就是這個情形:進度不寫在本機,工作樹本來就不存在
const repo = withRepo(t);
const worktree = await prep(repo);
writeFileSync(join(worktree, 'done.txt'), '已經提交的進度\n');
execFileSync('git', ['add', '-A'], { cwd: worktree });
execFileSync('git', ['commit', '-qm', '分支上的進度'], { cwd: worktree });
repo.git('worktree', 'remove', worktree);
const { code, json } = await run(repo);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.worktree, worktree, '同一顆工作包算出來的永遠是同一條路徑');
assert.equal(json.data.動作, '接上本地既有');
assert.equal(branchIn(worktree), BRANCH);
assert.equal(existsSync(join(worktree, 'done.txt')), true, '接上既有分支,不是長一棵空的');
});
test('本機連分支都沒有時,從遠端那一支重建', async (t) => {
// 真正的新機器:clone 完什麼都沒有,分支只在遠端上
const repo = withRepo(t);
repo.pushFromElsewhere(BRANCH, 'theirs.txt', '推上去的進度\n');
const { code, json } = await run(repo);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.動作, '接上遠端既有');
assert.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true);
assert.equal(
repo.git('for-each-ref', '--format=%(upstream)', `refs/heads/${BRANCH}`),
'',
'重建與建立走同一套:一樣不設 upstream',
);
});
test('中繼資料還在但目錄被砍掉時照樣重建', async (t) => {
const repo = withRepo(t);
const worktree = await prep(repo);
rmSync(worktree, { recursive: true, force: true });
const { code, json } = await run(repo);
assert.equal(code, 0, json.error?.message);
assert.equal(existsSync(worktree), true);
assert.equal(branchIn(worktree), BRANCH);
});
// ── 擋下來的情況 ───────────────────────────────────────────────────
test('分支在本機與遠端都不存在時明確報錯,不憑空長一棵', async (t) => {
const repo = withRepo(t);
const { code, json } = await run(repo);
assert.equal(code, 1);
assert.equal(json.error.code, 'BRANCH_NOT_FOUND');
assert.match(json.error.message, new RegExp(BRANCH));
assert.equal(existsSync(json.data?.worktree ?? '/nonexistent'), false);
});
test('推導出的路徑上是別的東西時明確報錯,不盲目拿來用', async (t) => {
const repo = withRepo(t);
const worktree = await prep(repo);
repo.git('worktree', 'remove', worktree);
mkdirSync(worktree, { recursive: true });
writeFileSync(join(worktree, '別人的東西.txt'), 'x\n');
const { code, json } = await run(repo);
assert.equal(code, 1);
assert.equal(json.error.code, 'WORKTREE_PATH_TAKEN');
assert.equal(existsSync(join(worktree, '別人的東西.txt')), true, '不是我們建的東西就不碰');
});
test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => {
const { json } = await runScript('worktree-ensure.js', [
'--repo', REPO, '--path', tmpRoot, '--branch', BRANCH,
]);
assert.equal(json.error.code, 'NOT_A_GIT_REPO');
});
test('缺 --branch 時指名缺的是哪一個', async (t) => {
const repo = withRepo(t);
const { json } = await runScript('worktree-ensure.js', ['--repo', REPO, '--path', repo.dir], {
env: { TEA_SDLC_HOME: repo.home },
});
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--branch/);
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出路徑與將執行的 git 指令,且不建任何東西', async (t) => {
const repo = withRepo(t);
repo.pushFromElsewhere(BRANCH, 'theirs.txt', '推上去的進度\n');
const { code, json } = await run(repo, ['--dry-run']);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.match(json.data.worktree, /worktrees\/[0-9a-f]{12}$/);
assert.deepEqual(json.data.commands, [
'git fetch origin',
`git worktree add --no-track -b ${BRANCH} ${json.data.worktree} origin/${BRANCH}`,
]);
assert.equal(existsSync(json.data.worktree), false, '試跑不該真的建');
});
test('--dry-run 在工作樹已經在時說沒事要做', async (t) => {
const repo = withRepo(t);
await prep(repo);
const { json } = await run(repo, ['--dry-run']);
assert.equal(json.data.動作, '沿用既有工作樹');
assert.deepEqual(json.data.commands, []);
});
test('--dry-run 也照樣把擋下來的情況說出來', async (t) => {
const repo = withRepo(t);
const { json } = await run(repo, ['--dry-run']);
assert.equal(json.error.code, 'BRANCH_NOT_FOUND');
});