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:
committed by
gsxdsm
parent
3e7073ab84
commit
0abb1fad2c
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`, {
|
||||
|
||||
Reference in New Issue
Block a user