同一份 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>
132 lines
4.9 KiB
JavaScript
132 lines
4.9 KiB
JavaScript
/**
|
|
* 工作樹路徑的推導。
|
|
*
|
|
* 這是一個純函式,而且是整套工作樹機制的地基:任何流程都要能從「這是哪顆工作包」
|
|
* 算出「它的工作樹在哪」,不查表、不讀狀態檔,換一台機器算出來也要一樣。
|
|
* 算錯的代價是找不到既有的工作樹而重建一棵,或兩顆工作包撞進同一個目錄。
|
|
*
|
|
* 這一支直接 import lib,是本專案第二個這麼做的測試:大小寫與空白的變體沒辦法
|
|
* 穿過 CLI 的 kebab 驗證送進去,而表格驅動正是這種純規則最划算的驗法。
|
|
* 最後一則測試把它與 CLI 實際印出的路徑釘在一起,避免兩邊各自為政。
|
|
*/
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { worktreePath } from '../scripts/lib.js';
|
|
import { runScript, tmpRoot } from './helpers/run-script.js';
|
|
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
|
|
|
|
/** 推導只看輸入,不看環境;家目錄則由 TEA_SDLC_HOME 決定,測試固定一個值好比對 */
|
|
const HOME = '/home/tester/.tea-sdlc';
|
|
const derive = (repo, branch) => {
|
|
process.env.TEA_SDLC_HOME = HOME;
|
|
try {
|
|
return worktreePath(repo, branch);
|
|
} finally {
|
|
delete process.env.TEA_SDLC_HOME;
|
|
}
|
|
};
|
|
|
|
test('路徑長在固定的家底下,目錄名是 12 碼十六進位', () => {
|
|
const path = derive('plugins/tea-sdlc', 'feat/worktree-branch-prep/main');
|
|
|
|
assert.match(path, new RegExp(`^${HOME}/worktrees/[0-9a-f]{12}$`));
|
|
});
|
|
|
|
// ── 同一顆工作包只能推導出一條路徑 ─────────────────────────────────
|
|
|
|
const SAME = [
|
|
{
|
|
name: '大小寫不同',
|
|
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
|
b: ['Plugins/Tea-SDLC', 'Feat/Mine/Main'],
|
|
},
|
|
{
|
|
name: '前後有空白',
|
|
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
|
b: [' plugins/tea-sdlc ', ' feat/mine/main '],
|
|
},
|
|
{
|
|
name: '斜線兩側有空白',
|
|
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
|
b: ['plugins / tea-sdlc', 'feat / mine / main'],
|
|
},
|
|
];
|
|
|
|
for (const { name, a, b } of SAME) {
|
|
test(`同一顆工作包:${name}的變體推導出同一條路徑`, () => {
|
|
assert.equal(derive(...a), derive(...b), '正規化沒做,同一棵工作樹會被推導成兩個目錄');
|
|
});
|
|
}
|
|
|
|
// ── 不同的工作包不能撞在一起 ───────────────────────────────────────
|
|
|
|
const DIFFERENT = [
|
|
{
|
|
name: '分支名的斜線位置不同:feat/a-b/main 與 feat/a/b/main',
|
|
a: ['plugins/tea-sdlc', 'feat/a-b/main'],
|
|
b: ['plugins/tea-sdlc', 'feat/a/b/main'],
|
|
},
|
|
{
|
|
name: '同名分支在不同的 repo 上',
|
|
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
|
b: ['plugins/別的專案', 'feat/mine/main'],
|
|
},
|
|
{
|
|
name: '同名 repo 在不同的 owner 底下',
|
|
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
|
b: ['someone/tea-sdlc', 'feat/mine/main'],
|
|
},
|
|
{
|
|
name: '同一個 repo 的不同分支',
|
|
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
|
b: ['plugins/tea-sdlc', 'feat/yours/main'],
|
|
},
|
|
];
|
|
|
|
for (const { name, a, b } of DIFFERENT) {
|
|
test(`不同的工作包:${name},不得撞名`, () => {
|
|
assert.notEqual(derive(...a), derive(...b));
|
|
});
|
|
}
|
|
|
|
test('分支名含斜線時路徑仍是單層目錄,不會長出巢狀結構', () => {
|
|
const path = derive('plugins/tea-sdlc', 'feat/a/b/c/main');
|
|
|
|
assert.equal(path.startsWith(`${HOME}/worktrees/`), true);
|
|
assert.equal(path.slice(`${HOME}/worktrees/`.length).includes('/'), false);
|
|
});
|
|
|
|
test('換一台機器只有家目錄會變,目錄名不變', () => {
|
|
process.env.TEA_SDLC_HOME = '/somewhere/else/.tea-sdlc';
|
|
const elsewhere = worktreePath('plugins/tea-sdlc', 'feat/mine/main');
|
|
delete process.env.TEA_SDLC_HOME;
|
|
|
|
const here = derive('plugins/tea-sdlc', 'feat/mine/main');
|
|
|
|
assert.equal(elsewhere.split('/').at(-1), here.split('/').at(-1));
|
|
});
|
|
|
|
// ── 與 CLI 釘在一起 ───────────────────────────────────────────────
|
|
|
|
test('branch-prep 印出的工作樹路徑就是這個函式算出來的那一條', async (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 }));
|
|
|
|
const { json } = await runScript(
|
|
'branch-prep.js',
|
|
['--repo', 'plugins/tea-sdlc', '--path', repo.dir, '--source', 'master',
|
|
'--type', 'feat', '--slug', 'mine', '--dry-run'],
|
|
{ env: { TEA_SDLC_HOME: home } },
|
|
);
|
|
|
|
process.env.TEA_SDLC_HOME = home;
|
|
const expected = worktreePath('plugins/tea-sdlc', 'feat/mine/main');
|
|
delete process.env.TEA_SDLC_HOME;
|
|
assert.equal(json.data.worktree, expected);
|
|
});
|