refactor(branch-prep): 建立工作樹的「算」與「做」收進 lib
重建工作樹(下一個 commit 的 worktree-ensure)要走的是與開工時完全同一套:一樣先 git fetch 更新遠端引用,一樣不設 upstream,分支已經存在就接上去而不是長一棵空的。 兩邊各寫一份,遲早會在「起點取自哪裡」這種地方分岔——而那種分岔要等到有人的進度 不見了才會被發現。 planWorktree 多接受一種用法:不給來源分支就是「重建既有分支的工作樹」,沒有「從來源 長一支新的」那條路,走到那裡就是 BRANCH_NOT_FOUND。branch-prep 的行為完全不變。 議題 #42 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+169
-1
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
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, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -518,6 +518,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 {
|
||||
// 清不掉就算了:原本的錯誤比清理的錯誤重要
|
||||
}
|
||||
}
|
||||
|
||||
// ── 四層前置檢查 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user