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:
5
.changeset/fn-6117-stacked-branch-diff-count.md
Normal file
5
.changeset/fn-6117-stacked-branch-diff-count.md
Normal 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.
|
||||
@@ -109,6 +109,11 @@ async function requestFileDiffs(app: Parameters<typeof import("../test-request.j
|
||||
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", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -313,6 +313,41 @@ function parseNameStatusLine(line: string): { statusCode: string; path: string;
|
||||
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[]> {
|
||||
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)) {
|
||||
|
||||
Reference in New Issue
Block a user