From 9bd02f2479d905c74d10982c7595b6b8ffc0454c Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 09:01:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(executor):=20worktree=20=E8=BD=AF=E9=93=BE?= =?UTF-8?q?=E4=B8=BB=E4=BB=93=20node=5Fmodules,=E4=BF=AE=20verify=20exit?= =?UTF-8?q?=20127=20=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/executor/worktree.ts | 30 +++++++++++++++++++++++++++++- test/worktree.test.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/executor/worktree.ts b/src/executor/worktree.ts index e89ccdd..c3b3f30 100644 --- a/src/executor/worktree.ts +++ b/src/executor/worktree.ts @@ -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, diff --git a/test/worktree.test.ts b/test/worktree.test.ts index fa4693c..2de2413 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -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;