feat(worktree-ensure): 定位工作包的工作樹,不在就重建
處理 PR 留言的人不必先手動 cd 到正確的目錄:路徑由 owner/repo/分支名 純函式推導, 問這一支就知道該在哪裡動手。 「不在就重建」是常態不是防禦性程式設計。進度完全不寫在本機——換一台機器或換一個 agent 接手時,工作樹本來就不存在,而重建的成本就是一次 git worktree add。 兩種情況明確中止而不是硬幹:分支在本機與遠端都不見時報 BRANCH_NOT_FOUND(憑空長一棵 空的工作樹只會讓人以為進度還在),推導出的路徑上是別的東西時報 WORKTREE_PATH_TAKEN。 議題 #42 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
#!/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 {
|
||||
ScriptError,
|
||||
createWorktree,
|
||||
main,
|
||||
openGitRepo,
|
||||
parseFlags,
|
||||
parseRepo,
|
||||
planWorktree,
|
||||
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);
|
||||
if (!git('remote').split('\n').includes('origin')) {
|
||||
throw new ScriptError(
|
||||
'NO_ORIGIN',
|
||||
`${path} 沒有 origin 遠端;重建工作樹要先能讀到 origin/${branch},請先設定 origin`,
|
||||
);
|
||||
}
|
||||
|
||||
// 不給 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 };
|
||||
});
|
||||
@@ -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