feat/sdlc-fix-worktree/main #53

Merged
admin merged 4 commits from feat/sdlc-fix-worktree/main into master 2026-09-17 09:35:41 +00:00
2 changed files with 261 additions and 0 deletions
Showing only changes of commit 2562555058 - Show all commits
+65
View File
@@ -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 };
});
+196
View File
@@ -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');
});