fix: include remaining local updates

This commit is contained in:
gsxdsm
2026-04-08 08:57:19 -07:00
parent c152517d1e
commit 468b0070c5
5 changed files with 249 additions and 19 deletions

View File

@@ -42,6 +42,7 @@ import {
parseDiffStat,
extractFileScope,
validateDiffScope,
shouldSyncDependenciesForMerge,
type ConflictCategory,
} from "./merger.js";
import { createKbAgent } from "./pi.js";
@@ -1934,6 +1935,81 @@ describe("aiMergeTask — build verification", () => {
expect(result.merged).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("syncs dependencies before build verification when install state is missing", async () => {
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
mockedExistsSync.mockImplementation((path: any) => {
const pathStr = String(path);
if (pathStr.includes("node_modules") || pathStr.endsWith(".pnp.cjs")) return false;
return true;
});
let cachedQuietChecks = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "2 files changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "" as any;
if (cmdStr.includes("git diff --cached --name-only")) {
return "package.json\npackages/desktop/package.json" as any;
}
if (cmdStr.includes("pnpm install --frozen-lockfile")) return "Lockfile is up to date" as any;
if (cmdStr.includes("diff --cached --quiet")) {
cachedQuietChecks += 1;
return cachedQuietChecks === 1 ? "1" as any : "0" as any;
}
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
buildCommand: "pnpm build",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
const installCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("pnpm install --frozen-lockfile"),
);
expect(installCall).toBeDefined();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
"Syncing dependencies before merge build verification: pnpm install --frozen-lockfile",
);
});
});
describe("shouldSyncDependenciesForMerge", () => {
it("returns true when install state is missing", () => {
expect(shouldSyncDependenciesForMerge([], false)).toBe(true);
});
it("returns true when staged files change package manifests or lockfiles", () => {
expect(shouldSyncDependenciesForMerge(["packages/desktop/package.json"], true)).toBe(true);
expect(shouldSyncDependenciesForMerge(["pnpm-lock.yaml"], true)).toBe(true);
});
it("returns false for regular source-only changes when install state exists", () => {
expect(shouldSyncDependenciesForMerge(["packages/engine/src/merger.ts"], true)).toBe(false);
});
});
// ── Pre-merge diffstat scope validation tests ────────────────────────

View File

@@ -1,5 +1,6 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core";
import { createKbAgent, describeModel, promptWithFallback } from "./pi.js";
@@ -49,6 +50,17 @@ export const GENERATED_PATTERNS = [
"generated/*",
];
const DEPENDENCY_SYNC_TRIGGER_PATTERNS = [
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"package-lock.json",
"yarn.lock",
"bun.lockb",
"bun.lock",
"packages/*/package.json",
];
/** Check if a path matches a glob pattern (simple glob support: * and **) */
function matchGlob(path: string, pattern: string): boolean {
// Handle ** which matches across directory boundaries (must do before single *)
@@ -94,6 +106,65 @@ function matchGlob(path: string, pattern: string): boolean {
return regex.test(fileName) || regex.test(path);
}
export function getStagedFiles(cwd: string): string[] {
try {
const output = execSync("git diff --cached --name-only", {
cwd,
encoding: "utf-8",
stdio: "pipe",
}).trim();
return output ? output.split("\n").filter(Boolean) : [];
} catch {
return [];
}
}
export function hasInstallState(rootDir: string): boolean {
return existsSync(join(rootDir, "node_modules")) || existsSync(join(rootDir, ".pnp.cjs"));
}
export function shouldSyncDependenciesForMerge(
stagedFiles: string[],
installStatePresent: boolean,
): boolean {
if (!installStatePresent) return true;
return stagedFiles.some((file) =>
DEPENDENCY_SYNC_TRIGGER_PATTERNS.some((pattern) => matchGlob(file, pattern)),
);
}
function getDependencySyncCommand(rootDir: string): string | null {
if (existsSync(join(rootDir, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile";
if (existsSync(join(rootDir, "package-lock.json"))) return "npm install";
if (existsSync(join(rootDir, "yarn.lock"))) return "yarn install --frozen-lockfile";
if (existsSync(join(rootDir, "bun.lock")) || existsSync(join(rootDir, "bun.lockb"))) {
return "bun install --frozen-lockfile";
}
return null;
}
async function syncDependenciesForMerge(
store: TaskStore,
rootDir: string,
taskId: string,
): Promise<void> {
const installCommand = getDependencySyncCommand(rootDir);
if (!installCommand) return;
mergerLog.log(`${taskId}: syncing dependencies before merge build verification`);
await store.logEntry(taskId, `Syncing dependencies before merge build verification: ${installCommand}`);
try {
execSync(installCommand, {
cwd: rootDir,
encoding: "utf-8",
stdio: "pipe",
});
} catch (error: any) {
const details = error?.stderr || error?.stdout || error?.message || String(error);
throw new Error(`Dependency sync failed for ${taskId}: ${details}`.trim());
}
}
// ── Pre-merge diffstat scope validation ──────────────────────────────
interface DiffFileEntry {
@@ -1152,6 +1223,13 @@ async function executeMergeAttempt(
}
}
if (buildCommand) {
const stagedFiles = getStagedFiles(rootDir);
if (shouldSyncDependenciesForMerge(stagedFiles, hasInstallState(rootDir))) {
await syncDependenciesForMerge(store, rootDir, taskId);
}
}
// At this point, either:
// - No conflicts (attempt 1) - AI writes commit message
// - Complex conflicts remain after attempt 2 auto-resolution - AI resolves them