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`);
|
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", () => {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user