fix(RUFU-018): add noCommitsExpected dep-sync skip and corepack/pnpm env passthrough (#2501)

Manually land RUFU-018 fix bypassing the AI merge pipeline.

## Summary
- Add `noCommitsExpected` flag to `LandRepoContext`; skip dependency
sync when set
- Forward `COREPACK_HOME`/`PNPM_HOME`/`npm_config_registry` in
`installWorktreeDependencies`
- Add comprehensive tests for both changes

This unblocks all downstream RUFU audit tasks.

## Surface Enumeration
- Providers/bridges: `installWorktreeDependencies` called from
`landOneRepo` (AI merge) and legacy `merger.ts`; `landOneRepo` called
from `runAiMerge` and `landWorkspaceTask`
- Data states: `noCommitsExpected` can be `true`, `false`, or
`undefined` — both callers use `=== true` strict check

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Improved support for tasks that do not produce commits by skipping
unnecessary dependency installation during merges.
- Preserved normal merge and review behavior when dependency
installation is skipped.

- **Bug Fixes**
- Dependency installation now correctly preserves relevant
package-manager and system environment settings.
- Reduced installation failures caused by missing or unavailable
package-manager configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Fusion <noreply@runfusion.ai>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
This commit is contained in:
ischindl
2026-07-31 06:50:25 +02:00
committed by GitHub
parent 01f081e8aa
commit 8d6acf1314
4 changed files with 425 additions and 0 deletions

View File

@@ -135,3 +135,127 @@ describe("installWorktreeDependencies lockfile auto-heal", () => {
expect(readLog(logPath)).toEqual([["install", "--frozen-lockfile"]]);
});
});
/*
FNXC:MergeDeps 2026-07-17-12:00:
Env passthrough coverage for installWorktreeDependencies. The explicit forwarding of corepack/pnpm
env vars mirrors mission-verification.ts VERIFICATION_ENV_ALLOWLIST so pnpm is resolvable even when
the engine process starts without full shell initialization.
*/
describe("installWorktreeDependencies env passthrough", () => {
/**
* Install a fake `pnpm` that logs selected env vars to a file so we can assert
* the child process receives the expected environment. Writes a JSON object with
* the requested env var values.
*/
function installEnvLoggingPnpm(envVars: string[], logPath: string): string {
const binDir = tmp("fusion-env-fake-bin-");
const script = join(binDir, "pnpm");
const varsJson = JSON.stringify(envVars);
writeFileSync(
script,
`#!/usr/bin/env node
const fs = require('fs');
const vars = ${varsJson};
const env = {};
for (let v of vars) env[v] = process.env[v];
fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify(env));
`,
);
chmodSync(script, 0o755);
const previousPath = process.env.PATH ?? "";
process.env.PATH = `${binDir}${delimiter}${previousPath}`;
return previousPath;
}
it("passes COREPACK_HOME, PNPM_HOME, and npm_config_registry through to exec", async () => {
// Set the env vars so the passthrough has values to forward
const origCorepackHome = process.env.COREPACK_HOME;
const origPnpmHome = process.env.PNPM_HOME;
const origNpmRegistry = process.env.npm_config_registry;
process.env.COREPACK_HOME = "/tmp/fake-corepack";
process.env.PNPM_HOME = "/tmp/fake-pnpm";
process.env.npm_config_registry = "https://fake.registry/";
const dir = tmp("fusion-env-repo-");
writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfile: {}\n");
const logPath = join(tmp("fusion-env-log-"), "env.json");
const previousPath = installEnvLoggingPnpm(
["COREPACK_HOME", "PNPM_HOME", "npm_config_registry"],
logPath,
);
try {
await installWorktreeDependencies({ cwd: dir, taskId: "FN-1" });
const captured = JSON.parse(readFileSync(logPath, "utf-8"));
expect(captured.COREPACK_HOME).toBe("/tmp/fake-corepack");
expect(captured.PNPM_HOME).toBe("/tmp/fake-pnpm");
expect(captured.npm_config_registry).toBe("https://fake.registry/");
} finally {
process.env.PATH = previousPath;
process.env.COREPACK_HOME = origCorepackHome;
process.env.PNPM_HOME = origPnpmHome;
process.env.npm_config_registry = origNpmRegistry;
}
});
it("does NOT override or strip existing env vars like PATH", async () => {
const dir = tmp("fusion-env-repo2-");
writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfile: {}\n");
const logPath = join(tmp("fusion-env-log2-"), "env.json");
const previousPath = installEnvLoggingPnpm(
["PATH", "HOME", "SHELL"],
logPath,
);
try {
await installWorktreeDependencies({ cwd: dir, taskId: "FN-1" });
const captured = JSON.parse(readFileSync(logPath, "utf-8"));
// PATH should still contain the fake bin dir AND the real PATH
expect(captured.PATH).toContain("fusion-env-fake-bin-");
// HOME and SHELL should be preserved from process.env
expect(captured.HOME).toBe(process.env.HOME);
expect(captured.SHELL).toBe(process.env.SHELL);
} finally {
process.env.PATH = previousPath;
}
});
it("handles undefined corepack/pnpm env vars gracefully", async () => {
// Clear the env vars
const origCorepackHome = process.env.COREPACK_HOME;
const origPnpmHome = process.env.PNPM_HOME;
const origNpmRegistry = process.env.npm_config_registry;
delete process.env.COREPACK_HOME;
delete process.env.PNPM_HOME;
delete process.env.npm_config_registry;
const dir = tmp("fusion-env-repo3-");
writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfile: {}\n");
const logPath = join(tmp("fusion-env-log3-"), "env.json");
const previousPath = installEnvLoggingPnpm(
["COREPACK_HOME", "PNPM_HOME", "npm_config_registry", "PATH"],
logPath,
);
try {
await installWorktreeDependencies({ cwd: dir, taskId: "FN-1" });
const captured = JSON.parse(readFileSync(logPath, "utf-8"));
// When the env vars are undefined, they should be undefined in the child too
// (not set to empty string or some sentinel)
expect(captured.COREPACK_HOME).toBeUndefined();
expect(captured.PNPM_HOME).toBeUndefined();
expect(captured.npm_config_registry).toBeUndefined();
// PATH should still be present
expect(captured.PATH).toBeDefined();
} finally {
process.env.PATH = previousPath;
process.env.COREPACK_HOME = origCorepackHome;
process.env.PNPM_HOME = origPnpmHome;
process.env.npm_config_registry = origNpmRegistry;
}
});
});

