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
This commit is contained in:
gsxdsm
2026-06-09 13:44:56 -07:00
parent 0b7549a534
commit 6ec0e2bfb8
3 changed files with 122 additions and 0 deletions

View File

@@ -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.

View File

@@ -109,6 +109,11 @@ async function requestFileDiffs(app: Parameters<typeof import("../test-request.j
return get(app, `/api/tasks/${taskId}/file-diffs`); return get(app, `/api/tasks/${taskId}/file-diffs`);
} }
async function requestTaskDiff(app: Parameters<typeof import("../test-request.js").get>[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", () => { describe("GET /api/tasks/:id/file-diffs", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -266,6 +271,69 @@ describe("GET /api/tasks/:id/file-diffs", () => {
rmSync(repoDir, { recursive: true, force: true }); rmSync(repoDir, { recursive: true, force: true });
} }
}, 15_000); }, 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", () => { describe("resolveDiffBase", () => {

View File

@@ -313,6 +313,41 @@ function parseNameStatusLine(line: string): { statusCode: string; path: string;
return oldPath ? { statusCode, path, oldPath } : { statusCode, path }; return oldPath ? { statusCode, path, oldPath } : { statusCode, path };
} }
async function restrictActiveCommittedFilesToOwnTask<T>(
fileMap: Map<string, T>,
input: {
taskId: string;
diffBase?: string;
worktreePath: string;
runGit: (args: string[]) => Promise<string>;
},
): Promise<void> {
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<AggregatedDoneTaskFile[]> { async function collectDoneRangeFiles(range: string, rootDir: string): Promise<AggregatedDoneTaskFile[]> {
const nameStatus = (await runGitCommand(["diff", "--name-status", "-M", range], rootDir, 10000)).trim(); const nameStatus = (await runGitCommand(["diff", "--name-status", "-M", range], rootDir, 10000)).trim();
const files: AggregatedDoneTaskFile[] = []; 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 { try {
const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 10000)).trim(); const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 10000)).trim();
for (const line of stagedOutput.split("\n").filter(Boolean)) { 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 { try {
const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 5000)).trim(); const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status", "-M"], cwd, 5000)).trim();
for (const line of stagedOutput.split("\n").filter(Boolean)) { for (const line of stagedOutput.split("\n").filter(Boolean)) {