From 6ec0e2bfb88c61c0941861ff234c9af78aa77db0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 13:44:56 -0700 Subject: [PATCH] fix(FN-6117): correct stacked branch diff counts Filter active task diff displays to commits attributed to the current task when a branch range includes foreign task commits. This keeps task cards and Changes tabs from showing inherited branch stacks as task-owned file changes. Fusion-Task-Id: FN-6117 --- .../fn-6117-stacked-branch-diff-count.md | 5 ++ .../src/__tests__/routes-file-diffs.test.ts | 68 +++++++++++++++++++ .../routes/register-session-diff-routes.ts | 49 +++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 .changeset/fn-6117-stacked-branch-diff-count.md diff --git a/.changeset/fn-6117-stacked-branch-diff-count.md b/.changeset/fn-6117-stacked-branch-diff-count.md new file mode 100644 index 0000000000..014869d6d5 --- /dev/null +++ b/.changeset/fn-6117-stacked-branch-diff-count.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix task changed-file counts for stacked or cherry-equivalent task branches by filtering active review diffs to commits attributed to the current task. diff --git a/packages/dashboard/src/__tests__/routes-file-diffs.test.ts b/packages/dashboard/src/__tests__/routes-file-diffs.test.ts index 560cf80373..a34685edb1 100644 --- a/packages/dashboard/src/__tests__/routes-file-diffs.test.ts +++ b/packages/dashboard/src/__tests__/routes-file-diffs.test.ts @@ -109,6 +109,11 @@ async function requestFileDiffs(app: Parameters[0], taskId = "KB-651"): Promise<{ status: number; body: any }> { + const { get } = await import("../test-request.js"); + return get(app, `/api/tasks/${taskId}/diff`); +} + describe("GET /api/tasks/:id/file-diffs", () => { beforeEach(() => { vi.clearAllMocks(); @@ -266,6 +271,69 @@ describe("GET /api/tasks/:id/file-diffs", () => { rmSync(repoDir, { recursive: true, force: true }); } }, 15_000); + + it("restricts active stacked branch diffs to commits attributed to the task", async () => { + const repoDir = mkdtempSync(join(tmpdir(), "fn-active-stacked-branch-diff-")); + + try { + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["-C", repoDir, "config", "user.email", "stacked@example.com"], { stdio: "pipe" }); + execFileSync("git", ["-C", repoDir, "config", "user.name", "Stacked Test"], { stdio: "pipe" }); + + writeFileSync(join(repoDir, "README.md"), "# stacked\n"); + execFileSync("git", ["-C", repoDir, "add", "README.md"], { stdio: "pipe" }); + execFileSync("git", ["-C", repoDir, "commit", "-m", "initial"], { stdio: "pipe" }); + const forkPoint = execFileSync("git", ["-C", repoDir, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: "pipe" }).trim(); + + writeFileSync(join(repoDir, "foreign-upstream.ts"), "upstream\n"); + execFileSync("git", ["-C", repoDir, "add", "foreign-upstream.ts"], { stdio: "pipe" }); + execFileSync("git", ["-C", repoDir, "commit", "-m", "FN-6118: upstream landed"], { stdio: "pipe" }); + const recordedBase = execFileSync("git", ["-C", repoDir, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: "pipe" }).trim(); + + execFileSync("git", ["-C", repoDir, "checkout", "-b", "fusion/fn-6117", forkPoint], { stdio: "pipe" }); + writeFileSync(join(repoDir, "foreign-copy.ts"), "copied foreign work\n"); + execFileSync("git", ["-C", repoDir, "add", "foreign-copy.ts"], { stdio: "pipe" }); + execFileSync("git", ["-C", repoDir, "commit", "-m", "FN-6118: upstream landed"], { stdio: "pipe" }); + + writeFileSync(join(repoDir, "own-a.ts"), "own a\n"); + execFileSync("git", ["-C", repoDir, "add", "own-a.ts"], { stdio: "pipe" }); + execFileSync("git", ["-C", repoDir, "commit", "-m", "test(FN-6117): add own a"], { stdio: "pipe" }); + + writeFileSync(join(repoDir, "own-b.ts"), "own b\n"); + execFileSync("git", ["-C", repoDir, "add", "own-b.ts"], { stdio: "pipe" }); + execFileSync("git", ["-C", repoDir, "commit", "-m", "feat(FN-6117): add own b"], { stdio: "pipe" }); + + const rawBranchFiles = execFileSync("git", ["-C", repoDir, "diff", "--name-only", "main...fusion/fn-6117"], { + encoding: "utf-8", + stdio: "pipe", + }).trim().split("\n").filter(Boolean); + expect(rawBranchFiles).toHaveLength(3); + + const store = new RepoBackedStore(repoDir); + store.addTask(createTask({ + id: "FN-6117", + column: "in-review", + worktree: repoDir, + branch: "fusion/fn-6117", + baseBranch: "main", + baseCommitSha: recordedBase, + })); + + const app = createServer(store as any); + const diffResponse = await requestTaskDiff(app, "FN-6117"); + const fileDiffsResponse = await requestFileDiffs(app, "FN-6117"); + + expect(diffResponse.status).toBe(200); + expect(diffResponse.body.stats.filesChanged).toBe(2); + expect(diffResponse.body.files.map((file: { path: string }) => file.path).sort()).toEqual(["own-a.ts", "own-b.ts"]); + expect(diffResponse.body.files.map((file: { path: string }) => file.path)).not.toContain("foreign-copy.ts"); + + expect(fileDiffsResponse.status).toBe(200); + expect(fileDiffsResponse.body.map((file: { path: string }) => file.path).sort()).toEqual(["own-a.ts", "own-b.ts"]); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }, 15_000); }); describe("resolveDiffBase", () => { diff --git a/packages/dashboard/src/routes/register-session-diff-routes.ts b/packages/dashboard/src/routes/register-session-diff-routes.ts index 62a615f7d3..b41ec8d5ec 100644 --- a/packages/dashboard/src/routes/register-session-diff-routes.ts +++ b/packages/dashboard/src/routes/register-session-diff-routes.ts @@ -313,6 +313,41 @@ function parseNameStatusLine(line: string): { statusCode: string; path: string; return oldPath ? { statusCode, path, oldPath } : { statusCode, path }; } +async function restrictActiveCommittedFilesToOwnTask( + fileMap: Map, + input: { + taskId: string; + diffBase?: string; + worktreePath: string; + runGit: (args: string[]) => Promise; + }, +): Promise { + if (!input.diffBase || fileMap.size === 0) return; + + try { + const attribution = await filterFilesToOwnTaskCommits({ + worktreePath: input.worktreePath, + baseRef: input.diffBase, + taskId: input.taskId, + runGit: input.runGit, + }); + + if (attribution.foreignCommitCount === 0 || attribution.ownCommitShas.length === 0 || attribution.files.length === 0) { + return; + } + + const ownFiles = new Set(attribution.files); + for (const filePath of fileMap.keys()) { + if (!ownFiles.has(filePath)) { + fileMap.delete(filePath); + } + } + } catch { + // Display-only attribution. If git metadata is unavailable, preserve the + // existing broad diff rather than hiding task files. + } +} + async function collectDoneRangeFiles(range: string, rootDir: string): Promise { const nameStatus = (await runGitCommand(["diff", "--name-status", "-M", range], rootDir, 10000)).trim(); const files: AggregatedDoneTaskFile[] = []; @@ -892,6 +927,13 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute } } + await restrictActiveCommittedFilesToOwnTask(fileMap, { + taskId: task.id, + diffBase, + worktreePath: cwd, + runGit: (args) => runGitCommand(args, cwd, 10000), + }); + try { const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 10000)).trim(); for (const line of stagedOutput.split("\n").filter(Boolean)) { @@ -1131,6 +1173,13 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute } } + await restrictActiveCommittedFilesToOwnTask(fileMap, { + taskId: task.id, + diffBase, + worktreePath: cwd, + runGit: (args) => runGitCommand(args, cwd, 5000), + }); + try { const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 5000)).trim(); for (const line of stagedOutput.split("\n").filter(Boolean)) {