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:
@@ -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}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user