Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions change-logs/2026/06/26/fix-unpushed-diff-merge-base.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions src/bun/__tests__/git-branch-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions src/bun/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
Loading