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:
gsxdsm
2026-04-04 06:43:45 -07:00
parent c1f5e4bb4f
commit 4f80480a44
4 changed files with 267 additions and 88 deletions

View File

@@ -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());
});
});

View File

@@ -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"]);
});
});