fcca3f95e0
上一版软链修复有回归:.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>
130 lines
6.1 KiB
TypeScript
130 lines
6.1 KiB
TypeScript
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 repo(main 分支) */
|
||
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('worktree:create / 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/,
|
||
);
|
||
});
|