View File

@@ -0,0 +1,261 @@
/*
FNXC:MergeNoCommits 2026-07-17-12:00:
No-commits tasks (audit, documentation, decision-only) have no code changes to install or build.
The clean-room dependency sync must be skipped entirely to avoid "pnpm: command not found" failures
when pnpm is not resolvable in the engine process environment. We drive the REAL landOneRepo against
a REAL git fixture with injected agents (the squash is a plain `git merge --squash`, no AI), and
MOCK installWorktreeDependencies so we can assert call counts without real/slow/networked npm runs
(FN-5048).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Task, TaskStore } from "@fusion/core";
vi.mock("../merge-dependency-sync.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../merge-dependency-sync.js")>();
return { ...actual, installWorktreeDependencies: vi.fn() };
});
import { installWorktreeDependencies } from "../merge-dependency-sync.js";
import { landOneRepo } from "../merger-ai.js";
import { createRunAuditor, generateSyntheticRunId } from "../run-audit.js";
// ---------------------------------------------------------------------------
// Git helpers
// ---------------------------------------------------------------------------
function hasGitInstall(): boolean {
try {
execSync("git --version", { stdio: "pipe" });
return true;
} catch {
return false;
}
}
const describeIfGit = hasGitInstall() ? describe : describe.skip;
const TASK_ID = "RUFU-018";
const BRANCH = "fusion/rufu-018";
function configureIdentity(dir: string): void {
execSync('git config user.email "test@example.com"', { cwd: dir, stdio: "pipe" });
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
}
interface RepoFixture {
rootDir: string;
mainSha: string;
cleanup(): void;
}
/** Create a single real git repo with an initial commit on main, then a branch
* with one commit. Returns the fixture. */
function createRepoFixture(withChanges: boolean): RepoFixture {
const rootDir = mkdtempSync(path.join(os.tmpdir(), "fusion-no-commits-"));
execSync("git init -b main", { cwd: rootDir, stdio: "pipe" });
configureIdentity(rootDir);
writeFileSync(path.join(rootDir, "README.md"), "# Test\n", "utf-8");
execSync("git add README.md && git commit -m 'init'", { cwd: rootDir, stdio: "pipe" });
// Create the task branch
execSync(`git checkout -b ${BRANCH}`, { cwd: rootDir, stdio: "pipe" });
if (withChanges) {
writeFileSync(path.join(rootDir, "feature.txt"), "feature work\n", "utf-8");
execSync("git add feature.txt && git commit -m 'feat: add feature'", { cwd: rootDir, stdio: "pipe" });
} else {
// Commit that adds no code (e.g. a readme-only doc change), so the branch is ahead
writeFileSync(path.join(rootDir, "README.md"), "# Test\n\nUpdated\n", "utf-8");
execSync("git add README.md && git commit -m 'docs: update readme'", { cwd: rootDir, stdio: "pipe" });
}
const mainSha = execSync("git rev-parse main", { cwd: rootDir, encoding: "utf-8" }).trim();
execSync("git checkout main", { cwd: rootDir, stdio: "pipe" });
return {
rootDir,
mainSha,
cleanup: () => { try { rmSync(rootDir, { recursive: true, force: true }); } catch { /* best effort */ } },
};
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
function createStore(): TaskStore & { logs: string[] } {
const emitter = new EventEmitter();
const logs: string[] = [];
return Object.assign(emitter, {
logs,
getSettings: vi.fn().mockResolvedValue({ autoMerge: false }),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn((_id: string, message: string) => { logs.push(message); return Promise.resolve(undefined); }),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getTask: vi.fn().mockResolvedValue({
id: TASK_ID, column: "in-review", branch: BRANCH,
comments: [], steeringComments: [], steps: [], log: [],
}),
moveTask: vi.fn().mockResolvedValue({ id: TASK_ID, column: "done" } as Task),
upsertTaskCommitAssociation: vi.fn().mockResolvedValue(undefined),
accumulateTokenUsage: vi.fn().mockResolvedValue(undefined),
}) as unknown as TaskStore & { logs: string[] };
}
// ---------------------------------------------------------------------------
// Injected agents (plain git merge, no AI)
// ---------------------------------------------------------------------------
const squashMergeAgent = async (cwd: string): Promise<void> => {
configureIdentity(cwd);
execSync(`git merge --squash ${BRANCH}`, { cwd, stdio: "pipe" });
const staged = execSync("git diff --cached --name-only", { cwd, encoding: "utf-8" }).trim();
if (staged.length === 0) return;
execSync(`git commit -m "${BRANCH}: squashed"`, { cwd, stdio: "pipe" });
};
const approveReviewAgent = async (): Promise<string> => "REVIEW_VERDICT: approve";
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describeIfGit("landOneRepo no-commits dep-sync skip", () => {
let fx: RepoFixture;
let store: TaskStore & { logs: string[] };
let audit: ReturnType<typeof createRunAuditor>;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
fx?.cleanup();
});
it("skips installWorktreeDependencies entirely when noCommitsExpected: true", async () => {
fx = createRepoFixture(true); // branch has actual changes
store = createStore();
audit = createRunAuditor(store, {
runId: generateSyntheticRunId("ai-merge", TASK_ID),
agentId: "merger",
taskId: TASK_ID,
phase: "merge",
});
const result = await landOneRepo(fx.rootDir, BRANCH, "main", {
taskId: TASK_ID,
settings: { autoMerge: false } as never,
audit,
log: async () => undefined,
setStatus: async () => undefined,
maxPasses: 1,
mergeAgent: squashMergeAgent,
reviewAgent: approveReviewAgent,
stashResolveAgent: async () => undefined,
includeTaskId: true,
trailers: [],
store,
noCommitsExpected: true,
});
// Dep sync was never called
expect(vi.mocked(installWorktreeDependencies)).not.toHaveBeenCalled();
// The land still succeeds
expect(result.outcome).toBe("landed");
});
it("still calls installWorktreeDependencies when noCommitsExpected is false", async () => {
fx = createRepoFixture(true); // branch has actual changes
store = createStore();
audit = createRunAuditor(store, {
runId: generateSyntheticRunId("ai-merge", TASK_ID),
agentId: "merger",
taskId: TASK_ID,
phase: "merge",
});
const result = await landOneRepo(fx.rootDir, BRANCH, "main", {
taskId: TASK_ID,
settings: { autoMerge: false } as never,
audit,
log: async () => undefined,
setStatus: async () => undefined,
maxPasses: 1,
mergeAgent: squashMergeAgent,
reviewAgent: approveReviewAgent,
stashResolveAgent: async () => undefined,
includeTaskId: true,
trailers: [],
store,
// noCommitsExpected intentionally omitted (defaults to undefined → false)
});
// Dep sync WAS called
expect(vi.mocked(installWorktreeDependencies)).toHaveBeenCalled();
// The land still succeeds
expect(result.outcome).toBe("landed");
});
it("still calls installWorktreeDependencies when noCommitsExpected is explicitly false", async () => {
fx = createRepoFixture(true); // branch has actual changes
store = createStore();
audit = createRunAuditor(store, {
runId: generateSyntheticRunId("ai-merge", TASK_ID),
agentId: "merger",
taskId: TASK_ID,
phase: "merge",
});
const result = await landOneRepo(fx.rootDir, BRANCH, "main", {
taskId: TASK_ID,
settings: { autoMerge: false } as never,
audit,
log: async () => undefined,
setStatus: async () => undefined,
maxPasses: 1,
mergeAgent: squashMergeAgent,
reviewAgent: approveReviewAgent,
stashResolveAgent: async () => undefined,
includeTaskId: true,
trailers: [],
store,
noCommitsExpected: false,
});
// Dep sync WAS called
expect(vi.mocked(installWorktreeDependencies)).toHaveBeenCalled();
expect(result.outcome).toBe("landed");
});
it("lands successfully with noCommitsExpected: true and actual changes", async () => {
fx = createRepoFixture(true); // branch has actual file changes
store = createStore();
audit = createRunAuditor(store, {
runId: generateSyntheticRunId("ai-merge", TASK_ID),
agentId: "merger",
taskId: TASK_ID,
phase: "merge",
});
const result = await landOneRepo(fx.rootDir, BRANCH, "main", {
taskId: TASK_ID,
settings: { autoMerge: false } as never,
audit,
log: async () => undefined,
setStatus: async () => undefined,
maxPasses: 1,
mergeAgent: squashMergeAgent,
reviewAgent: approveReviewAgent,
stashResolveAgent: async () => undefined,
includeTaskId: true,
trailers: [],
store,
noCommitsExpected: true,
});
expect(result.outcome).toBe("landed");
expect(vi.mocked(installWorktreeDependencies)).not.toHaveBeenCalled();
});
});

