Merge pull request 'feat/sdlc-fix-worktree/main' (#53) from feat/sdlc-fix-worktree/main into master
Reviewed-on: #53 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #53.
This commit is contained in:
+53
-6
@@ -1,5 +1,5 @@
|
|||||||
name: sdlc-fix
|
name: sdlc-fix
|
||||||
description: 僅由 /sdlc-fix 指令叫用。讀取 PR 上的三類留言,逐條處理並回覆,最後輸出修正摘要。
|
description: 僅由 /sdlc-fix 指令叫用。定位工作包的工作樹,讀取 PR 上的三類留言,逐條處理並回覆,最後輸出修正摘要。
|
||||||
|
|
||||||
# sdlc-fix
|
# sdlc-fix
|
||||||
|
|
||||||
@@ -11,7 +11,52 @@ reviewer 留完意見,跑這一段,意見被逐條處理並回覆,不漏
|
|||||||
|
|
||||||
一個 PR 編號。
|
一個 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 編號>
|
node scripts/pr-comments.js --repo <owner/name> --index <PR 編號>
|
||||||
@@ -31,7 +76,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按
|
|||||||
偶爾會遇到 `可標記` 是 `false` 的總評(在 timeline 上對不到它在 issue comment 表裡的
|
偶爾會遇到 `可標記` 是 `false` 的總評(在 timeline 上對不到它在 issue comment 表裡的
|
||||||
那一份)。那一則回覆照發,但沒有記號留得下來,**要在修正摘要裡單獨點出來**。
|
那一份)。那一則回覆照發,但沒有記號留得下來,**要在修正摘要裡單獨點出來**。
|
||||||
|
|
||||||
## 2. 分類:必改還是建議
|
## 3. 分類:必改還是建議
|
||||||
|
|
||||||
逐則判斷,**reviewer 不必逐則說明**。判斷依據是內容本身:
|
逐則判斷,**reviewer 不必逐則說明**。判斷依據是內容本身:
|
||||||
|
|
||||||
@@ -42,7 +87,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按
|
|||||||
分類錯的代價不對稱——把必改當成建議會漏掉真的問題,所以拿不準時歸到必改那一邊,
|
分類錯的代價不對稱——把必改當成建議會漏掉真的問題,所以拿不準時歸到必改那一邊,
|
||||||
並在下一步問清楚。
|
並在下一步問清楚。
|
||||||
|
|
||||||
## 3. 不確定就問
|
## 4. 不確定就問
|
||||||
|
|
||||||
**一次問一題。** 下列情況不要自作主張:
|
**一次問一題。** 下列情況不要自作主張:
|
||||||
|
|
||||||
@@ -57,7 +102,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按
|
|||||||
|
|
||||||
**不確定卻硬改,比多問一題貴得多。** 改壞的地方 reviewer 下一輪才會看到。
|
**不確定卻硬改,比多問一題貴得多。** 改壞的地方 reviewer 下一輪才會看到。
|
||||||
|
|
||||||
## 4. 逐則處理並回覆
|
## 5. 逐則處理並回覆
|
||||||
|
|
||||||
一則一則來:先改,改完立刻回覆那一則,再處理下一則。**不要全部改完才一起回**——
|
一則一則來:先改,改完立刻回覆那一則,再處理下一則。**不要全部改完才一起回**——
|
||||||
中途斷掉的話,沒有人知道哪幾則已經處理過。
|
中途斷掉的話,沒有人知道哪幾則已經處理過。
|
||||||
@@ -81,7 +126,7 @@ node scripts/pr-reply.js --repo <owner/name> --index <PR 編號> \
|
|||||||
不必自己去翻 diff。決定不改的也要回,並說明理由——建議類的留言常常合理地不採納,
|
不必自己去翻 diff。決定不改的也要回,並說明理由——建議類的留言常常合理地不採納,
|
||||||
但沉默會讓 reviewer 以為被忽略了。
|
但沉默會讓 reviewer 以為被忽略了。
|
||||||
|
|
||||||
## 5. 修正摘要
|
## 6. 修正摘要
|
||||||
|
|
||||||
全部處理完後印一則摘要,讓 reviewer 不必逐串點開:
|
全部處理完後印一則摘要,讓 reviewer 不必逐串點開:
|
||||||
|
|
||||||
@@ -102,3 +147,5 @@ node scripts/pr-reply.js --repo <owner/name> --index <PR 編號> \
|
|||||||
- 不在回覆沒成功時標記已處理。
|
- 不在回覆沒成功時標記已處理。
|
||||||
- **不自動張貼修正摘要**,也不自動關閉或合併 PR。
|
- **不自動張貼修正摘要**,也不自動關閉或合併 PR。
|
||||||
- 不動 PR 的 review 狀態:回覆一則意見不該順手把整個 PR 標成通過或要求變更。
|
- 不動 PR 的 review 狀態:回覆一則意見不該順手把整個 PR 標成通過或要求變更。
|
||||||
|
- **不在主工作區處理留言**,一律在 `worktree-ensure` 定位出來的那棵工作樹裡。
|
||||||
|
- PR 已經合併或關閉時不繼續處理留言,也不自己去刪推導路徑上的東西。
|
||||||
|
|||||||
+17
-153
@@ -31,6 +31,9 @@
|
|||||||
* 別的東西佔住時中止而不是硬蓋過去。兩者都在任何 git 寫入之前判斷完:試跑印得出
|
* 別的東西佔住時中止而不是硬蓋過去。兩者都在任何 git 寫入之前判斷完:試跑印得出
|
||||||
* 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。
|
* 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。
|
||||||
*
|
*
|
||||||
|
* 算指令、跑指令與失敗回滾都在 lib 的 `planWorktree`/`createWorktree`——重建工作樹
|
||||||
|
* (`worktree-ensure`)走的是同一套,兩邊各寫一份遲早會在「起點取自哪裡」這種地方分岔。
|
||||||
|
*
|
||||||
* 工作樹路徑由 `owner/repo/分支名` 純函式推導(見 lib 的 worktreePath),
|
* 工作樹路徑由 `owner/repo/分支名` 純函式推導(見 lib 的 worktreePath),
|
||||||
* 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。
|
* 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。
|
||||||
*
|
*
|
||||||
@@ -38,9 +41,19 @@
|
|||||||
* node scripts/branch-prep.js --repo <owner/name> --source <來源分支> --slug <英文-kebab>
|
* node scripts/branch-prep.js --repo <owner/name> --source <來源分支> --slug <英文-kebab>
|
||||||
* [--type feat] [--path <目標專案>] [--dry-run]
|
* [--type feat] [--path <目標專案>] [--dry-run]
|
||||||
*/
|
*/
|
||||||
import { existsSync, realpathSync, rmSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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;
|
const SLUG_MAX = 40;
|
||||||
@@ -79,12 +92,7 @@ main(async () => {
|
|||||||
const worktree = worktreePath(repo, branch);
|
const worktree = worktreePath(repo, branch);
|
||||||
|
|
||||||
const git = openGitRepo(path);
|
const git = openGitRepo(path);
|
||||||
if (!git('remote').split('\n').includes('origin')) {
|
requireOrigin(git, path, '工作樹的起點一律取自 origin/{來源分支}');
|
||||||
throw new ScriptError(
|
|
||||||
'NO_ORIGIN',
|
|
||||||
`${path} 沒有 origin 遠端;工作樹的起點一律取自 origin/{來源分支},請先設定 origin`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = planWorktree(git, { source, branch, worktree });
|
const plan = planWorktree(git, { source, branch, worktree });
|
||||||
const 報告 = { path, repo, source, branch, worktree, 分支: { 動作: plan.動作 } };
|
const 報告 = { path, repo, source, branch, worktree, 分支: { 動作: plan.動作 } };
|
||||||
@@ -93,16 +101,7 @@ main(async () => {
|
|||||||
return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) };
|
return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plan.commands.length > 0) {
|
createWorktree(git, plan, { worktree, branch });
|
||||||
// 既有的本地分支不是這次建的,回滾時不能連它一起刪掉
|
|
||||||
const 分支本來就在 = onLocal(git, branch);
|
|
||||||
try {
|
|
||||||
for (const args of plan.commands) git(...args);
|
|
||||||
} catch (error) {
|
|
||||||
rollback(git, { worktree, branch, 保留分支: 分支本來就在 });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...報告, 提示: { 訊息: CLEAN_WORKTREE, 安裝指令: installHints(worktree) } };
|
return { ...報告, 提示: { 訊息: CLEAN_WORKTREE, 安裝指令: installHints(worktree) } };
|
||||||
});
|
});
|
||||||
@@ -169,141 +168,6 @@ function isKebab(value) {
|
|||||||
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(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
@@ -1,19 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* tea-sdlc 所有腳本的共用地基。
|
* tea-sdlc 所有腳本的共用地基。
|
||||||
*
|
*
|
||||||
* 這一層負責六件事,其餘腳本只寫自己的業務:
|
* 這一層負責七件事,其餘腳本只寫自己的業務:
|
||||||
* 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}})
|
* 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}})
|
||||||
* 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口
|
* 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口
|
||||||
* 3. git 執行 —— 全專案唯一的子行程出口
|
* 3. git 執行 —— 全專案唯一的子行程出口
|
||||||
* 4. 四層前置檢查
|
* 4. 工作樹的一生 —— 路徑推導、建立(含重建)、現況、移除
|
||||||
* 5. 冪等查重
|
* 5. 四層前置檢查
|
||||||
* 6. 兩支抽取腳本共用的議題讀取
|
* 6. 冪等查重
|
||||||
|
* 7. 兩支抽取腳本共用的議題讀取
|
||||||
*
|
*
|
||||||
* 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。
|
* 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。
|
||||||
*/
|
*/
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { createHash } from 'node:crypto';
|
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 { homedir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
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,回傳綁在它身上的執行器。
|
* 開一個目標專案的 git repo,回傳綁在它身上的執行器。
|
||||||
*
|
*
|
||||||
@@ -518,6 +539,174 @@ export function removeWorktree(worktree) {
|
|||||||
return { ...state, removed: true, reason: 'removed' };
|
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 {
|
||||||
|
// 清不掉就算了:原本的錯誤比清理的錯誤重要
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 四層前置檢查 ───────────────────────────────────────────────────
|
// ── 四層前置檢查 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -275,6 +275,7 @@ test('工作樹建不起來時,不留下那一支已經建好的分支', async
|
|||||||
false,
|
false,
|
||||||
'也不該留下工作樹的中繼資料',
|
'也不該留下工作樹的中繼資料',
|
||||||
);
|
);
|
||||||
|
assert.equal(existsSync(join(blocked, 'worktrees')), false, '半途建出來的目錄也不該留下來');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 目標分支已存在:接上去,不覆蓋 ─────────────────────────────────
|
// ── 目標分支已存在:接上去,不覆蓋 ─────────────────────────────────
|
||||||
|
|||||||
@@ -11,6 +11,53 @@ import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
|
|||||||
|
|
||||||
const prompt = readPrompt('sdlc-fix');
|
const prompt = readPrompt('sdlc-fix');
|
||||||
const steps = prompt.slice(prompt.indexOf('## 1.'), prompt.indexOf('## 邊界'));
|
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 前綴正確', () => {
|
test('正本平台中立,description 前綴正確', () => {
|
||||||
assertNeutralPrompt(prompt, 'sdlc-fix');
|
assertNeutralPrompt(prompt, 'sdlc-fix');
|
||||||
@@ -71,7 +118,7 @@ test('一次問一題,選項含手動輸入', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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) ?? [];
|
const bullets = section.match(/^- /gm) ?? [];
|
||||||
assert.ok(bullets.length >= 3, `該問的情況要列得出來,只找到 ${bullets.length} 條`);
|
assert.ok(bullets.length >= 3, `該問的情況要列得出來,只找到 ${bullets.length} 條`);
|
||||||
assert.match(section, /推了新 commit/, '位置對不上是最常見的一種,要點名');
|
assert.match(section, /推了新 commit/, '位置對不上是最常見的一種,要點名');
|
||||||
@@ -112,7 +159,7 @@ test('寫入前要求先試跑', () => {
|
|||||||
// ── 摘要 ───────────────────────────────────────────────────────────
|
// ── 摘要 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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, /沒有留下記號的那幾則/);
|
assert.match(section, /沒有留下記號的那幾則/);
|
||||||
assert.match(section, /以為它們被跳過/, '要說出為什麼得單獨列');
|
assert.match(section, /以為它們被跳過/, '要說出為什麼得單獨列');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user