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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||
|
||||
const GIT_TIMEOUT_MS = 60_000;
|
||||
const MAX_BUFFER = 16 * 1024 * 1024;
|
||||
@@ -60,9 +60,37 @@ export async function createWorktree(repoPath: string, taskId: string, baseBranc
|
||||
|
||||
mkdirSync(dirname(dir), { recursive: true });
|
||||
await git(repoPath, ['worktree', 'add', '-b', branch, dir, baseBranch]);
|
||||
linkNodeModules(repoPath, dir);
|
||||
return { dir, branch };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把主仓的 node_modules 软链进 worktree。
|
||||
* git worktree 是干净检出,不带 gitignored 的 node_modules,导致 verify(tsc/tsx/vitest 等)
|
||||
* 与执行 agent 跑 npm 脚本时 `command not found`(exit 127)。软链复用主仓依赖:
|
||||
* 零网络、同平台 native 模块兼容、被 .gitignore 忽略不污染 diff。主仓无 node_modules 则跳过(非 Node 项目)。
|
||||
*/
|
||||
function linkNodeModules(repoPath: string, dir: string): void {
|
||||
const src = join(repoPath, 'node_modules');
|
||||
if (!existsSync(src)) return;
|
||||
const dest = join(dir, 'node_modules');
|
||||
if (existsSync(dest) || isBrokenSymlink(dest)) return; // 项目自己提交了 node_modules / 残留软链,别覆盖
|
||||
try {
|
||||
symlinkSync(src, dest, 'dir');
|
||||
} catch {
|
||||
// 软链失败(权限/平台)不阻断执行——verify 仍可能因缺依赖失败,但那是显式可见的,不在这里吞
|
||||
}
|
||||
}
|
||||
|
||||
/** 目标是「指向已失效路径的软链」——existsSync 对断链返回 false,需 lstat 兜底判断 */
|
||||
function isBrokenSymlink(p: string): boolean {
|
||||
try {
|
||||
return lstatSync(p).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 分支相对 baseBranch 的改动:diff --stat 摘要 + commit 列表(新→旧,"<短hash> <标题>") */
|
||||
export async function worktreeDiff(
|
||||
repoPath: string,
|
||||
|
||||
+33
-1
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
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';
|
||||
@@ -73,6 +73,38 @@ test('worktree:create / diff / remove 在真实 git repo 上工作', async (t)
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user