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/, ); });