review 抓到一個沉默的失效:rollback 最後那一手「git 清不乾淨時把目錄本身也清掉」呼叫 rmSync,但把這段從 branch-prep 搬進 lib 時,import 留在了原處。那一行落在自己的 try/catch 裡,所以 ReferenceError 被吞掉——註解承諾的事從來沒發生過,而且測試全綠。 下一次重跑會撞上 WORKTREE_PATH_TAKEN,人看到的是一句與真正原因無關的錯誤。 順手收掉兩支腳本各寫一次的 origin 檢查(只有「為什麼需要它」那一句不同,由呼叫端給), 並把 lib 檔頭「負責六件事」改成七件——工作樹的一生現在整個住在這裡。 正本三處跟著改: - 查現況那一行補上 --dry-run。pr-watch 在 PR 已終止時會順手清掉工作樹,而「我想看一下 留言」不該把清理順便做掉——清不清理是使用者的決定。 - 拿掉「使用者堅持要繼續就繼續」:它與同一份正本的邊界(已合併或關閉時不繼續處理留言) 直接矛盾,而在一棵該被清掉的工作樹上改東西,那些改動不會進到任何 PR 裡。 - worktree-ensure 的指令補上 --path:目標專案多半不是當前目錄,而「不必先手動 cd」 正是這一步要解決的問題。 議題 #42 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
505 lines
21 KiB
JavaScript
505 lines
21 KiB
JavaScript
/**
|
|
* 備妥開工的工作樹。
|
|
*
|
|
* 三件事各自要驗:
|
|
* 1. **分支命名**是純字串規則,表格驅動,成本最低、回歸價值最高。
|
|
* 規則錯了會一路帶到 PR 標題與 CI,事後改名很痛。
|
|
* 2. **一律在獨立的工作樹上開工**。主工作區一個字都不該被動到——包含它未提交的變更,
|
|
* 以及它現在停在哪一支分支上。
|
|
* 3. **原子性與不覆蓋他人進度**。分支與工作樹是同一個動作,失敗時不留半成品;
|
|
* 起點一律取自遠端,目標分支已存在時接上去而不是蓋掉。
|
|
*
|
|
* git 不做替身:在臨時 repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。
|
|
* 工作樹則以 TEA_SDLC_HOME 改指到測試暫存,不落到開發者真正的家目錄。
|
|
*/
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
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';
|
|
|
|
/** 開一個有遠端的臨時 repo 與一個空的工作樹家,並登記在測試結束時清掉 */
|
|
function withRepo(t) {
|
|
const repo = makeTempRepoWithRemote();
|
|
t.after(() => repo.cleanup());
|
|
mkdirSync(tmpRoot, { recursive: true });
|
|
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
|
t.after(() => {
|
|
// 工作樹裡有 .git 檔指回主 repo,直接刪目錄即可;主 repo 隨後也會被刪掉
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
return { ...repo, home };
|
|
}
|
|
|
|
const run = (repo, args) =>
|
|
runScript('branch-prep.js', ['--repo', REPO, '--path', repo.dir, ...args], {
|
|
env: { TEA_SDLC_HOME: repo.home },
|
|
});
|
|
|
|
/** 主工作區目前停在哪一支 */
|
|
const currentBranch = (repo) => repo.git('rev-parse', '--abbrev-ref', 'HEAD');
|
|
|
|
/** 工作樹裡 checkout 出來的是哪一支 */
|
|
const branchIn = (dir) =>
|
|
execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();
|
|
|
|
// ── 分支命名規則 ───────────────────────────────────────────────────
|
|
|
|
const NAMING = [
|
|
{
|
|
name: '從開發分支長出:{類型}/{需求描述}/main',
|
|
source: 'master',
|
|
args: ['--type', 'feat', '--slug', 'claim-and-branch-prep'],
|
|
expected: 'feat/claim-and-branch-prep/main',
|
|
},
|
|
{
|
|
name: '開發分支叫 main 時同樣成立',
|
|
source: 'main',
|
|
args: ['--type', 'fix', '--slug', 'crlf-parsing'],
|
|
expected: 'fix/crlf-parsing/main',
|
|
},
|
|
{
|
|
name: '開發分支叫 develop 時同樣成立',
|
|
source: 'develop',
|
|
args: ['--type', 'chore', '--slug', 'bump-node'],
|
|
expected: 'chore/bump-node/main',
|
|
},
|
|
{
|
|
name: '從功能分支長出:類型與需求描述沿用來源,第三段換成這次的描述',
|
|
source: 'feat/wp-extract-contract/main',
|
|
args: ['--slug', 'nested-todo-parser'],
|
|
expected: 'feat/wp-extract-contract/nested-todo-parser',
|
|
},
|
|
{
|
|
name: '從功能分支的子分支長出時,仍掛在同一個需求描述底下',
|
|
source: 'feat/wp-extract-contract/nested-todo-parser',
|
|
args: ['--slug', 'crlf-fix'],
|
|
expected: 'feat/wp-extract-contract/crlf-fix',
|
|
},
|
|
];
|
|
|
|
for (const { name, source, args, expected } of NAMING) {
|
|
test(`命名:${name}`, async (t) => {
|
|
const repo = withRepo(t);
|
|
// 來源分支一律取自遠端,所以先讓遠端有這一支
|
|
if (source !== 'master') repo.pushFromElsewhere(source, `${expected.replace(/\//g, '-')}.txt`, '來源\n');
|
|
|
|
const { code, json } = await run(repo, ['--source', source, ...args]);
|
|
|
|
assert.equal(code, 0, json.error?.message);
|
|
assert.equal(json.data.branch, expected);
|
|
assert.equal(branchIn(json.data.worktree), expected, '工作樹裡 checkout 出來的要是新分支');
|
|
});
|
|
}
|
|
|
|
// ── 命名規則的輸入驗證 ─────────────────────────────────────────────
|
|
|
|
test('需求描述含中文時擋下:CI 與 URL 會出問題', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', '領取工作包']);
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(json.error.code, 'BAD_SLUG');
|
|
assert.match(json.error.message, /英文/);
|
|
});
|
|
|
|
test('需求描述超過 40 字元時擋下', async (t) => {
|
|
const repo = withRepo(t);
|
|
const long = 'a'.repeat(41);
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', long]);
|
|
|
|
assert.equal(json.error.code, 'BAD_SLUG');
|
|
assert.match(json.error.message, /40/);
|
|
});
|
|
|
|
test('剛好 40 字元是可以的', async (t) => {
|
|
const repo = withRepo(t);
|
|
const exactly = 'a'.repeat(40);
|
|
|
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', exactly]);
|
|
|
|
assert.equal(code, 0, json.error?.message);
|
|
assert.equal(json.data.branch, `feat/${exactly}/main`);
|
|
});
|
|
|
|
test('大寫、底線、連續連字號都不是 kebab', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
for (const slug of ['Claim-Work', 'claim_work', 'claim--work', '-claim', 'claim-']) {
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', slug]);
|
|
assert.equal(json.error?.code, 'BAD_SLUG', `${slug} 應該被擋下`);
|
|
}
|
|
});
|
|
|
|
test('從開發分支長出卻沒給 --type 時,指名缺的是哪一個', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--slug', 'claim-work']);
|
|
|
|
assert.equal(json.error.code, 'MISSING_FLAG');
|
|
assert.match(json.error.message, /--type/);
|
|
});
|
|
|
|
test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵樹下', async (t) => {
|
|
const repo = withRepo(t);
|
|
repo.pushFromElsewhere('feat/wp-extract-contract/main', 'src.txt', '來源\n');
|
|
|
|
const { json } = await run(repo, [
|
|
'--source', 'feat/wp-extract-contract/main', '--type', 'fix', '--slug', 'crlf',
|
|
]);
|
|
|
|
assert.equal(json.error.code, 'TYPE_FROM_SOURCE');
|
|
assert.match(json.error.message, /feat/);
|
|
});
|
|
|
|
// ── 一律建立工作樹 ─────────────────────────────────────────────────
|
|
|
|
test('分支與工作樹一起建立,主工作區完全不被動到', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(code, 0, json.error?.message);
|
|
assert.equal(existsSync(json.data.worktree), true, '工作樹要真的在磁碟上');
|
|
assert.equal(branchIn(json.data.worktree), 'feat/mine/main');
|
|
assert.equal(currentBranch(repo), 'master', '主工作區不該被切走:agent 會在那裡讀到不屬於它的程式碼');
|
|
});
|
|
|
|
test('主工作區有未提交的變更時照樣開得了工,那正是工作樹要解決的事', async (t) => {
|
|
const repo = withRepo(t);
|
|
writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n');
|
|
writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n');
|
|
|
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(code, 0, json.error?.message);
|
|
assert.equal(
|
|
repo.git('status', '--porcelain').includes('stray.txt'),
|
|
true,
|
|
'未提交的變更要原封不動留在主工作區,不被帶到新分支上',
|
|
);
|
|
assert.equal(existsSync(join(json.data.worktree, 'stray.txt')), false, '也不該跟到工作樹裡');
|
|
});
|
|
|
|
test('工作樹路徑在集中的家底下,不長在目標專案裡也不長在它的兄弟目錄', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(json.data.worktree.startsWith(join(repo.home, 'worktrees')), true);
|
|
assert.equal(repo.git('status', '--porcelain'), '', '工作樹不該出現在目標專案的 git status 裡');
|
|
});
|
|
|
|
// ── 起點一律取自遠端 ───────────────────────────────────────────────
|
|
|
|
test('本機落後時,工作樹仍從遠端的最新狀態長出', async (t) => {
|
|
const repo = withRepo(t);
|
|
repo.pushFromElsewhere('master', 'theirs.txt', '別人的進度\n');
|
|
|
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(code, 0, json.error?.message);
|
|
assert.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true, '起點要是遠端的最新狀態');
|
|
assert.equal(existsSync(join(repo.dir, 'theirs.txt')), false, '主工作區不必被順便更新');
|
|
});
|
|
|
|
test('遠端沒有來源分支時明確中止,不退回本機同名分支', async (t) => {
|
|
const repo = withRepo(t);
|
|
repo.git('branch', 'feat/local-only/main');
|
|
|
|
const { code, json } = await run(repo, ['--source', 'feat/local-only/main', '--slug', 'sub-feature']);
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(json.error.code, 'SOURCE_NOT_FOUND');
|
|
assert.match(json.error.message, /推/, '要指出下一步是把來源分支推上去');
|
|
assert.match(json.error.message, /來源/, '或改指定一個已存在的來源分支');
|
|
assert.equal(
|
|
repo.git('branch', '--list', 'feat/local-only/sub-feature'),
|
|
'',
|
|
'擋下來就不該已經建好分支',
|
|
);
|
|
});
|
|
|
|
test('遠端分支的比對是全名,不是尾段', async (t) => {
|
|
// git ls-remote --heads origin main 會匹配到 refs/heads/feat/x/main ——
|
|
// 而本 repo 的命名慣例讓每一支分支都以 /main 結尾,拿 main 當開發分支的專案
|
|
// 會整個誤判成「遠端已經有這一支」。
|
|
const repo = withRepo(t);
|
|
repo.pushFromElsewhere('feat/x/main', 'theirs.txt', '別人的分支\n');
|
|
|
|
const { code, json } = await run(repo, ['--source', 'main', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(json.error.code, 'SOURCE_NOT_FOUND', '遠端沒有 main 這一支,不該被 feat/x/main 冒名頂替');
|
|
});
|
|
|
|
test('不為新分支設定 upstream:此刻遠端還沒有這一支,設了會誤指到來源分支', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(
|
|
repo.git('for-each-ref', '--format=%(upstream)', 'refs/heads/feat/mine/main'),
|
|
'',
|
|
'upstream 指到來源分支的話,之後 git pull 會把來源分支的提交拉進來',
|
|
);
|
|
assert.equal(json.data.branch, 'feat/mine/main');
|
|
});
|
|
|
|
// ── 原子性:失敗不留半成品 ─────────────────────────────────────────
|
|
|
|
test('工作樹建不起來時,不留下那一支已經建好的分支', async (t) => {
|
|
// git worktree add 失敗時仍會把分支留下來,那是最難查的半成品:
|
|
// 下一次重跑會走到「目標分支已存在」那條路,起點從此不是遠端的來源分支
|
|
const repo = withRepo(t);
|
|
const blocked = join(repo.home, 'blocker');
|
|
writeFileSync(blocked, '這是一個檔案,不是目錄\n');
|
|
|
|
const { code, json } = await runScript(
|
|
'branch-prep.js',
|
|
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', 'mine'],
|
|
{ env: { TEA_SDLC_HOME: blocked } },
|
|
);
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(json.error.code, 'GIT_FAILED');
|
|
assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '分支不該留下來');
|
|
assert.equal(
|
|
repo.git('worktree', 'list', '--porcelain').includes('feat/mine/main'),
|
|
false,
|
|
'也不該留下工作樹的中繼資料',
|
|
);
|
|
assert.equal(existsSync(join(blocked, 'worktrees')), false, '半途建出來的目錄也不該留下來');
|
|
});
|
|
|
|
// ── 目標分支已存在:接上去,不覆蓋 ─────────────────────────────────
|
|
|
|
test('目標分支已在遠端時接上去,保留上面已有的進度', async (t) => {
|
|
const repo = withRepo(t);
|
|
repo.pushFromElsewhere('feat/mine/main', 'progress.txt', '已經做了一半\n');
|
|
|
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(code, 0, json.error?.message);
|
|
assert.equal(json.data.分支.動作, '接上遠端既有');
|
|
assert.equal(
|
|
existsSync(join(json.data.worktree, 'progress.txt')),
|
|
true,
|
|
'遠端已有的分支要接上去,不是從來源重建一個空的蓋掉',
|
|
);
|
|
});
|
|
|
|
test('目標分支只在本地時接上去,不重建', async (t) => {
|
|
const repo = withRepo(t);
|
|
repo.git('branch', 'feat/mine/main');
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(json.data.分支.動作, '接上本地既有');
|
|
assert.equal(branchIn(json.data.worktree), 'feat/mine/main');
|
|
});
|
|
|
|
test('目標分支不存在時從遠端的來源分支建立', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'brand-new']);
|
|
|
|
assert.equal(json.data.分支.動作, '從來源建立');
|
|
});
|
|
|
|
// ── 冪等:工作樹已經在了 ───────────────────────────────────────────
|
|
|
|
test('工作樹已經在了就沿用,不動裡面還沒提交的東西', async (t) => {
|
|
const repo = withRepo(t);
|
|
const first = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
writeFileSync(join(first.json.data.worktree, 'wip.txt'), '做到一半\n');
|
|
|
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(code, 0, json.error?.message);
|
|
assert.equal(json.data.worktree, first.json.data.worktree, '推導出來的是同一條路徑');
|
|
assert.equal(json.data.分支.動作, '沿用既有工作樹');
|
|
assert.equal(existsSync(join(json.data.worktree, 'wip.txt')), true, '重跑不該把做到一半的東西刷掉');
|
|
});
|
|
|
|
test('推導出來的路徑被別的東西佔住時明確中止,不硬蓋過去', async (t) => {
|
|
const repo = withRepo(t);
|
|
const { json: first } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
// 造出「別的 clone 留下的目錄」:本 repo 的 git 已經不認得它了,但路徑還在
|
|
repo.git('worktree', 'remove', '--force', first.data.worktree);
|
|
mkdirSync(first.data.worktree, { recursive: true });
|
|
writeFileSync(join(first.data.worktree, '別人的東西.txt'), 'x\n');
|
|
|
|
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(json.error.code, 'WORKTREE_PATH_TAKEN');
|
|
assert.match(json.error.message, new RegExp(first.data.worktree), '要指名是哪一條路徑被佔住');
|
|
assert.equal(existsSync(join(first.data.worktree, '別人的東西.txt')), true, '不得動到裡面的東西');
|
|
});
|
|
|
|
// ── 乾淨的工作樹 ───────────────────────────────────────────────────
|
|
|
|
test('建好後說明這是一棵乾淨的工作樹,並依專案檔給出安裝指令', async (t) => {
|
|
const repo = withRepo(t);
|
|
repo.pushFromElsewhere('master', 'package.json', '{"name":"demo"}\n');
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.deepEqual(json.data.提示.安裝指令, ['npm install']);
|
|
assert.match(json.data.提示.訊息, /乾淨/);
|
|
});
|
|
|
|
test('偵測不到專案檔時不亂猜指令,只說明它是乾淨的', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.deepEqual(json.data.提示.安裝指令, []);
|
|
assert.match(json.data.提示.訊息, /乾淨/);
|
|
});
|
|
|
|
test('多種語言的專案檔都偵測得到,各給各的指令', async (t) => {
|
|
const repo = withRepo(t);
|
|
repo.pushFromElsewhere('master', 'composer.json', '{}\n');
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.deepEqual(json.data.提示.安裝指令, ['composer install']);
|
|
});
|
|
|
|
test('不複製也不連結依賴、建置產物與機密檔案', async (t) => {
|
|
const repo = withRepo(t);
|
|
mkdirSync(join(repo.dir, 'node_modules', 'left-pad'), { recursive: true });
|
|
writeFileSync(join(repo.dir, 'node_modules', 'left-pad', 'index.js'), '\n');
|
|
writeFileSync(join(repo.dir, '.env'), 'TOKEN=秘密\n');
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(existsSync(join(json.data.worktree, 'node_modules')), false, 'symlink 會把隔離接回去');
|
|
assert.equal(existsSync(join(json.data.worktree, '.env')), false, '機密一律由使用者自己放');
|
|
});
|
|
|
|
// ── 不留本機狀態檔 ─────────────────────────────────────────────────
|
|
|
|
test('跑完不在目標專案裡留下任何狀態檔', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'no-state']);
|
|
|
|
assert.equal(repo.git('status', '--porcelain'), '', '進度只從 Gitea 與 git 本身推導,不寫本機狀態檔');
|
|
});
|
|
|
|
test('git 自己的進度訊息不漏到 stderr', async (t) => {
|
|
// fetch 與 worktree add 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、
|
|
// stderr 乾淨」,漏出去的話呼叫端就得去分辨哪幾行是雜訊。
|
|
const repo = withRepo(t);
|
|
|
|
const { stderr } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'quiet']);
|
|
|
|
assert.equal(stderr, '');
|
|
});
|
|
|
|
// ── 路徑與 flag ───────────────────────────────────────────────────
|
|
|
|
test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => {
|
|
const { json } = await runScript('branch-prep.js', [
|
|
'--repo', REPO, '--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever',
|
|
]);
|
|
|
|
assert.equal(json.error.code, 'NOT_A_GIT_REPO');
|
|
});
|
|
|
|
test('缺 --repo 時擋下:沒有它推導不出工作樹在哪', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
const { json } = await runScript('branch-prep.js', [
|
|
'--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', 'mine',
|
|
], { env: { TEA_SDLC_HOME: repo.home } });
|
|
|
|
assert.equal(json.error.code, 'MISSING_FLAG');
|
|
assert.match(json.error.message, /--repo/);
|
|
});
|
|
|
|
test('目標專案沒有 origin 時擋下:起點一律取自遠端', async (t) => {
|
|
const repo = withRepo(t);
|
|
repo.git('remote', 'remove', 'origin');
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(json.error.code, 'NO_ORIGIN');
|
|
});
|
|
|
|
test('回報實際動到的是哪一個目錄,讓人確認沒搞錯專案', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
|
|
|
assert.equal(json.data.path, repo.dir);
|
|
assert.equal(json.data.repo, REPO);
|
|
});
|
|
|
|
// ── --dry-run ─────────────────────────────────────────────────────
|
|
|
|
test('--dry-run 印出將執行的 git 指令,且完全不動 repo', async (t) => {
|
|
const repo = withRepo(t);
|
|
const before = repo.git('rev-parse', 'HEAD');
|
|
|
|
const { code, json } = await run(repo, [
|
|
'--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run',
|
|
]);
|
|
|
|
assert.equal(code, 0);
|
|
assert.equal(json.data.dryRun, true);
|
|
assert.equal(json.data.branch, 'feat/mine/main');
|
|
assert.ok(
|
|
json.data.commands.every((c) => c.startsWith('git ')),
|
|
'預覽的是 git 指令本身,不是自創的描述',
|
|
);
|
|
assert.equal(repo.git('rev-parse', 'HEAD'), before);
|
|
assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '試跑不該建分支');
|
|
assert.equal(existsSync(json.data.worktree), false, '試跑不該建工作樹');
|
|
});
|
|
|
|
test('--dry-run 把 fetch 與建立工作樹兩步都印出來,順序不顛倒', async (t) => {
|
|
const repo = withRepo(t);
|
|
|
|
const { json } = await run(repo, [
|
|
'--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run',
|
|
]);
|
|
|
|
assert.deepEqual(json.data.commands, [
|
|
'git fetch origin',
|
|
`git worktree add --no-track -b feat/mine/main ${json.data.worktree} origin/master`,
|
|
], '建分支與建工作樹是同一個指令,不拆成兩步');
|
|
});
|
|
|
|
test('--dry-run 連工作樹路徑都先算出來,看得到才叫預覽', async (t) => {
|
|
const repo = withRepo(t);
|
|
repo.pushFromElsewhere('feat/wp-extract-contract/main', 'src.txt', '來源\n');
|
|
|
|
const { json } = await run(repo, [
|
|
'--source', 'feat/wp-extract-contract/main', '--slug', 'crlf-fix', '--dry-run',
|
|
]);
|
|
|
|
assert.equal(json.data.branch, 'feat/wp-extract-contract/crlf-fix');
|
|
assert.match(json.data.worktree, /worktrees\/[0-9a-f]{12}$/);
|
|
});
|
|
|
|
test('遠端沒有來源分支時,--dry-run 也要照樣說出來', async (t) => {
|
|
// 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差
|
|
const repo = withRepo(t);
|
|
|
|
const { json } = await run(repo, [
|
|
'--source', 'feat/沒有這支/main', '--slug', 'mine', '--dry-run',
|
|
]);
|
|
|
|
assert.equal(json.error.code, 'SOURCE_NOT_FOUND');
|
|
});
|