feat(FN-4436): complete Step 2 — restore done fallback enumeration

Fusion-Task-Id: FN-4436
Fusion-Task-Lineage: df683800-0108-4415-a496-81481082284d
This commit is contained in:
Fusion
2026-05-13 22:50:50 -07:00
committed by gsxdsm
parent 46e1dacdb8
commit cb3519500d
2 changed files with 146 additions and 90 deletions

View File

@@ -149,17 +149,17 @@ describe("FN-4308 multi-commit done task aggregation", () => {
"merge-base --is-ancestor c1 HEAD": "",
"merge-base --is-ancestor c2 HEAD": "",
"merge-base --is-ancestor c3 HEAD": "",
"rev-parse c1^": "p1",
"diff --name-status p1..c1": "A\ta.txt\nM\tb.txt",
"diff p1..c1 -- a.txt": "+a\n",
"diff p1..c1 -- b.txt": "+b\n",
"rev-parse c2^": "p2",
"diff --name-status p2..c2": "M\tb.txt\nA\tc.txt",
"diff p2..c2 -- b.txt": "+bb\n-b\n",
"diff p2..c2 -- c.txt": "+c\n",
"rev-parse c3^": "p3",
"diff --name-status p3..c3": "A\td.txt",
"diff p3..c3 -- d.txt": "+d\n",
"rev-list --parents -n 1 c1": "c1 p1",
"diff --name-status -M p1..c1": "A\ta.txt\nM\tb.txt",
"diff -M p1..c1 -- a.txt": "+a\n",
"diff -M p1..c1 -- b.txt": "+b\n",
"rev-list --parents -n 1 c2": "c2 p2",
"diff --name-status -M p2..c2": "M\tb.txt\nA\tc.txt",
"diff -M p2..c2 -- b.txt": "+bb\n-b\n",
"diff -M p2..c2 -- c.txt": "+c\n",
"rev-list --parents -n 1 c3": "c3 p3",
"diff --name-status -M p3..c3": "A\td.txt",
"diff -M p3..c3 -- d.txt": "+d\n",
});
const app = createServer(store as any);
@@ -180,9 +180,9 @@ describe("FN-4308 multi-commit done task aggregation", () => {
gitResponses({
"merge-base --is-ancestor c1 HEAD": "",
"rev-parse c1^": "p1",
"diff --name-status p1..c1": "A\tone.txt",
"diff p1..c1 -- one.txt": "+one\n",
"rev-list --parents -n 1 c1": "c1 p1",
"diff --name-status -M p1..c1": "A\tone.txt",
"diff -M p1..c1 -- one.txt": "+one\n",
});
const app = createServer(store as any);
@@ -199,15 +199,16 @@ describe("FN-4308 multi-commit done task aggregation", () => {
gitResponses({
"merge-base --is-ancestor m1 HEAD": "",
"rev-parse m1^": "pm1",
"diff pm1..m1": "+x\n-y\n",
"diff --name-only pm1..m1": "x.txt\n",
"rev-list --parents -n 1 m1": "m1 pm1",
"diff --name-status -M pm1..m1": "M\tx.txt",
"diff -M pm1..m1 -- x.txt": "+x\n-y\n",
});
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body.files).toEqual([]);
expect(response.body.files).toHaveLength(1);
expect(response.body.files[0].path).toBe("x.txt");
expect(response.body.stats.filesChanged).toBe(1);
});
@@ -220,9 +221,9 @@ describe("FN-4308 multi-commit done task aggregation", () => {
const key = args.join(" ");
if (key === "merge-base --is-ancestor bad HEAD") throw new Error("unreachable");
if (key === "merge-base --is-ancestor good HEAD") return "";
if (key === "rev-parse good^") return "p";
if (key === "diff --name-status p..good") return "A\treachable.txt";
if (key === "diff p..good -- reachable.txt") return "+ok\n";
if (key === "rev-list --parents -n 1 good") return "good p";
if (key === "diff --name-status -M p..good") return "A\treachable.txt";
if (key === "diff -M p..good -- reachable.txt") return "+ok\n";
throw new Error(`Unexpected git command: ${key}`);
});
@@ -241,12 +242,12 @@ describe("FN-4308 multi-commit done task aggregation", () => {
gitResponses({
"merge-base --is-ancestor rev-1 HEAD": "",
"merge-base --is-ancestor rev-2 HEAD": "",
"rev-parse rev-1^": "p1",
"diff --name-status p1..rev-1": "A\tinitial.ts",
"diff p1..rev-1 -- initial.ts": "+i\n",
"rev-parse rev-2^": "p2",
"diff --name-status p2..rev-2": "A\trevision.ts",
"diff p2..rev-2 -- revision.ts": "+r\n",
"rev-list --parents -n 1 rev-1": "rev-1 p1",
"diff --name-status -M p1..rev-1": "A\tinitial.ts",
"diff -M p1..rev-1 -- initial.ts": "+i\n",
"rev-list --parents -n 1 rev-2": "rev-2 p2",
"diff --name-status -M p2..rev-2": "A\trevision.ts",
"diff -M p2..rev-2 -- revision.ts": "+r\n",
});
const app = createServer(store as any);
@@ -262,9 +263,9 @@ describe("FN-4308 multi-commit done task aggregation", () => {
gitResponses({
"merge-base --is-ancestor healed HEAD": "",
"rev-parse healed^": "ph",
"diff --name-status ph..healed": "A\thealed.ts",
"diff ph..healed -- healed.ts": "+h\n",
"rev-list --parents -n 1 healed": "healed ph",
"diff --name-status -M ph..healed": "A\thealed.ts",
"diff -M ph..healed -- healed.ts": "+h\n",
});
const app = createServer(store as any);
@@ -274,6 +275,25 @@ describe("FN-4308 multi-commit done task aggregation", () => {
expect(response.body.files[0].path).toBe("healed.ts");
});
it("uses parent-to-parent range for merge commits", async () => {
const store = new MockStore();
store.addTask(createTask({ column: "done", mergeDetails: { commitSha: "merge-sha" } }));
gitResponses({
"merge-base --is-ancestor merge-sha HEAD": "",
"rev-list --parents -n 1 merge-sha": "merge-sha p1 p2",
"diff --name-status -M merge-sha^1...merge-sha^2": "A\tfeature-a.ts\nM\tfeature-b.ts",
"diff -M merge-sha^1...merge-sha^2 -- feature-a.ts": "+a\n",
"diff -M merge-sha^1...merge-sha^2 -- feature-b.ts": "+b\n",
});
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body.files.map((f: any) => f.path).sort()).toEqual(["feature-a.ts", "feature-b.ts"]);
expect(response.body.stats.filesChanged).toBe(2);
});
it("includes mergeDetails.commitSha even when missing from associations", async () => {
const store = new MockStore();
store.addTask(createTask({ column: "done", lineageId: "lin-1", mergeDetails: { commitSha: "merge-only" } }));
@@ -283,9 +303,9 @@ describe("FN-4308 multi-commit done task aggregation", () => {
const key = args.join(" ");
if (key === "merge-base --is-ancestor assoc-1 HEAD") throw new Error("unreachable");
if (key === "merge-base --is-ancestor merge-only HEAD") return "";
if (key === "rev-parse merge-only^") return "p";
if (key === "diff --name-status p..merge-only") return "A\tmerged.txt";
if (key === "diff p..merge-only -- merged.txt") return "+ok\n";
if (key === "rev-list --parents -n 1 merge-only") return "merge-only p";
if (key === "diff --name-status -M p..merge-only") return "A\tmerged.txt";
if (key === "diff -M p..merge-only -- merged.txt") return "+ok\n";
throw new Error(`Unexpected git command: ${key}`);
});

View File

@@ -41,6 +41,8 @@ const fileDiffsCache = new Map<
type DoneTaskFileStatus = "added" | "modified" | "deleted" | "renamed";
const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
type BranchFallbackTask = {
branch?: string | null;
baseBranch?: string;
@@ -235,6 +237,60 @@ type DoneTaskAggregationStore = {
getTaskCommitAssociationsByLineageId: (lineageId: string) => Promise<Array<{ commitSha: string; authoredAt?: string | null }>>;
};
async function resolveCommitDiffSpec(sha: string, rootDir: string): Promise<
| { mode: "root"; base: string; range: string }
| { mode: "single-parent"; base: string; range: string }
| { mode: "merge"; range: string }
> {
const parentLine = (await runGitCommand(["rev-list", "--parents", "-n", "1", sha], rootDir, 5000)).trim();
const parts = parentLine.split(/\s+/).filter(Boolean);
if (parts.length <= 1) {
return { mode: "root", base: EMPTY_TREE_SHA, range: `${EMPTY_TREE_SHA}..${sha}` };
}
const parents = parts.slice(1);
if (parents.length >= 2) {
return { mode: "merge", range: `${sha}^1...${sha}^2` };
}
return { mode: "single-parent", base: parents[0]!, range: `${parents[0]}..${sha}` };
}
function parseStatusCode(statusCode: string): DoneTaskFileStatus {
if (statusCode.startsWith("A")) return "added";
if (statusCode.startsWith("D")) return "deleted";
if (statusCode.startsWith("R")) return "renamed";
return "modified";
}
async function collectDoneRangeFiles(range: string, rootDir: string): Promise<AggregatedDoneTaskFile[]> {
const nameStatus = (await runGitCommand(["diff", "--name-status", "-M", range], rootDir, 10000)).trim();
const files: AggregatedDoneTaskFile[] = [];
for (const line of nameStatus.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const statusCode = parts[0] ?? "M";
const isRenameLike = statusCode.startsWith("R") || statusCode.startsWith("C");
const oldPath = isRenameLike ? (parts[1] ?? "") : undefined;
const filePath = isRenameLike ? (parts[2] ?? parts[1] ?? "") : (parts[1] ?? "");
if (!filePath) continue;
let patch = "";
try {
patch = await runGitCommand(["diff", "-M", range, "--", filePath], rootDir, 10000);
} catch {
patch = "";
}
const additions = (patch.match(/^\+[^+]/gm) || []).length;
const deletions = (patch.match(/^-[^-]/gm) || []).length;
const status = parseStatusCode(statusCode);
files.push(oldPath ? { path: filePath, status, additions, deletions, patch: patch || `rename from ${oldPath}\nrename to ${filePath}\n` } : { path: filePath, status, additions, deletions, patch });
}
return files;
}
async function collectDoneTaskFiles(task: DoneTaskAggregationTask, scopedStore: DoneTaskAggregationStore): Promise<{
files: AggregatedDoneTaskFile[];
stats: { filesChanged: number; additions: number; deletions: number };
@@ -284,52 +340,32 @@ async function collectDoneTaskFiles(task: DoneTaskAggregationTask, scopedStore:
const byPath = new Map<string, AggregatedDoneTaskFile>();
for (const sha of reachableShas) {
let parentSha: string;
let diffSpec: Awaited<ReturnType<typeof resolveCommitDiffSpec>>;
try {
parentSha = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
diffSpec = await resolveCommitDiffSpec(sha, rootDir);
} catch {
continue;
}
let nameStatus = "";
let filesForSha: AggregatedDoneTaskFile[] = [];
try {
nameStatus = (await runGitCommand(["diff", "--name-status", `${parentSha}..${sha}`], rootDir, 10000)).trim();
filesForSha = await collectDoneRangeFiles(diffSpec.range, rootDir);
} catch {
continue;
}
for (const line of nameStatus.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const statusCode = parts[0] ?? "M";
const filePath = statusCode.startsWith("R") ? (parts[2] ?? parts[1] ?? "") : (parts[1] ?? "");
if (!filePath) continue;
let status: DoneTaskFileStatus = "modified";
if (statusCode.startsWith("A")) status = "added";
else if (statusCode.startsWith("D")) status = "deleted";
else if (statusCode.startsWith("R")) status = "renamed";
let patch = "";
try {
patch = await runGitCommand(["diff", `${parentSha}..${sha}`, "--", filePath], rootDir, 10000);
} catch {
patch = "";
}
const additions = (patch.match(/^\+[^+]/gm) || []).length;
const deletions = (patch.match(/^-[^-]/gm) || []).length;
const existing = byPath.get(filePath);
for (const file of filesForSha) {
const existing = byPath.get(file.path);
if (!existing) {
byPath.set(filePath, { path: filePath, status, additions, deletions, patch });
byPath.set(file.path, file);
continue;
}
existing.additions += additions;
existing.deletions += deletions;
existing.patch = `${existing.patch}${existing.patch && patch ? "\n" : ""}${patch}`;
if (statusPriority(status) > statusPriority(existing.status)) {
existing.status = status;
existing.additions += file.additions;
existing.deletions += file.deletions;
existing.patch = `${existing.patch}${existing.patch && file.patch ? "\n" : ""}${file.patch}`;
if (statusPriority(file.status) > statusPriority(existing.status)) {
existing.status = file.status;
}
}
}
@@ -486,16 +522,33 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
const rootDir = scopedStore.getRootDir();
const sha = task.mergeDetails.commitSha;
let mergeBase: string | undefined;
let diffSpec: Awaited<ReturnType<typeof resolveCommitDiffSpec>>;
try {
mergeBase = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
diffSpec = await resolveCommitDiffSpec(sha, rootDir);
} catch {
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
const patch = await runGitCommand(["diff", `${mergeBase}..${sha}`], rootDir, 10000).catch(() => "");
const filesChanged = (await runGitCommand(["diff", "--name-only", `${mergeBase}..${sha}`], rootDir, 10000)
const doneFiles = await collectDoneRangeFiles(diffSpec.range, rootDir).catch(() => []);
if (doneFiles.length > 0) {
const files = doneFiles.map((file) => ({
...file,
status: file.status === "renamed" ? "modified" : file.status,
}));
res.json({
files,
stats: {
filesChanged: files.length,
additions: files.reduce((sum, file) => sum + file.additions, 0),
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
},
});
return;
}
const patch = await runGitCommand(["diff", diffSpec.range], rootDir, 10000).catch(() => "");
const filesChanged = (await runGitCommand(["diff", "--name-only", diffSpec.range], rootDir, 10000)
.then((output) => output.split("\n").filter(Boolean).length)
.catch(() => 0));
@@ -661,35 +714,18 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
const rootDir = scopedStore.getRootDir();
const sha = task.mergeDetails.commitSha;
let mergeBase: string | undefined;
let diffSpec: Awaited<ReturnType<typeof resolveCommitDiffSpec>>;
try {
mergeBase = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
diffSpec = await resolveCommitDiffSpec(sha, rootDir);
} catch {
res.json([]);
return;
}
try {
const nameStatus = (await runGitCommand(["diff", "--name-status", `${mergeBase}..${sha}`], rootDir, 5000)).trim();
const doneFiles = [];
for (const line of nameStatus.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const statusCode = parts[0] ?? "M";
const filePath = parts[1] ?? "";
let status: "added" | "modified" | "deleted" | "renamed" = "modified";
if (statusCode.startsWith("A")) status = "added";
else if (statusCode.startsWith("D")) status = "deleted";
else if (statusCode.startsWith("R")) status = "renamed";
let diff = "";
try {
diff = await runGitCommand(["diff", `${mergeBase}..${sha}`, "--", filePath], rootDir, 5000);
} catch {
// ignore per-file diff failures
}
doneFiles.push({ path: filePath, status, diff });
}
res.json(doneFiles);
const doneFiles = await collectDoneRangeFiles(diffSpec.range, rootDir);
res.json(doneFiles.map((file) => ({ path: file.path, status: file.status, diff: file.patch })));
} catch {
res.json([]);
}