Files
maestro/test/worktree.test.ts
T
wangjia fcca3f95e0 fix(executor): node_modules 软链写入仓库本地 exclude,杜绝被提交进历史
上一版软链修复有回归:.gitignore 的 `node_modules/`(带斜杠)只匹配目录不匹配软链,
导致 agent 的 `git add -A` 把 `node_modules -> /abs/path` 软链提交进分支,泄露本机绝对路径
(code review + 安全审计已正确 reject)。改为建软链前先把 node_modules 写进共享
<repo>/.git/info/exclude(git 的 info/exclude 取 common dir,worktree 也读它;untracked、幂等、
不动 tracked 的 .gitignore)。测试加回归断言:git add -A 后软链不出现在 status。

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

130 lines
6.1 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');
// 关键回归:软链绝不能被 git 收录(否则把本机绝对路径提交进历史)
gitSync(wt.dir, ['add', '-A']);
const staged = gitSync(wt.dir, ['status', '--porcelain']);
assert.ok(!staged.includes('node_modules'), `node_modules 软链不应被 git add 收录:${staged}`);
// 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/,
);
});