diff --git a/change-logs/2026/06/26/fix-unpushed-diff-merge-base.md b/change-logs/2026/06/26/fix-unpushed-diff-merge-base.md new file mode 100644 index 00000000..bb015d15 --- /dev/null +++ b/change-logs/2026/06/26/fix-unpushed-diff-merge-base.md @@ -0,0 +1 @@ +Fixed the "Unpushed" diff mode showing changes that aren't yours when the branch's upstream has diverged from HEAD (after a rebase, force-push, or when the upstream tracks a base branch that has advanced). The diff now compares HEAD against the merge-base with the upstream (three-dot semantics) instead of the upstream tip directly, so it shows only the commits HEAD added — matching the no-upstream fallback. diff --git a/src/bun/__tests__/git-branch-ops.test.ts b/src/bun/__tests__/git-branch-ops.test.ts index 2dbed2fc..9d524a0d 100644 --- a/src/bun/__tests__/git-branch-ops.test.ts +++ b/src/bun/__tests__/git-branch-ops.test.ts @@ -482,6 +482,48 @@ describe("getTaskDiff", () => { })); }); + it("diffs unpushed mode against the merge-base, not the upstream tip (three-dot)", async () => { + // Regression: when the upstream has diverged from HEAD (rebase / force-push / + // upstream tracks an advancing base), a two-dot `upstream..HEAD` diff leaks + // the upstream's independent commits in reverse. Comparing against the + // merge-base shows only what HEAD added. + const MERGE_BASE = "abcabcabcabcabcabcabcabcabcabcabcabcabca"; + queueResponse(0, "origin/dev3/feature\n"); // getUpstreamRef (@{upstream}) + queueResponse(0, `${MERGE_BASE}\n`); // git merge-base origin/dev3/feature HEAD + queueResponse(0, "M\0src/app.ts\0"); // listDiffEntries (name-status) + queueResponse(0, "1\t1\tsrc/app.ts\n"); // getDiffShortStat + queueResponse(0, "1\t1\tsrc/app.ts\0"); // getNumstat + queueResponse(0, catCheck(13)); // old ref (merge-base) batch-check + queueResponse(0, catBlob("const a = 1;\n")); // old ref batch + queueResponse(0, catCheck(13)); // new ref (HEAD) batch-check + queueResponse(0, catBlob("const a = 2;\n")); // new ref batch + + const result = await getTaskDiff("/repo", "unpushed", { + baseBranch: "main", + compareRef: "origin/main", + compareLabel: "origin/main", + }); + + // Upstream is resolved via merge-base before diffing. + const cmds = spawnMock.mock.calls.map((c) => (c[0] as string[]).join(" ")); + expect(cmds.some((c) => c.includes("merge-base origin/dev3/feature HEAD"))).toBe(true); + // Every diff endpoint is the merge-base SHA, never the bare upstream tip. + const diffCmds = cmds.filter((c) => c.includes(" diff ")); + expect(diffCmds.length).toBeGreaterThan(0); + for (const c of diffCmds) { + expect(c).toContain(MERGE_BASE); + expect(c).not.toContain("origin/dev3/feature"); + } + // Label still reports the upstream the user is comparing against. + expect(result.fallbackReason).toBeNull(); + expect(result.compareRef).toBe("origin/dev3/feature"); + expect(result.files[0]).toEqual(expect.objectContaining({ + displayPath: "src/app.ts", + oldContent: "const a = 1;\n", + newContent: "const a = 2;\n", + })); + }); + it("reports binary files in skippedFiles with both sides' sizes", async () => { queueResponse(0, "M\0image.png\0"); // name-status queueResponse(0, "-\t-\timage.png\n"); // getBranchDiffStats diff --git a/src/bun/git.ts b/src/bun/git.ts index 8dd24efd..f3f67138 100644 --- a/src/bun/git.ts +++ b/src/bun/git.ts @@ -1754,15 +1754,29 @@ export async function getTaskDiff( if (mode === "unpushed") { const upstreamRef = await getUpstreamRef(worktreePath); if (upstreamRef) { - const entries = await listDiffEntries(worktreePath, [upstreamRef, "HEAD"]); + // Compare HEAD against the merge-base with the upstream (three-dot + // semantics), not the upstream tip directly. A two-dot + // `upstream..HEAD` diff also surfaces commits the upstream gained + // independently of HEAD — after a rebase, a force-push, or when the + // upstream tracks the base branch and the base has advanced — as + // reversed hunks, i.e. "changes that aren't mine". The merge-base + // shows only what HEAD added. Mirrors the no-upstream fallback below. + const mergeBaseResult = await run( + ["git", "merge-base", upstreamRef, "HEAD"], + worktreePath, + ); + const oldRef = mergeBaseResult.ok && mergeBaseResult.stdout + ? mergeBaseResult.stdout + : upstreamRef; + const entries = await listDiffEntries(worktreePath, [oldRef, "HEAD"]); const [summary, numstat] = await Promise.all([ - getDiffShortStat(worktreePath, [upstreamRef, "HEAD"]), - getNumstat(worktreePath, [upstreamRef, "HEAD"]), + getDiffShortStat(worktreePath, [oldRef, "HEAD"]), + getNumstat(worktreePath, [oldRef, "HEAD"]), ]); const filesResult = await buildTaskDiffFiles( worktreePath, entries, - { kind: "ref", ref: upstreamRef }, + { kind: "ref", ref: oldRef }, { kind: "ref", ref: "HEAD" }, numstat, );