feat(FN-4646): complete Step 3 — reconcile landed files in self-healing
Fusion-Task-Id: FN-4646 Fusion-Task-Lineage: f921098e-3407-40fa-984d-720e0c3248c7
This commit is contained in:
@@ -23,6 +23,13 @@ function parseShortstat(output: string): { filesChanged: number; insertions: num
|
||||
};
|
||||
}
|
||||
|
||||
function parseNameOnly(output: string): string[] {
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function makeTask(id: string, repo: string, sha: string, mergeDetails: Task["mergeDetails"]): Task {
|
||||
return {
|
||||
id,
|
||||
@@ -92,7 +99,7 @@ describeIfGit("SelfHealingManager recoverDoneTaskMergeMetadata stale stats", ()
|
||||
it("repairs stale confirmed stats from live shortstat without changing SHA", async () => {
|
||||
const { repo, sha } = setupRepo();
|
||||
const expected = parseShortstat(git(repo, `git show --shortstat --format= ${sha}`));
|
||||
const task = makeTask("FN-4526-STATS", repo, sha, { filesChanged: 99, insertions: 999, deletions: 999 });
|
||||
const task = makeTask("FN-4526-STATS", repo, sha, { filesChanged: 99, insertions: 999, deletions: 999, landedFiles: ["a.ts", "b.ts", "c.ts"] });
|
||||
const tasks = new Map([[task.id, task]]);
|
||||
const store = createStore(tasks);
|
||||
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
|
||||
@@ -104,13 +111,37 @@ describeIfGit("SelfHealingManager recoverDoneTaskMergeMetadata stale stats", ()
|
||||
expect(repaired.mergeDetails?.filesChanged).toBe(expected.filesChanged);
|
||||
expect(repaired.mergeDetails?.insertions).toBe(expected.insertions);
|
||||
expect(repaired.mergeDetails?.deletions).toBe(expected.deletions);
|
||||
expect((store.logEntry as any).mock.calls.some((call: any[]) => String(call[1]).includes("stale mergeDetails stats repaired"))).toBe(true);
|
||||
expect((store.logEntry as any).mock.calls.some((call: any[]) => String(call[1]).includes("stale mergeDetails repaired"))).toBe(true);
|
||||
});
|
||||
|
||||
it("FN-4646: repairs stale landed file snapshot and modifiedFiles from live commit", async () => {
|
||||
const { repo, sha } = setupRepo();
|
||||
const expected = parseShortstat(git(repo, `git show --shortstat --format= ${sha}`));
|
||||
const expectedFiles = parseNameOnly(git(repo, `git show --name-only --format= ${sha}`));
|
||||
const task = makeTask("FN-4526-STATS", repo, sha, {
|
||||
filesChanged: 99,
|
||||
insertions: 999,
|
||||
deletions: 999,
|
||||
landedFiles: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"],
|
||||
});
|
||||
task.modifiedFiles = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"];
|
||||
const tasks = new Map([[task.id, task]]);
|
||||
const store = createStore(tasks);
|
||||
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
|
||||
|
||||
await manager.recoverDoneTaskMergeMetadata();
|
||||
|
||||
const repaired = tasks.get(task.id)!;
|
||||
expect(repaired.mergeDetails?.landedFiles).toEqual(expectedFiles);
|
||||
expect(repaired.modifiedFiles).toEqual(expectedFiles);
|
||||
expect(repaired.mergeDetails?.filesChanged).toBe(expected.filesChanged);
|
||||
expect((store.logEntry as any).mock.calls.some((call: any[]) => String(call[1]).includes("files 5 → 2"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not rewrite when stats are already correct", async () => {
|
||||
const { repo, sha } = setupRepo();
|
||||
const expected = parseShortstat(git(repo, `git show --shortstat --format= ${sha}`));
|
||||
const task = makeTask("FN-4526-STATS", repo, sha, expected);
|
||||
const task = makeTask("FN-4526-STATS", repo, sha, { ...expected, landedFiles: parseNameOnly(git(repo, `git show --name-only --format= ${sha}`)) });
|
||||
const tasks = new Map([[task.id, task]]);
|
||||
const store = createStore(tasks);
|
||||
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
|
||||
|
||||
@@ -5851,6 +5851,71 @@ describe("recoverDoneTaskMergeMetadata", () => {
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-4646: repairs missing landedFiles on confirmed merge metadata", async () => {
|
||||
const store = createMockStore();
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-4646-A",
|
||||
column: "done",
|
||||
paused: false,
|
||||
mergeDetails: { commitSha: "merge1", mergeConfirmed: true, filesChanged: 2, insertions: 3, deletions: 1, mergeCommitMessage: "msg" },
|
||||
modifiedFiles: ["a.ts", "b.ts", "c.ts"],
|
||||
},
|
||||
]);
|
||||
|
||||
mockedExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("merge-base --is-ancestor 'merge1' HEAD")) return "" as any;
|
||||
if (cmd.includes("log -1 --format=%H%x1f%s%x1f%b 'merge1'")) return "merge1\u001ffix(FN-4646): canonical merge\u001fFusion-Task-Id: FN-4646-A" as any;
|
||||
if (cmd.includes("show --shortstat --format=") && cmd.includes("merge1")) return "2 files changed, 3 insertions(+), 1 deletion(-)" as any;
|
||||
if (cmd.includes("Fusion-Task-Id: FN-4646-A")) return "merge1\u001ffix(FN-4646): canonical merge\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
await manager.recoverDoneTaskMergeMetadata();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-4646-A", expect.objectContaining({
|
||||
mergeDetails: expect.objectContaining({ landedFiles: [] }),
|
||||
modifiedFiles: undefined,
|
||||
}));
|
||||
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-4646: repairs differing landedFiles on confirmed merge metadata", async () => {
|
||||
const store = createMockStore();
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-4646-B",
|
||||
column: "done",
|
||||
paused: false,
|
||||
mergeDetails: { commitSha: "merge1", mergeConfirmed: true, filesChanged: 2, insertions: 3, deletions: 1, mergeCommitMessage: "msg", landedFiles: ["a.ts", "b.ts", "c.ts"] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockedExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("merge-base --is-ancestor 'merge1' HEAD")) return "" as any;
|
||||
if (cmd.includes("log -1 --format=%H%x1f%s%x1f%b 'merge1'")) return "merge1\u001ffix(FN-4646): canonical merge\u001fFusion-Task-Id: FN-4646-B" as any;
|
||||
if (cmd.includes("show --shortstat --format=") && cmd.includes("merge1")) return "2 files changed, 3 insertions(+), 1 deletion(-)" as any;
|
||||
if (cmd.includes("Fusion-Task-Id: FN-4646-B")) return "merge1\u001ffix(FN-4646): canonical merge\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
await manager.recoverDoneTaskMergeMetadata();
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-4646-B", expect.objectContaining({
|
||||
mergeDetails: expect.objectContaining({ landedFiles: [] }),
|
||||
modifiedFiles: undefined,
|
||||
}));
|
||||
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("populates rebaseBaseSha from landed commit when missing", async () => {
|
||||
const store = createMockStore();
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
|
||||
@@ -2688,6 +2688,25 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async readLandedFilesForSha(sha: string, rebaseBaseSha?: string): Promise<string[] | null> {
|
||||
try {
|
||||
const command = rebaseBaseSha
|
||||
? `git diff --name-only ${shellQuote(`${rebaseBaseSha}..${sha}`)}`
|
||||
: `git show --name-only --format= ${shellQuote(sha)}`;
|
||||
const result = await execAsync(command, {
|
||||
cwd: this.options.rootDir,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const files = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return files.length > 0 ? Array.from(new Set(files)) : [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async recoverDoneTaskMergeMetadata(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ column: "done", slim: true });
|
||||
@@ -2710,6 +2729,15 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
const liveShortstat = await this.readShortstatForSha(storedSha);
|
||||
const liveLandedFiles = await this.readLandedFilesForSha(storedSha, task.mergeDetails?.rebaseBaseSha);
|
||||
const currentLandedFiles = task.mergeDetails?.landedFiles;
|
||||
const landedFilesMismatch = Boolean(
|
||||
liveLandedFiles && (
|
||||
!currentLandedFiles ||
|
||||
liveLandedFiles.length !== currentLandedFiles.length ||
|
||||
liveLandedFiles.some((file, index) => currentLandedFiles[index] !== file)
|
||||
),
|
||||
);
|
||||
const statsMismatch = Boolean(
|
||||
liveShortstat && (
|
||||
task.mergeDetails?.filesChanged !== liveShortstat.filesChanged ||
|
||||
@@ -2723,6 +2751,8 @@ export class SelfHealingManager {
|
||||
task.mergeDetails?.insertions === undefined ||
|
||||
task.mergeDetails?.deletions === undefined ||
|
||||
task.mergeDetails?.mergeCommitMessage === undefined ||
|
||||
!currentLandedFiles ||
|
||||
landedFilesMismatch ||
|
||||
statsMismatch;
|
||||
|
||||
if (!needsMetadataRepair) continue;
|
||||
@@ -2737,16 +2767,18 @@ export class SelfHealingManager {
|
||||
filesChanged: nextFilesChanged,
|
||||
insertions: nextInsertions,
|
||||
deletions: nextDeletions,
|
||||
landedFiles: liveLandedFiles ?? task.mergeDetails?.landedFiles,
|
||||
mergeCommitMessage: task.mergeDetails?.mergeCommitMessage ?? landed.subject,
|
||||
rebaseBaseSha: task.mergeDetails?.rebaseBaseSha ?? landed.rebaseBaseSha,
|
||||
mergedAt: task.mergeDetails?.mergedAt ?? new Date().toISOString(),
|
||||
prNumber: task.prInfo?.number,
|
||||
},
|
||||
modifiedFiles: liveLandedFiles && liveLandedFiles.length > 0 ? liveLandedFiles : undefined,
|
||||
});
|
||||
if (statsMismatch && liveShortstat) {
|
||||
if ((statsMismatch && liveShortstat) || landedFilesMismatch) {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-recovered: stale mergeDetails stats repaired (was ${task.mergeDetails?.filesChanged ?? "?"}/${task.mergeDetails?.insertions ?? "?"}/${task.mergeDetails?.deletions ?? "?"}, now ${liveShortstat.filesChanged}/${liveShortstat.insertions}/${liveShortstat.deletions}) — sha unchanged ${storedSha.slice(0, 8)}`,
|
||||
`Auto-recovered: stale mergeDetails repaired (was ${task.mergeDetails?.filesChanged ?? "?"}/${task.mergeDetails?.insertions ?? "?"}/${task.mergeDetails?.deletions ?? "?"}, now ${liveShortstat?.filesChanged ?? nextFilesChanged}/${liveShortstat?.insertions ?? nextInsertions}/${liveShortstat?.deletions ?? nextDeletions})${landedFilesMismatch ? ` (files ${task.mergeDetails?.landedFiles?.length ?? 0} → ${liveLandedFiles?.length ?? task.mergeDetails?.landedFiles?.length ?? 0})` : ""} — sha unchanged ${storedSha.slice(0, 8)}`,
|
||||
);
|
||||
} else {
|
||||
await this.store.logEntry(task.id, `Auto-recovered: reconciled done-task mergeDetails to owned commit ${landed.sha.slice(0, 8)}`);
|
||||
@@ -2768,16 +2800,22 @@ export class SelfHealingManager {
|
||||
insertions: landed.insertions ?? 0,
|
||||
deletions: landed.deletions ?? 0,
|
||||
};
|
||||
const landedFiles = await this.readLandedFilesForSha(landed.sha, task.mergeDetails?.rebaseBaseSha ?? landed.rebaseBaseSha);
|
||||
|
||||
const needsRepair =
|
||||
task.mergeDetails?.commitSha !== landed.sha ||
|
||||
task.mergeDetails?.filesChanged === undefined ||
|
||||
task.mergeDetails?.insertions === undefined ||
|
||||
task.mergeDetails?.deletions === undefined || (
|
||||
task.mergeDetails?.deletions === undefined ||
|
||||
!task.mergeDetails?.landedFiles || (
|
||||
task.mergeDetails?.commitSha === landed.sha && (
|
||||
task.mergeDetails?.filesChanged !== landedStats.filesChanged ||
|
||||
task.mergeDetails?.insertions !== landedStats.insertions ||
|
||||
task.mergeDetails?.deletions !== landedStats.deletions
|
||||
task.mergeDetails?.deletions !== landedStats.deletions ||
|
||||
(landedFiles ? (
|
||||
task.mergeDetails?.landedFiles?.length !== landedFiles.length ||
|
||||
landedFiles.some((file, index) => task.mergeDetails?.landedFiles?.[index] !== file)
|
||||
) : false)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2792,10 +2830,12 @@ export class SelfHealingManager {
|
||||
deletions: landedStats.deletions,
|
||||
mergeCommitMessage: landed.subject,
|
||||
rebaseBaseSha: task.mergeDetails?.rebaseBaseSha ?? landed.rebaseBaseSha,
|
||||
landedFiles: landedFiles ?? task.mergeDetails?.landedFiles,
|
||||
mergedAt: task.mergeDetails?.mergedAt ?? new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
prNumber: task.prInfo?.number,
|
||||
},
|
||||
modifiedFiles: landedFiles && landedFiles.length > 0 ? landedFiles : undefined,
|
||||
});
|
||||
await this.store.logEntry(task.id, `Auto-recovered: reconciled done-task mergeDetails to owned commit ${landed.sha.slice(0, 8)}`);
|
||||
repaired++;
|
||||
|
||||
Reference in New Issue
Block a user