View File

@@ -166,9 +166,25 @@ export async function installWorktreeDependencies(options: InstallWorktreeDepend
logger?.log?.(`${taskId}: syncing dependencies ${context}`);
await log?.(`Syncing dependencies ${context}: ${installCommand}`);
/*
FNXC:MergeDeps 2026-07-17-12:00:
Pass corepack/pnpm env vars (PNPM_HOME, COREPACK_HOME, npm_config_registry) to the exec
child process — mirrors mission-verification.ts VERIFICATION_ENV_ALLOWLIST so pnpm is
resolvable even when the engine process starts without full shell initialization. Without
these vars, corepack cannot locate its pnpm shim and "pnpm: command not found" occurs.
*/
const resolvedEnv: NodeJS.ProcessEnv = { ...process.env };
const PNPM_ENV_VARS = ["COREPACK_HOME", "PNPM_HOME", "npm_config_registry"] as const;
for (const key of PNPM_ENV_VARS) {
const value = process.env[key];
if (value !== undefined) {
resolvedEnv[key] = value;
}
}
const runInstall = (command: string): Promise<unknown> =>
execAsync(command, {
cwd,
env: resolvedEnv,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
timeout: INSTALL_TIMEOUT_MS,

View File

@@ -763,6 +763,14 @@ export interface LandRepoContext {
off, preserving the documented hard-fail for the single-repo land path.
*/
nonFatalDependencySync?: boolean;
/*
FNXC:MergeNoCommits 2026-07-17-12:00:
When true, the task is expected to produce no code changes (audit, documentation, decision-only).
The clean-room dependency sync is skipped entirely because there are no source changes to install
or build. Avoiding the dep-sync prevents "pnpm: command not found" failures when pnpm is not
resolvable in the engine process environment, and avoids unnecessary work.
*/
noCommitsExpected?: boolean;
store: TaskStore;
}
@@ -883,6 +891,17 @@ export async function landOneRepo(
* FNXC:AIMerge 2026-06-13-20:32:
* The detached AI-merge clean room is rebuilt from the integration tip and starts without workspace dependencies. Hard-fail configured or inferred install failures so verification cannot silently run against an uninstalled checkout; aborts propagate before merge agents run.
*/
/*
FNXC:MergeNoCommits 2026-07-17-12:00:
No-commits tasks (audit, documentation, decision-only) have no code changes to install or
build. Skip the entire dependency-sync step in the clean-room worktree to avoid "pnpm: command
not found" when pnpm is not resolvable in the engine process environment. The merge/review
agents still run (they may verify documentation or produce merge metadata); only the
dependency install is skipped.
*/
if (ctx.noCommitsExpected === true) {
await log(`AI merge: skipping dependency sync — no-commits task (no code changes expected)`);
} else {
const depsSyncStartedAt = Date.now();
let depsSyncResult: Awaited<ReturnType<typeof installWorktreeDependencies>> | null = null;
try {
@@ -937,6 +956,7 @@ export async function landOneRepo(
});
}
await log(`[timing] AI merge dependency sync completed in ${Date.now() - depsSyncStartedAt}ms${depsSyncResult ? (depsSyncResult.installCommand ? ` (${depsSyncResult.skipped ? "skipped" : "ran"}: ${depsSyncResult.installCommand})` : " (no command)") : " (failed — non-fatal, deps unavailable)"}`);
}
// 2 + 3. Merge + review loop (corrective passes).
const reviewResult = await mergeAndReview({
@@ -1281,6 +1301,8 @@ export async function runAiMerge(
mergeAgent, reviewAgent, stashResolveAgent,
includeTaskId, trailers, taskTitle, signal: options.signal,
allowDirtyLocalCheckoutSync,
// FNXC:MergeNoCommits 2026-07-17-12:00: no-commits tasks skip dependency sync in the clean room
noCommitsExpected: task.noCommitsExpected === true,
store,
});
@@ -1877,6 +1899,8 @@ export async function landWorkspaceTask(
// FNXC:Workspace 2026-06-24-23:50: one sub-repo's dependency-sync failure must not block
// landing the others — degrade verification for that repo, still land the git squash.
nonFatalDependencySync: true,
// FNXC:MergeNoCommits 2026-07-17-12:00: no-commits tasks skip dependency sync in the clean room
noCommitsExpected: task.noCommitsExpected === true,
store,
});
if (landResult.outcome === "landed") {