feat(FN-5103): expose rebase landed-files capture helper for reliability tests

Fusion-Task-Id: FN-5103
Fusion-Task-Lineage: e4e4d9ba-4884-4feb-b18b-a30e799c3fb1
This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 23:58:38 -07:00
committed by gsxdsm
parent 3e7073ab84
commit 0abb1fad2c
2 changed files with 126 additions and 76 deletions

View File

@@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync, spawnSync } from "node:child_process";
import { captureRebaseLandedFilesForTask, sumShortstatsForCommits } from "../../merger.js";
import { filterFilesToOwnTaskCommits } from "../../branch-attribution.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
@@ -63,8 +64,14 @@ describeIfGit("FN-5103 reliability interaction: landed-files attribution", () =>
expect(attribution.files).toEqual(["task-a.ts", "task-b.ts", "task-c.ts"]);
expect(attribution.ownCommitCount).toBe(3);
expect(attribution.foreignCommits.length).toBe(5);
expect(attribution.ownCommitShas).toHaveLength(3);
expect(new Set(attribution.ownCommitShas ?? []).size).toBe(3);
const stats = await sumShortstatsForCommits(repoDir, attribution.ownCommitShas ?? []);
expect(stats.insertions).toBeGreaterThan(0);
expect(stats.deletions).toBeGreaterThanOrEqual(0);
const capture = await captureRebaseLandedFilesForTask({ rootDir: repoDir, rebaseMergeBaseSha: baseSha, recordedSha: git(repoDir, "git rev-parse HEAD"), taskId });
expect(capture.landedFiles).toEqual(["task-a.ts", "task-b.ts", "task-c.ts"]);
expect(capture.filesChanged).toBe(3);
expect(capture.landedFilesAttributionRestricted).toBe(true);
expect(capture.noOpVerifiedShortCircuit).toBeUndefined();
expect([own1, own2, own3]).toHaveLength(3);
});
@@ -76,10 +83,12 @@ describeIfGit("FN-5103 reliability interaction: landed-files attribution", () =>
git(repoDir, `git checkout -b fusion/${taskId.toLowerCase()}`);
await commitFile(repoDir, "foreign-only.ts", "x\n", "feat(FN-OTHER): foreign", "FN-OTHER");
const attribution = await filterFilesToOwnTaskCommits({ worktreePath: repoDir, baseRef: baseSha, taskId });
expect(attribution.files).toEqual([]);
expect(attribution.ownCommitCount).toBe(0);
expect(attribution.foreignCommits.length).toBe(1);
const capture = await captureRebaseLandedFilesForTask({ rootDir: repoDir, rebaseMergeBaseSha: baseSha, recordedSha: git(repoDir, "git rev-parse HEAD"), taskId });
expect(capture.landedFiles).toEqual([]);
expect(capture.noOpVerifiedShortCircuit).toBe(true);
expect(capture.filesChanged).toBe(0);
expect(capture.insertions).toBe(0);
expect(capture.deletions).toBe(0);
});
it("surfaces attribution failure when git reads fail", async () => {
@@ -90,18 +99,18 @@ describeIfGit("FN-5103 reliability interaction: landed-files attribution", () =>
git(repoDir, `git checkout -b fusion/${taskId.toLowerCase()}`);
await commitFile(repoDir, "task-owned.ts", "owned\n", "feat(FN-5103): own", taskId);
await expect(
filterFilesToOwnTaskCommits({
worktreePath: repoDir,
baseRef: baseSha,
taskId,
execAsyncImpl: async () => {
throw new Error("forced attribution failure");
},
}),
).rejects.toMatchObject({
name: "BranchAttributionError",
message: expect.stringContaining("forced attribution failure"),
const capture = await captureRebaseLandedFilesForTask({
rootDir: repoDir,
rebaseMergeBaseSha: baseSha,
recordedSha: git(repoDir, "git rev-parse HEAD"),
taskId,
attributionExecAsyncImpl: async () => {
throw new Error("forced attribution failure");
},
});
expect(capture.landedFilesCaptureFallback).toBe("attribution-failed");
expect(capture.landedFiles).toEqual(["task-owned.ts"]);
expect(capture.filesChanged).toBe(1);
});
});

View File

@@ -35,7 +35,7 @@ import { createHash } from "node:crypto";
import { join } from "node:path";
import { resolveTaskWorktreePath } from "./worktree-paths.js";
import { canonicalFusionBranchName } from "./worktree-names.js";
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "./branch-attribution.js";
import { filterFilesToOwnTaskCommits } from "./branch-attribution.js";
import { hostname } from "node:os";
import {
buildTaskLineageTrailer,
@@ -5126,7 +5126,7 @@ function parseShortstatSummary(statsOutput: string): { filesChanged: number; ins
* per-commit (instead of range-based) so rebased/cherry-picked SHAs do not
* require contiguous ancestry assumptions.
*/
async function sumShortstatsForCommits(
export async function sumShortstatsForCommits(
rootDir: string,
ownCommitShas: string[],
): Promise<{ insertions: number; deletions: number }> {
@@ -5145,6 +5145,78 @@ async function sumShortstatsForCommits(
return { insertions, deletions };
}
export async function captureRebaseLandedFilesForTask(params: {
rootDir: string;
rebaseMergeBaseSha: string;
recordedSha: string;
taskId: string;
onAttributionFailure?: (message: string) => Promise<void> | void;
attributionExecAsyncImpl?: (command: string, options: { cwd?: string; encoding?: BufferEncoding; maxBuffer?: number }) => Promise<{ stdout: string; stderr: string }>;
}): Promise<{
landedFiles: string[];
filesChanged: number;
insertions: number;
deletions: number;
noOpVerifiedShortCircuit?: boolean;
landedFilesAttributionRestricted?: boolean;
landedFilesCaptureFallback?: MergeDetails["landedFilesCaptureFallback"];
}> {
const { rootDir, rebaseMergeBaseSha, recordedSha, taskId, onAttributionFailure, attributionExecAsyncImpl } = params;
try {
const attribution = await filterFilesToOwnTaskCommits({
worktreePath: rootDir,
baseRef: rebaseMergeBaseSha,
taskId,
execAsyncImpl: attributionExecAsyncImpl as any,
});
if (attribution.ownCommitCount === 0) {
return {
landedFiles: [],
filesChanged: 0,
insertions: 0,
deletions: 0,
noOpVerifiedShortCircuit: true,
landedFilesAttributionRestricted: true,
};
}
const landedFiles = attribution.files;
const stats = await sumShortstatsForCommits(rootDir, attribution.ownCommitShas ?? []);
return {
landedFiles,
filesChanged: landedFiles.length,
insertions: stats.insertions,
deletions: stats.deletions,
landedFilesAttributionRestricted: true,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (onAttributionFailure) {
await onAttributionFailure(message);
}
const { stdout: landedFilesOutput } = await execAsync(
`git diff --name-only ${quoteArg(`${rebaseMergeBaseSha}..${recordedSha}`)}`,
{ cwd: rootDir, encoding: "utf-8", maxBuffer: 2 * 1024 * 1024 },
);
const landedFiles = landedFilesOutput
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const { stdout: statsOutput } = await execAsync(`git diff --shortstat ${quoteArg(`${rebaseMergeBaseSha}..HEAD`)}`, {
cwd: rootDir,
encoding: "utf-8",
});
const parsed = parseShortstatSummary(statsOutput);
return {
landedFiles: landedFiles.length > 0 ? Array.from(new Set(landedFiles)) : [],
filesChanged: parsed.filesChanged,
insertions: parsed.insertions,
deletions: parsed.deletions,
landedFilesCaptureFallback: "attribution-failed",
};
}
}
function parseDirectMergeCommitStrategyOverride(prompt: string | undefined): DirectMergeCommitStrategy | undefined {
if (!prompt) return undefined;
const match = prompt.match(/^\*\*Direct Merge Commit Strategy:\*\*\s*(auto|always-squash|always-rebase)\s*$/im);
@@ -7927,62 +7999,31 @@ export async function aiMergeTask(
if (!isEmptyCommit && !mergeWasEmpty && recordedSha) {
try {
if (rebaseMergeBaseSha) {
try {
const attribution = await filterFilesToOwnTaskCommits({
worktreePath: rootDir,
baseRef: rebaseMergeBaseSha,
taskId,
});
landedFilesAttributionRestricted = true;
if (attribution.ownCommitCount === 0) {
landedFiles = [];
filesChanged = 0;
insertions = 0;
deletions = 0;
noOpVerifiedShortCircuit = true;
mergerLog.log(`${taskId}: rebase-strategy landed-files capture: zero own commits — verified-short-circuit (rebase walked ${attribution.foreignCommits.length} foreign commits)`);
} else {
landedFiles = attribution.files;
filesChanged = landedFiles.length;
const stats = await sumShortstatsForCommits(rootDir, attribution.ownCommitShas ?? []);
insertions = stats.insertions;
deletions = stats.deletions;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const attributionErrorType = error instanceof BranchAttributionError ? "BranchAttributionError" : "Error";
mergerLog.warn(`${taskId}: landed-files attribution failed (${attributionErrorType}), falling back to full-range capture (${message})`);
await store.appendAgentLog(
taskId,
`merger: landed-files attribution failed, falling back to full-range capture (${message})`,
"text",
undefined,
"merger",
);
landedFilesCaptureFallback = "attribution-failed";
const { stdout: landedFilesOutput } = await execAsync(
`git diff --name-only ${quoteArg(`${rebaseMergeBaseSha}..${recordedSha}`)}`,
{
cwd: rootDir,
encoding: "utf-8",
maxBuffer: 2 * 1024 * 1024,
},
);
const parsedLandedFiles = landedFilesOutput
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
landedFiles = parsedLandedFiles.length > 0 ? Array.from(new Set(parsedLandedFiles)) : undefined;
const { stdout: statsOutput } = await execAsync(`git diff --shortstat ${quoteArg(`${rebaseMergeBaseSha}..HEAD`)}`, {
cwd: rootDir,
encoding: "utf-8",
});
const parsed = parseShortstatSummary(statsOutput);
filesChanged = parsed.filesChanged;
insertions = parsed.insertions;
deletions = parsed.deletions;
const capture = await captureRebaseLandedFilesForTask({
rootDir,
rebaseMergeBaseSha,
recordedSha,
taskId,
onAttributionFailure: async (message) => {
mergerLog.warn(`${taskId}: landed-files attribution failed (Error), falling back to full-range capture (${message})`);
await store.appendAgentLog(
taskId,
`merger: landed-files attribution failed, falling back to full-range capture (${message})`,
"text",
undefined,
"merger",
);
},
});
landedFiles = capture.landedFiles;
filesChanged = capture.filesChanged;
insertions = capture.insertions;
deletions = capture.deletions;
noOpVerifiedShortCircuit = capture.noOpVerifiedShortCircuit;
landedFilesAttributionRestricted = capture.landedFilesAttributionRestricted;
landedFilesCaptureFallback = capture.landedFilesCaptureFallback;
if (capture.noOpVerifiedShortCircuit) {
mergerLog.log(`${taskId}: rebase-strategy landed-files capture: zero own commits — verified-short-circuit`);
}
} else {
const { stdout: landedFilesOutput } = await execAsync(`git show --name-only --format= ${quoteArg(recordedSha)}`, {