fix(FN-951): use baseCommitSha for done task diff base calculation

- Fix diff base resolution for done/merged tasks to use task.baseCommitSha as priority
- Add 3-tier fallback: baseCommitSha → merge-base with base branch → first parent
- Validate baseCommitSha is a valid ancestor before using it
- Use task.baseBranch (defaulting to main) for merge-base computation
- Update and expand test cases to cover new priority resolution logic
This commit is contained in:
gsxdsm
2026-04-04 23:21:39 -07:00
parent 818f5786bd
commit 2b857cd245
2 changed files with 215 additions and 50 deletions

View File

@@ -381,16 +381,13 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
store.addTask(createTask({ store.addTask(createTask({
column: "done", column: "done",
mergeDetails: { commitSha: "merge789" }, mergeDetails: { commitSha: "merge789" },
baseBranch: "main",
})); }));
mockExecSync.mockImplementation((command) => { mockExecSync.mockImplementation((command) => {
const cmd = String(command); const cmd = String(command);
// git rev-parse merge789^ → first parent (main branch tip at merge time) // No baseCommitSha set, so Priority 2: merge-base with base branch
if (cmd === "git rev-parse merge789^") { if (cmd.includes("git merge-base merge789 origin/main") || cmd.includes("git merge-base merge789 main")) {
return "parent123\n" as any;
}
// git merge-base merge789 parent123 → true divergence point
if (cmd === "git merge-base merge789 parent123") {
return "base456\n" as any; return "base456\n" as any;
} }
// git diff --name-status base456..merge789 → only this task's files // git diff --name-status base456..merge789 → only this task's files
@@ -420,14 +417,13 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
store.addTask(createTask({ store.addTask(createTask({
column: "done", column: "done",
mergeDetails: { commitSha: "sha_with_modifications" }, mergeDetails: { commitSha: "sha_with_modifications" },
baseBranch: "main",
})); }));
mockExecSync.mockImplementation((command) => { mockExecSync.mockImplementation((command) => {
const cmd = String(command); const cmd = String(command);
if (cmd === "git rev-parse sha_with_modifications^") { // No baseCommitSha, so Priority 2: merge-base with base branch
return "parent_abc\n" as any; if (cmd.includes("git merge-base sha_with_modifications origin/main") || cmd.includes("git merge-base sha_with_modifications main")) {
}
if (cmd === "git merge-base sha_with_modifications parent_abc") {
return "base_xyz\n" as any; return "base_xyz\n" as any;
} }
if (cmd === "git diff --name-status base_xyz..sha_with_modifications") { if (cmd === "git diff --name-status base_xyz..sha_with_modifications") {
@@ -466,19 +462,20 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
store.addTask(createTask({ store.addTask(createTask({
column: "done", column: "done",
mergeDetails: { commitSha: "merge_ff" }, mergeDetails: { commitSha: "merge_ff" },
baseBranch: "main",
})); }));
mockExecSync.mockImplementation((command) => { mockExecSync.mockImplementation((command) => {
const cmd = String(command); const cmd = String(command);
// git rev-parse merge_ff^ succeeds (fast-forward: single parent) // No baseCommitSha — Priority 1 skipped
// Priority 2: merge-base with base branch fails
if (cmd.includes("git merge-base merge_ff origin/main") || cmd.includes("git merge-base merge_ff main")) {
throw new Error("fatal: not a git repository");
}
// Priority 3: fall back to first parent
if (cmd === "git rev-parse merge_ff^") { if (cmd === "git rev-parse merge_ff^") {
return "parent_ff\n" as any; return "parent_ff\n" as any;
} }
// git merge-base fails (e.g., shallow clone)
if (cmd === "git merge-base merge_ff parent_ff") {
throw new Error("fatal: not a git repository");
}
// Fallback: git rev-parse merge_ff^ → parent_ff (called again in catch block)
// Name-status from parent to merge commit // Name-status from parent to merge commit
if (cmd === "git diff --name-status parent_ff..merge_ff") { if (cmd === "git diff --name-status parent_ff..merge_ff") {
return "M\treadme.md\n" as any; return "M\treadme.md\n" as any;
@@ -526,14 +523,13 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
store.addTask(createTask({ store.addTask(createTask({
column: "done", column: "done",
mergeDetails: { commitSha: "multi_merge" }, mergeDetails: { commitSha: "multi_merge" },
baseBranch: "main",
})); }));
mockExecSync.mockImplementation((command) => { mockExecSync.mockImplementation((command) => {
const cmd = String(command); const cmd = String(command);
if (cmd === "git rev-parse multi_merge^") { // No baseCommitSha — Priority 2: merge-base with base branch
return "multi_parent\n" as any; if (cmd.includes("git merge-base multi_merge origin/main") || cmd.includes("git merge-base multi_merge main")) {
}
if (cmd === "git merge-base multi_merge multi_parent") {
return "multi_base\n" as any; return "multi_base\n" as any;
} }
if (cmd === "git diff --name-status multi_base..multi_merge") { if (cmd === "git diff --name-status multi_base..multi_merge") {
@@ -560,4 +556,131 @@ describe("GET /api/tasks/:id/diff — done tasks", () => {
expect(response.body.files[1]).toMatchObject({ path: "src/changed.ts", status: "modified" }); expect(response.body.files[1]).toMatchObject({ path: "src/changed.ts", status: "modified" });
expect(response.body.files[2]).toMatchObject({ path: "src/removed.ts", status: "deleted" }); expect(response.body.files[2]).toMatchObject({ path: "src/removed.ts", status: "deleted" });
}); });
it("uses task.baseCommitSha as diff base when available and valid ancestor", async () => {
const store = new MockStore();
store.addTask(createTask({
column: "done",
mergeDetails: { commitSha: "merged_commit" },
baseCommitSha: "original_base",
baseBranch: "main",
}));
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// Priority 1: validate baseCommitSha is ancestor of merge commit — succeeds
if (cmd === "git merge-base --is-ancestor original_base merged_commit") {
return "" as any; // exit 0 = is ancestor
}
// Should NOT reach Priority 2 (merge-base with branch)
if (cmd.includes("git merge-base merged_commit origin/main") || cmd.includes("git merge-base merged_commit main")) {
throw new Error("Should not reach branch merge-base when baseCommitSha is valid");
}
// Diff from baseCommitSha to merge commit
if (cmd === "git diff --name-status original_base..merged_commit") {
return "A\tfeature-file.ts\n" as any;
}
if (cmd === 'git diff original_base..merged_commit -- "feature-file.ts"') {
return "diff --git a/feature-file.ts b/feature-file.ts\n+new feature\n" as any;
}
throw new Error(`Unexpected command: ${cmd}`);
});
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body.files).toHaveLength(1);
expect(response.body.files[0].path).toBe("feature-file.ts");
expect(response.body.files[0].status).toBe("added");
// Verify baseCommitSha validation was called
expect(mockExecSync).toHaveBeenCalledWith(
expect.stringContaining("git merge-base --is-ancestor original_base merged_commit"),
expect.any(Object),
);
});
it("falls through to branch merge-base when baseCommitSha is not an ancestor", async () => {
const store = new MockStore();
store.addTask(createTask({
column: "done",
mergeDetails: { commitSha: "merged_commit" },
baseCommitSha: "stale_base",
baseBranch: "develop",
}));
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// Priority 1: baseCommitSha is NOT a valid ancestor — fails
if (cmd === "git merge-base --is-ancestor stale_base merged_commit") {
throw new Error("not an ancestor");
}
// Priority 2: merge-base with base branch succeeds
if (cmd.includes("git merge-base merged_commit origin/develop") || cmd.includes("git merge-base merged_commit develop")) {
return "branch_base\n" as any;
}
// Diff from branch merge-base
if (cmd === "git diff --name-status branch_base..merged_commit") {
return "M\tsrc/app.ts\n" as any;
}
if (cmd === 'git diff branch_base..merged_commit -- "src/app.ts"') {
return "diff --git a/src/app.ts b/src/app.ts\n-old\n+new\n" as any;
}
throw new Error(`Unexpected command: ${cmd}`);
});
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body.files).toHaveLength(1);
expect(response.body.files[0].path).toBe("src/app.ts");
// Verify baseCommitSha was tried first
expect(mockExecSync).toHaveBeenCalledWith(
expect.stringContaining("git merge-base --is-ancestor stale_base merged_commit"),
expect.any(Object),
);
// Verify branch merge-base was called as fallback
expect(mockExecSync).toHaveBeenCalledWith(
expect.stringContaining("git merge-base merged_commit"),
expect.any(Object),
);
});
it("uses custom baseBranch for merge-base computation", async () => {
const store = new MockStore();
store.addTask(createTask({
column: "done",
mergeDetails: { commitSha: "custom_merge" },
// No baseCommitSha — should go straight to Priority 2
baseBranch: "release/v2",
}));
mockExecSync.mockImplementation((command) => {
const cmd = String(command);
// Priority 2: merge-base with custom base branch
if (cmd.includes("git merge-base custom_merge origin/release/v2") || cmd.includes("git merge-base custom_merge release/v2")) {
return "release_base\n" as any;
}
if (cmd === "git diff --name-status release_base..custom_merge") {
return "A\trelease-file.ts\n" as any;
}
if (cmd === 'git diff release_base..custom_merge -- "release-file.ts"') {
return "diff --git a/release-file.ts b/release-file.ts\n+release stuff\n" as any;
}
throw new Error(`Unexpected command: ${cmd}`);
});
const app = createServer(store as any);
const response = await requestDiff(app);
expect(response.status).toBe(200);
expect(response.body.files).toHaveLength(1);
expect(response.body.files[0].path).toBe("release-file.ts");
// Verify the custom base branch was used
expect(mockExecSync).toHaveBeenCalledWith(
expect.stringContaining("release/v2"),
expect.any(Object),
);
});
}); });

View File

@@ -7504,26 +7504,46 @@ Output ONLY the prompt text (no markdown, no explanations).`;
// Done tasks: compute diff from merge base to isolate only this task's changes. // Done tasks: compute diff from merge base to isolate only this task's changes.
// Using `git show SHA` on merge commits shows ALL files changed in the merge, // Using `git show SHA` on merge commits shows ALL files changed in the merge,
// including files from unrelated commits on main. Instead, find the merge base // including files from unrelated commits on main. Instead, find the true
// (where the feature branch diverged) and diff from that point. // divergence point (where the feature branch started) and diff from that point.
if (task.column === "done" && task.mergeDetails?.commitSha) { if (task.column === "done" && task.mergeDetails?.commitSha) {
const rootDir = scopedStore.getRootDir(); const rootDir = scopedStore.getRootDir();
const sha = task.mergeDetails.commitSha; const sha = task.mergeDetails.commitSha;
// Resolve the merge base: the common ancestor between this commit and its first parent. // Resolve the diff base using the same priority as resolveDiffBase():
// For a merge commit, SHA^1 is the first parent (main branch tip at merge time). // 1. task.baseCommitSha (exact starting commit of the worktree)
let mergeBase = sha; // 2. merge-base between the merge commit and the base branch
try { // 3. First parent of the merge commit as fallback
const parentSha = nodeChildProcess.execSync( let mergeBase: string | undefined;
`git rev-parse ${sha}^`, const baseBranch = task.baseBranch ?? "main";
{ cwd: rootDir, encoding: "utf-8", timeout: 5000 },
).trim(); // Priority 1: Use task.baseCommitSha if it's a valid ancestor of the merge commit
mergeBase = nodeChildProcess.execSync( if (task.baseCommitSha) {
`git merge-base ${sha} ${parentSha}`, try {
{ cwd: rootDir, encoding: "utf-8", timeout: 5000 }, nodeChildProcess.execSync(
).trim(); `git merge-base --is-ancestor ${task.baseCommitSha} ${sha}`,
} catch { { cwd: rootDir, encoding: "utf-8", timeout: 5000, stdio: "pipe" },
// Fallback: use the parent commit as base if merge-base fails );
mergeBase = task.baseCommitSha;
} catch {
// baseCommitSha is stale or not an ancestor — fall through
}
}
// Priority 2: Compute merge-base between merge commit and base branch
if (!mergeBase) {
try {
mergeBase = nodeChildProcess.execSync(
`git merge-base ${sha} origin/${baseBranch} 2>/dev/null || git merge-base ${sha} ${baseBranch}`,
{ cwd: rootDir, encoding: "utf-8", timeout: 5000 },
).trim();
} catch {
// merge-base with branch failed — fall through
}
}
// Priority 3: Fall back to first parent of the merge commit
if (!mergeBase) {
try { try {
mergeBase = nodeChildProcess.execSync( mergeBase = nodeChildProcess.execSync(
`git rev-parse ${sha}^`, `git rev-parse ${sha}^`,
@@ -7681,24 +7701,46 @@ Output ONLY the prompt text (no markdown, no explanations).`;
// Done tasks: compute diff from merge base to isolate only this task's changes. // Done tasks: compute diff from merge base to isolate only this task's changes.
// Using `git show SHA` on merge commits shows ALL files changed in the merge, // Using `git show SHA` on merge commits shows ALL files changed in the merge,
// including files from unrelated commits on main. Instead, find the merge base // including files from unrelated commits on main. Instead, find the true
// (where the feature branch diverged) and diff from that point. // divergence point (where the feature branch started) and diff from that point.
if (task.column === "done" && task.mergeDetails?.commitSha) { if (task.column === "done" && task.mergeDetails?.commitSha) {
const rootDir = scopedStore.getRootDir(); const rootDir = scopedStore.getRootDir();
const sha = task.mergeDetails.commitSha; const sha = task.mergeDetails.commitSha;
// Resolve the merge base: the common ancestor between this commit and its first parent. // Resolve the diff base using the same priority as resolveDiffBase():
let mergeBase = sha; // 1. task.baseCommitSha (exact starting commit of the worktree)
try { // 2. merge-base between the merge commit and the base branch
const parentSha = nodeChildProcess.execSync( // 3. First parent of the merge commit as fallback
`git rev-parse ${sha}^`, let mergeBase: string | undefined;
{ cwd: rootDir, encoding: "utf-8", timeout: 5000 }, const baseBranch = task.baseBranch ?? "main";
).trim();
mergeBase = nodeChildProcess.execSync( // Priority 1: Use task.baseCommitSha if it's a valid ancestor of the merge commit
`git merge-base ${sha} ${parentSha}`, if (task.baseCommitSha) {
{ cwd: rootDir, encoding: "utf-8", timeout: 5000 }, try {
).trim(); nodeChildProcess.execSync(
} catch { `git merge-base --is-ancestor ${task.baseCommitSha} ${sha}`,
{ cwd: rootDir, encoding: "utf-8", timeout: 5000, stdio: "pipe" },
);
mergeBase = task.baseCommitSha;
} catch {
// baseCommitSha is stale or not an ancestor — fall through
}
}
// Priority 2: Compute merge-base between merge commit and base branch
if (!mergeBase) {
try {
mergeBase = nodeChildProcess.execSync(
`git merge-base ${sha} origin/${baseBranch} 2>/dev/null || git merge-base ${sha} ${baseBranch}`,
{ cwd: rootDir, encoding: "utf-8", timeout: 5000 },
).trim();
} catch {
// merge-base with branch failed — fall through
}
}
// Priority 3: Fall back to first parent of the merge commit
if (!mergeBase) {
try { try {
mergeBase = nodeChildProcess.execSync( mergeBase = nodeChildProcess.execSync(
`git rev-parse ${sha}^`, `git rev-parse ${sha}^`,