feat(branch-prep): 一律在獨立的工作樹上開工,不在原地切換分支

同一份 clone 上同時持有多顆工作包時,原地切分支有三種損耗,一種比一種難查:
未提交的變更擋路、建置產物跨分支混淆,以及 agent 讀到不屬於它那顆工作包的
程式碼——agent 是非同步的,它可能在分支已經被切走之後才去讀檔,而且不會察覺,
產出看起來完全合理,只是接錯了上下文。前兩種人會當場發現,第三種不會,
所以工作樹一律建立,不是「有衝突才用」。

建不起來就中止,不退回原地切分支:靜默降級會讓使用者以為自己在隔離環境裡,
其實在原地改。

分支與工作樹合併為一個原子動作(fetch 後一次 worktree add),並補上回滾——
git 在 worktree add 失敗時仍會把分支留下來,那是最難查的半成品:下一次重跑
會走到「目標分支已存在」那條路,起點從此不再是遠端的來源分支。

起點一律取自 origin/{來源分支},遠端沒有就中止,不退回本機同名分支;
本機分支可能落後好幾天,而這件事從輸出上完全看不出來。原「來源分支在遠端
已存在時 pull 而非重建」那條,用更強的方式達成同一個目的:根本不碰本機分支,
就沒有覆蓋他人進度的可能。

不設 upstream:此刻遠端還沒有這個新分支,--track 會把 upstream 指到來源分支,
之後 git pull 會把來源分支的提交拉進來。留給第一次 push -u 自然建立。

路徑由 owner/repo/分支名 正規化後取雜湊推導(lib 的 worktreePath),不查表、
不寫狀態檔,換機器算出來一樣。取雜湊而不是把斜線攤平成 -,是因為攤平會讓
feat/a-b/main 與 feat/a/b/main 撞成同一個目錄,而現行的分支命名規則恰好讓
這種形狀有機會出現。

議題 #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 16:27:43 +08:00
co-authored by Claude Opus 5
parent 03c368b6de
commit 5b740c236b
4 changed files with 647 additions and 208 deletions
+76
View File
@@ -12,6 +12,7 @@
* 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
@@ -51,6 +52,50 @@ export function promptsDir() {
return join(pluginRoot(), 'prompts');
}
/**
* tea-sdlc 在使用者家目錄底下的家。工作樹集中放在這裡,清理時只有一個地方要看。
* `TEA_SDLC_HOME` 只是測試與 CI 的覆寫出口,正常使用不必設。
*/
export function teaSdlcHome() {
return process.env.TEA_SDLC_HOME?.trim() || join(homedir(), '.tea-sdlc');
}
/** 所有工作樹的集中處 */
export function worktreesRoot() {
return join(teaSdlcHome(), 'worktrees');
}
/**
* 由「哪顆工作包」純函式推導出「它的工作樹在哪」。
*
* 不查表、不讀狀態檔:任何流程(開工、處理留言、清理)都要算得出同一條路徑,
* 換一台機器或換一個 agent 也一樣,不存在就重建。
*
* 目錄名取雜湊而不是把分支名的斜線攤平成 `-`:攤平會讓 `feat/a-b/main` 與
* `feat/a/b/main` 撞成同一個目錄,而本專案的分支命名規則恰好讓這種形狀有機會出現。
* 可讀性的缺口由 `git worktree list` 補上——它本來就會把分支名印在路徑旁邊。
*
* 推導前先正規化:沒有它,同一棵工作樹會因為輸入多一個空白或大小寫不同而被推導成兩條路徑。
*
* @param {string} repo owner/name
* @param {string} branch 分支名,可含斜線
* @returns {string} ~/.tea-sdlc/worktrees/{sha256 前 12 碼}
*/
export function worktreePath(repo, branch) {
const key = `${normalizeRef(repo)}/${normalizeRef(branch)}`;
const hash = createHash('sha256').update(key).digest('hex').slice(0, 12);
return join(worktreesRoot(), hash);
}
/** 逐段修掉空白再轉小寫:`Plugins / Tea-SDLC` 與 `plugins/tea-sdlc` 是同一個東西。 */
function normalizeRef(value) {
return value
.split('/')
.map((segment) => segment.trim())
.join('/')
.toLowerCase();
}
// ── 套件 manifest ─────────────────────────────────────────────────
/**
@@ -609,6 +654,37 @@ export async function countUnmergedComments(login, repo, index) {
return unmerged;
}
// ── 碼錶 ───────────────────────────────────────────────────────────
/**
* 目前跑在自己身上的碼錶。
*
* Gitea 只讓人讀自己的碼錶,看不到別人的——所以這份清單的語意永遠是「**我**的錶」,
* 它用來發現自己忘了停上一顆,不是用來判斷別人有沒有在做(那看 assignee)。
* @param {{base: string, token: string}} login
* @returns {Promise<object[]>}
*/
export async function listStopwatches(login) {
const path = '/user/stopwatches';
return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
}
/**
* 這些碼錶裡,跑在指定議題上的那一顆。
* 比對要連 repo 一起看:不同 repo 的同號議題是兩件事。
* @param {object[]} watches listStopwatches 的結果
* @param {string} repo owner/name
* @param {number} index
* @returns {object|null}
*/
export function stopwatchOnIssue(watches, repo, index) {
return (
watches.find(
(watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index,
) ?? null
);
}
// ── 標籤 ───────────────────────────────────────────────────────────
/**