Files
maestro/test/worktree.test.ts
T
wangjia 9bd02f2479 fix(executor): worktree 软链主仓 node_modules,修 verify exit 127 误判
git worktree 是干净检出不带 gitignored 的 node_modules,导致 verify
(tsc/tsx 等)与执行 agent 跑 npm 脚本时 command not found(exit 127)被误判为失败。
建 worktree 后软链主仓 node_modules:零网络、同平台 native 兼容、被 gitignore 忽略不污染 diff;
非 Node 项目(无 node_modules)自动跳过。removeWorktree 只移除软链不动主仓依赖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 09:01:47 +08:00

125 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createWorktree, worktreeDiff, removeWorktree, worktreeDirFor, branchFor } from '../src/executor/worktree.js';
function gitSync(cwd: string, args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8' });
}
/** /tmp 下建一个带 1 个 commit 的真实 git repomain 分支) */
function makeRepo(): string {
const repo = mkdtempSync(join(tmpdir(), 'maestro-wt-repo-'));
gitSync(repo, ['init', '-b', 'main']);
gitSync(repo, ['config', 'user.name', 'maestro-test']);
gitSync(repo, ['config', 'user.email', 'test@maestro.local']);
writeFileSync(join(repo, 'README.md'), '# demo\n');
gitSync(repo, ['add', '-A']);
gitSync(repo, ['commit', '-m', 'init']);
return repo;
}
test('worktreecreate / diff / remove 在真实 git repo 上工作', async (t) => {
// worktree 根目录隔离到临时 MAESTRO_DATA_DIR,不碰 ~/.maestro
const dataDir = mkdtempSync(join(tmpdir(), 'maestro-wt-data-'));
const prevDataDir = process.env.MAESTRO_DATA_DIR;
process.env.MAESTRO_DATA_DIR = dataDir;
const repo = makeRepo();
t.after(() => {
if (prevDataDir === undefined) delete process.env.MAESTRO_DATA_DIR;
else process.env.MAESTRO_DATA_DIR = prevDataDir;
rmSync(dataDir, { recursive: true, force: true });
rmSync(repo, { recursive: true, force: true });
});
const taskId = 'tsk_test123';
// 1) 创建:目录与分支符合约定
const wt = await createWorktree(repo, taskId, 'main');
assert.equal(wt.branch, branchFor(taskId));
assert.equal(wt.dir, worktreeDirFor(repo, taskId));
assert.ok(existsSync(join(wt.dir, 'README.md')), 'worktree 应检出 README.md');
const head = gitSync(wt.dir, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
assert.equal(head, wt.branch);
// 2) 空分支 diff:无 commit、无摘要
const empty = await worktreeDiff(repo, wt.dir, wt.branch, 'main');
assert.equal(empty.commits.length, 0);
assert.equal(empty.diffSummary, '');
// 3) 在 worktree 提交一笔改动 → diff 能看到 commit 与 --stat 摘要
writeFileSync(join(wt.dir, 'README.md'), '# demo\nhello maestro\n');
gitSync(wt.dir, ['add', '-A']);
gitSync(wt.dir, ['commit', '-m', `maestro(${taskId}): tweak readme`]);
const diff = await worktreeDiff(repo, wt.dir, wt.branch, 'main');
assert.equal(diff.commits.length, 1);
assert.ok(diff.commits[0].includes(taskId), `commit 行应含任务 id${diff.commits[0]}`);
assert.ok(diff.diffSummary.includes('README.md'), `diffSummary 应含文件名:${diff.diffSummary}`);
// 4) 已存在同名 worktree/分支时重建(重试场景):从 main 干净重来
const wt2 = await createWorktree(repo, taskId, 'main');
assert.equal(wt2.dir, wt.dir);
const diff2 = await worktreeDiff(repo, wt2.dir, wt2.branch, 'main');
assert.equal(diff2.commits.length, 0, '重建后分支应回到 baseBranch,无旧 commit');
// 5) remove:目录与注册项移除,主 repo 不受影响
await removeWorktree(repo, wt2.dir);
assert.ok(!existsSync(wt2.dir), 'worktree 目录应被移除');
const list = gitSync(repo, ['worktree', 'list']);
assert.ok(!list.includes(taskId), 'git worktree list 不应再含该任务');
assert.ok(existsSync(join(repo, 'README.md')), '主 repo 不受影响');
});
test('worktree:主仓有 node_modules 时软链进 worktree(让 .bin/tsc 等可解析),无则不报错', async (t) => {
const dataDir = mkdtempSync(join(tmpdir(), 'maestro-wt-data-nm-'));
const prevDataDir = process.env.MAESTRO_DATA_DIR;
process.env.MAESTRO_DATA_DIR = dataDir;
const repo = makeRepo();
t.after(() => {
if (prevDataDir === undefined) delete process.env.MAESTRO_DATA_DIR;
else process.env.MAESTRO_DATA_DIR = prevDataDir;
rmSync(dataDir, { recursive: true, force: true });
rmSync(repo, { recursive: true, force: true });
});
// 无 node_modules:建 worktree 不应报错,也不应凭空造出 node_modules
const wtBare = await createWorktree(repo, 'tsk_nm_none', 'main');
assert.ok(!existsSync(join(wtBare.dir, 'node_modules')), '主仓无 node_modules 时不应造软链');
// 主仓装一个 node_modules/.bin/tsc 占位 → 重建 worktree 应能解析到它
const binDir = join(repo, 'node_modules', '.bin');
mkdirSync(binDir, { recursive: true });
writeFileSync(join(binDir, 'tsc'), '#!/bin/sh\necho tsc\n');
const wt = await createWorktree(repo, 'tsk_nm', 'main');
const link = join(wt.dir, 'node_modules');
assert.ok(existsSync(link), 'worktree 应有 node_modules');
assert.ok(lstatSync(link).isSymbolicLink(), 'node_modules 应是软链而非真实拷贝');
assert.equal(readlinkSync(link), join(repo, 'node_modules'), '软链应指向主仓 node_modules');
assert.ok(existsSync(join(link, '.bin', 'tsc')), '经软链可解析到 .bin/tsc');
// remove 不应误删主仓 node_modules(软链被移除,目标保留)
await removeWorktree(repo, wt.dir);
assert.ok(existsSync(join(repo, 'node_modules', '.bin', 'tsc')), '主仓 node_modules 不受 remove 影响');
});
test('worktree:错误透传(不存在的 baseBranch', async (t) => {
const dataDir = mkdtempSync(join(tmpdir(), 'maestro-wt-data2-'));
const prevDataDir = process.env.MAESTRO_DATA_DIR;
process.env.MAESTRO_DATA_DIR = dataDir;
const repo = makeRepo();
t.after(() => {
if (prevDataDir === undefined) delete process.env.MAESTRO_DATA_DIR;
else process.env.MAESTRO_DATA_DIR = prevDataDir;
rmSync(dataDir, { recursive: true, force: true });
rmSync(repo, { recursive: true, force: true });
});
await assert.rejects(
() => createWorktree(repo, 'tsk_badbase', 'no-such-branch'),
/git worktree add/,
);
});