feat(FN-867): add task-scoped diff base via baseCommitSha
- Introduce baseCommitSha on sessions for task-scoped file diffs independent of shared worktrees - Add /api/sessions/:id/files and /api/sessions/:id/file-diffs routes using baseCommitSha - Update TerminalModal to consume task-scoped file list and diff endpoints - Add regression tests for shared worktree scenario (multiple tasks in same worktree) - Expand route tests for file diffs and session files with edge cases - Clean up stale TerminalModal test helpers and simplify diff display styles - Update README and route documentation for task-scoped diffing
This commit is contained in:
@@ -134,16 +134,16 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
|
||||
it("uses merge-base to resolve diff base and returns per-file diffs", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ baseBranch: "main" }));
|
||||
store.addTask(createTask({ baseBranch: "main", baseCommitSha: "taskbase456" }));
|
||||
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
// Merge-base resolution (same strategy as session-files)
|
||||
if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
|
||||
return "mergebase456\n" as any;
|
||||
// Task-scoped baseCommitSha validation
|
||||
if (cmd === "git merge-base --is-ancestor taskbase456 HEAD") {
|
||||
return "" as any;
|
||||
}
|
||||
// Committed changes against merge-base
|
||||
if (cmd === "git diff --name-status mergebase456..HEAD") {
|
||||
// Committed changes against baseCommitSha
|
||||
if (cmd === "git diff --name-status taskbase456..HEAD") {
|
||||
return "M\tsrc/updated.ts\nA\tsrc/added.ts\n" as any;
|
||||
}
|
||||
// Working tree changes
|
||||
@@ -151,10 +151,10 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
return "" as any;
|
||||
}
|
||||
// Per-file diffs
|
||||
if (cmd === 'git diff mergebase456..HEAD -- "src/updated.ts"') {
|
||||
if (cmd === 'git diff taskbase456..HEAD -- "src/updated.ts"') {
|
||||
return "diff --git a/src/updated.ts b/src/updated.ts\n--- a/src/updated.ts\n+++ b/src/updated.ts\n+hello\n" as any;
|
||||
}
|
||||
if (cmd === 'git diff mergebase456..HEAD -- "src/added.ts"') {
|
||||
if (cmd === 'git diff taskbase456..HEAD -- "src/added.ts"') {
|
||||
return "diff --git a/src/added.ts b/src/added.ts\nnew file mode 100644\n+++ b/src/added.ts\n+added\n" as any;
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmd}`);
|
||||
@@ -178,20 +178,20 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
|
||||
it("supports rename metadata with merge-base strategy", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ baseBranch: "main" }));
|
||||
store.addTask(createTask({ baseBranch: "main", baseCommitSha: "taskbase456" }));
|
||||
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
|
||||
return "mergebase456\n" as any;
|
||||
if (cmd === "git merge-base --is-ancestor taskbase456 HEAD") {
|
||||
return "" as any;
|
||||
}
|
||||
if (cmd === "git diff --name-status mergebase456..HEAD") {
|
||||
if (cmd === "git diff --name-status taskbase456..HEAD") {
|
||||
return "R100\tsrc/old-name.ts\tsrc/new-name.ts\n" as any;
|
||||
}
|
||||
if (cmd === "git diff --name-status") {
|
||||
return "" as any;
|
||||
}
|
||||
if (cmd === 'git diff mergebase456..HEAD -- "src/new-name.ts"') {
|
||||
if (cmd === 'git diff taskbase456..HEAD -- "src/new-name.ts"') {
|
||||
return "diff --git a/src/old-name.ts b/src/new-name.ts\nsimilarity index 100%\nrename from src/old-name.ts\nrename to src/new-name.ts\n" as any;
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmd}`);
|
||||
@@ -257,24 +257,24 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
|
||||
it("includes working-tree changes alongside committed changes", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ baseBranch: "main" }));
|
||||
store.addTask(createTask({ baseBranch: "main", baseCommitSha: "taskbase456" }));
|
||||
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
|
||||
return "mergebase456\n" as any;
|
||||
if (cmd === "git merge-base --is-ancestor taskbase456 HEAD") {
|
||||
return "" as any;
|
||||
}
|
||||
if (cmd === "git diff --name-status mergebase456..HEAD") {
|
||||
if (cmd === "git diff --name-status taskbase456..HEAD") {
|
||||
return "M\tsrc/committed.ts\n" as any;
|
||||
}
|
||||
// Working tree has a different file
|
||||
if (cmd === "git diff --name-status") {
|
||||
return "A\tsrc/uncommitted.ts\n" as any;
|
||||
}
|
||||
if (cmd === 'git diff mergebase456..HEAD -- "src/committed.ts"') {
|
||||
if (cmd === 'git diff taskbase456..HEAD -- "src/committed.ts"') {
|
||||
return "diff --git a/src/committed.ts b/src/committed.ts\n+committed\n" as any;
|
||||
}
|
||||
if (cmd === 'git diff mergebase456..HEAD -- "src/uncommitted.ts"') {
|
||||
if (cmd === 'git diff taskbase456..HEAD -- "src/uncommitted.ts"') {
|
||||
return "diff --git a/src/uncommitted.ts b/src/uncommitted.ts\n+uncommitted\n" as any;
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmd}`);
|
||||
@@ -292,21 +292,21 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
|
||||
it("deduplicates files that appear in both committed and working-tree diffs", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ baseBranch: "main" }));
|
||||
store.addTask(createTask({ baseBranch: "main", baseCommitSha: "taskbase456" }));
|
||||
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
|
||||
return "mergebase456\n" as any;
|
||||
if (cmd === "git merge-base --is-ancestor taskbase456 HEAD") {
|
||||
return "" as any;
|
||||
}
|
||||
// Same file in both committed and working-tree
|
||||
if (cmd === "git diff --name-status mergebase456..HEAD") {
|
||||
if (cmd === "git diff --name-status taskbase456..HEAD") {
|
||||
return "M\tsrc/shared.ts\n" as any;
|
||||
}
|
||||
if (cmd === "git diff --name-status") {
|
||||
return "M\tsrc/shared.ts\n" as any;
|
||||
}
|
||||
if (cmd === 'git diff mergebase456..HEAD -- "src/shared.ts"') {
|
||||
if (cmd === 'git diff taskbase456..HEAD -- "src/shared.ts"') {
|
||||
return "diff --git a/src/shared.ts b/src/shared.ts\n+shared\n" as any;
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmd}`);
|
||||
@@ -346,22 +346,22 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
|
||||
it("uses the 10-second cache before recomputing", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ baseBranch: "main" }));
|
||||
store.addTask(createTask({ baseBranch: "main", baseCommitSha: "taskbase456" }));
|
||||
|
||||
let callCount = 0;
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
callCount++;
|
||||
const cmd = String(command);
|
||||
if (cmd === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
|
||||
return "mergebase456\n" as any;
|
||||
if (cmd === "git merge-base --is-ancestor taskbase456 HEAD") {
|
||||
return "" as any;
|
||||
}
|
||||
if (cmd === "git diff --name-status mergebase456..HEAD") {
|
||||
if (cmd === "git diff --name-status taskbase456..HEAD") {
|
||||
return "M\tsrc/cached.ts\n" as any;
|
||||
}
|
||||
if (cmd === "git diff --name-status") {
|
||||
return "" as any;
|
||||
}
|
||||
if (cmd === 'git diff mergebase456..HEAD -- "src/cached.ts"') {
|
||||
if (cmd === 'git diff taskbase456..HEAD -- "src/cached.ts"') {
|
||||
return "diff --git a/src/cached.ts b/src/cached.ts\n+cached\n" as any;
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmd}`);
|
||||
@@ -464,4 +464,124 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
// Both endpoints must report the same set of files
|
||||
expect(diffPaths.sort()).toEqual(sessionFiles.sort());
|
||||
});
|
||||
|
||||
// ── Regression: shared/recycled worktree produces broader file sets ─────────────────
|
||||
|
||||
it("task-scoped baseCommitSha narrows file-diffs to this task's work", async () => {
|
||||
const store = new MockStore();
|
||||
// Scenario: previous task left commits A, B, C. Current task started after and added D, E.
|
||||
// baseCommitSha = commit C (the commit where the current task started).
|
||||
// merge-base would return commit A (oldest common ancestor), which would be broader.
|
||||
// With task-scoped diffing using baseCommitSha=C, we should only see D, E.
|
||||
store.addTask(createTask({
|
||||
id: "FN-REGDIFF",
|
||||
baseCommitSha: "commitC",
|
||||
worktree: "/tmp/worktree",
|
||||
}));
|
||||
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
// baseCommitSha is valid — ancestor check passes
|
||||
if (cmd === "git merge-base --is-ancestor commitC HEAD") {
|
||||
return "" as any;
|
||||
}
|
||||
// Task-scoped diff shows only D, E
|
||||
if (cmd === "git diff --name-status commitC..HEAD") {
|
||||
return "M\tsrc/d.ts\nA\tsrc/e.ts\n" as any;
|
||||
}
|
||||
// No working tree changes
|
||||
if (cmd === "git diff --name-status") {
|
||||
return "" as any;
|
||||
}
|
||||
// Per-file diffs
|
||||
if (cmd === 'git diff commitC..HEAD -- "src/d.ts"') {
|
||||
return "diff d" as any;
|
||||
}
|
||||
if (cmd === 'git diff commitC..HEAD -- "src/e.ts"') {
|
||||
return "diff e" as any;
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmd}`);
|
||||
});
|
||||
|
||||
const response = await requestFileDiffs(store, "FN-REGDIFF");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Task-scoped: should only show files D and E, not A, B, C
|
||||
const paths = response.body.map((f: any) => f.path);
|
||||
expect(paths.sort()).toEqual(["src/d.ts", "src/e.ts"]);
|
||||
});
|
||||
|
||||
it("agrees with session-files under task-scoped base resolution", async () => {
|
||||
const store = new MockStore();
|
||||
// Both routes should produce the same file list when baseCommitSha is valid
|
||||
store.addTask(createTask({
|
||||
baseBranch: "main",
|
||||
id: "KB-AGREE-SCOPED",
|
||||
baseCommitSha: "scopedbase789",
|
||||
worktree: "/tmp/kb-agree-scoped",
|
||||
}));
|
||||
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
// Task-scoped ancestor check
|
||||
if (cmd === "git merge-base --is-ancestor scopedbase789 HEAD") {
|
||||
return "" as any;
|
||||
}
|
||||
// session-files uses --name-only
|
||||
if (cmd === "git diff --name-only scopedbase789..HEAD") {
|
||||
return "src/x.ts\nsrc/y.ts\n" as any;
|
||||
}
|
||||
if (cmd === "git diff --name-only") {
|
||||
return "src/z.ts\n" as any;
|
||||
}
|
||||
// file-diffs uses --name-status
|
||||
if (cmd === "git diff --name-status scopedbase789..HEAD") {
|
||||
return "M\tsrc/x.ts\nA\tsrc/y.ts\n" as any;
|
||||
}
|
||||
if (cmd === "git diff --name-status") {
|
||||
return "M\tsrc/z.ts\n" as any;
|
||||
}
|
||||
// Per-file diffs for file-diffs
|
||||
if (cmd.includes('git diff scopedbase789..HEAD -- "src/x.ts"')) {
|
||||
return "diff x" as any;
|
||||
}
|
||||
if (cmd.includes('git diff scopedbase789..HEAD -- "src/y.ts"')) {
|
||||
return "diff y" as any;
|
||||
}
|
||||
if (cmd.includes('git diff scopedbase789..HEAD -- "src/z.ts"')) {
|
||||
return "diff z" as any;
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmd}`);
|
||||
});
|
||||
|
||||
// Request session-files (card count)
|
||||
const sessionHandler = await import("../routes.js").then(({ createApiRoutes }) => {
|
||||
const router = createApiRoutes(store as any);
|
||||
const layer = (router as any).stack.find(
|
||||
(candidate: any) =>
|
||||
candidate.route?.path === "/tasks/:id/session-files" &&
|
||||
candidate.route?.methods?.get,
|
||||
);
|
||||
return layer.route.stack[layer.route.stack.length - 1].handle as (req: any, res: any) => Promise<void>;
|
||||
});
|
||||
|
||||
const sessionReq = { params: { id: "KB-AGREE-SCOPED" } };
|
||||
const sessionRes = createMockResponse();
|
||||
await sessionHandler(sessionReq, sessionRes);
|
||||
|
||||
expect(sessionRes.statusCode).toBe(200);
|
||||
const sessionFiles: string[] = sessionRes.body as string[];
|
||||
expect(sessionFiles).toEqual(["src/x.ts", "src/y.ts", "src/z.ts"]);
|
||||
|
||||
// Request file-diffs (modal viewer)
|
||||
const diffsHandler = await getFileDiffsHandler(store);
|
||||
const diffsRes = await requestFileDiffsWithHandler(diffsHandler, "KB-AGREE-SCOPED");
|
||||
|
||||
expect(diffsRes.status).toBe(200);
|
||||
const diffFiles = diffsRes.body as Array<{ path: string }>;
|
||||
const diffPaths = diffFiles.map((f) => f.path);
|
||||
|
||||
// Both endpoints must report the same set of files under task-scoped base
|
||||
expect(diffPaths.sort()).toEqual(sessionFiles.sort());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,10 +135,10 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ id: "FN-675-base", baseCommitSha: "abc123" }));
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
if (String(command) === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
|
||||
return "mergebase123\n" as any;
|
||||
if (String(command) === "git merge-base --is-ancestor abc123 HEAD") {
|
||||
return "" as any;
|
||||
}
|
||||
if (String(command) === "git diff --name-only mergebase123..HEAD") {
|
||||
if (String(command) === "git diff --name-only abc123..HEAD") {
|
||||
return "src/a.ts\n" as any;
|
||||
}
|
||||
if (String(command) === "git diff --name-only") {
|
||||
@@ -151,14 +151,15 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(["src/a.ts", "src/b.ts"]);
|
||||
// Should use task-scoped baseCommitSha (not merge-base)
|
||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main",
|
||||
"git merge-base --is-ancestor abc123 HEAD",
|
||||
expect.objectContaining({ cwd: "/tmp/fn-675" }),
|
||||
);
|
||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"git diff --name-only mergebase123..HEAD",
|
||||
"git diff --name-only abc123..HEAD",
|
||||
expect.objectContaining({ cwd: "/tmp/fn-675" }),
|
||||
);
|
||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||
@@ -168,10 +169,14 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores stale baseCommitSha values and uses current branch merge-base", async () => {
|
||||
it("ignores stale baseCommitSha values and falls back to merge-base", async () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ id: "FN-675-stale-base", baseCommitSha: "stale123" }));
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
// baseCommitSha is stale — is-ancestor fails
|
||||
if (String(command) === "git merge-base --is-ancestor stale123 HEAD") {
|
||||
throw new Error("not an ancestor");
|
||||
}
|
||||
if (String(command) === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
|
||||
return "mergebase123\n" as any;
|
||||
}
|
||||
@@ -188,19 +193,20 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(["packages/engine/src/executor.ts"]);
|
||||
// Should try baseCommitSha first, then fall back to merge-base
|
||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main",
|
||||
"git merge-base --is-ancestor stale123 HEAD",
|
||||
expect.objectContaining({ cwd: "/tmp/fn-675" }),
|
||||
);
|
||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"git diff --name-only mergebase123..HEAD",
|
||||
"git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main",
|
||||
expect.objectContaining({ cwd: "/tmp/fn-675" }),
|
||||
);
|
||||
expect(mockExecSync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"git diff --name-only",
|
||||
"git diff --name-only mergebase123..HEAD",
|
||||
expect.objectContaining({ cwd: "/tmp/fn-675" }),
|
||||
);
|
||||
});
|
||||
@@ -307,10 +313,10 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
const store = new MockStore();
|
||||
store.addTask(createTask({ id: "FN-675-cache", baseCommitSha: "cachebase" }));
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
if (String(command) === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
|
||||
return "mergebase123\n" as any;
|
||||
if (String(command) === "git merge-base --is-ancestor cachebase HEAD") {
|
||||
return "" as any;
|
||||
}
|
||||
if (String(command) === "git diff --name-only mergebase123..HEAD") {
|
||||
if (String(command) === "git diff --name-only cachebase..HEAD") {
|
||||
return "cached/file.ts\n" as any;
|
||||
}
|
||||
if (String(command) === "git diff --name-only") {
|
||||
@@ -333,4 +339,42 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
expect(third.body).toEqual(["cached/file.ts"]);
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
// ── Regression: shared/recycled worktree produces broader file sets ─────────────────
|
||||
|
||||
it("task-scoped baseCommitSha narrows changed-files to this task's work", async () => {
|
||||
const store = new MockStore();
|
||||
// Scenario: previous task left commits A, B, C. Current task started after and added D, E.
|
||||
// baseCommitSha = commit C (the commit where the current task started)
|
||||
// merge-base would return commit A (oldest common ancestor), which would be broader.
|
||||
// With task-scoped diffing using baseCommitSha=C, we should only see D, E.
|
||||
store.addTask(createTask({
|
||||
id: "FN-REGRESSION",
|
||||
baseCommitSha: "commitC",
|
||||
worktree: "/tmp/worktree",
|
||||
}));
|
||||
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
// baseCommitSha is valid — ancestor check passes
|
||||
if (cmd === "git merge-base --is-ancestor commitC HEAD") {
|
||||
return "" as any;
|
||||
}
|
||||
// Task-scoped diff shows only D, E
|
||||
if (cmd === "git diff --name-only commitC..HEAD") {
|
||||
return "src/d.ts\nsrc/e.ts\n" as any;
|
||||
}
|
||||
// No working tree changes
|
||||
if (cmd === "git diff --name-only") {
|
||||
return "" as any;
|
||||
}
|
||||
throw new Error(`Unexpected command: ${cmd}`);
|
||||
});
|
||||
|
||||
const response = await requestSessionFiles(store, "FN-REGRESSION");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Task-scoped: should only show files D and E, not the A, B, C
|
||||
expect(response.body).toEqual(["src/d.ts", "src/e.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user