From daae1b37c1fe99f27f26377d1fcbacd08522cf4c Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Fri, 21 Aug 2026 10:43:20 +0200 Subject: [PATCH] fix(github): diff a PR from its base commit, not the local branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR mode read baseRefName from `gh pr view` and handed that name to the local ref resolver, so the diff was taken from merge-base(local branch, HEAD). Whenever the local base branch is behind the remote — which is most of the time — every commit the remote gained since then is folded into the PR's diff. On a real 11-file PR with a local master 88 commits behind, diffity reported 325 files and +14524/-3457 instead of +367/-22. baseRefOid is the commit GitHub itself bases the pull request on, and it is always present locally after `gh pr checkout` because it is an ancestor of the head. The base branch name is kept for the description, so the UI still reads "Changes from master". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018PkYQzbsnMihHesafWvXKs --- package-lock.json | 3 ++- package.json | 2 +- packages/cli/src/index.ts | 14 ++++++++++---- packages/github/package.json | 7 +++++-- packages/github/src/index.ts | 4 ++-- packages/github/src/pr-url.ts | 18 +++++++++++++++-- packages/github/src/types.ts | 10 ++++++++++ packages/github/tests/pr-base.test.ts | 28 +++++++++++++++++++++++++++ 8 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 packages/github/tests/pr-base.test.ts diff --git a/package-lock.json b/package-lock.json index 41a4257..07d9606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7760,7 +7760,8 @@ "version": "0.9.5", "devDependencies": { "@types/node": "^25.5.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.0" } }, "packages/parser": { diff --git a/package.json b/package.json index 8325382..c222123 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "tsx scripts/build.ts", "build:skills": "tsx scripts/build-skills.ts", - "test": "npm run test -w @diffity/git && npm run test -w @diffity/parser && npm run test -w @diffity/ui && npm run test:scripts", + "test": "npm run test -w @diffity/git && npm run test -w @diffity/github && npm run test -w @diffity/parser && npm run test -w @diffity/ui && npm run test:scripts", "link-dev": "tsx scripts/link-dev.ts", "dev": "tsx scripts/dev.ts", "release:patch": "npm run build && tsx scripts/release.ts patch && npm publish -w packages/cli", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f0e7338..9c38a82 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,11 +4,12 @@ import { createRequire } from 'node:module'; import open from 'open'; import pc from 'picocolors'; import { isGitRepo, isValidGitRef, getRepoRoot, getRepoName, normalizeRef, WORKING_TREE_REFS } from '@diffity/git'; +import type { PrBase } from '@diffity/github'; import { isGitHubPrUrl, parseGitHubPrUrl, checkoutPr, - getPrBaseRef, + getPrBase, isCliInstalled, isAuthenticated, detectRemote, @@ -100,6 +101,8 @@ range syntax (main..feature, main...feature) also work.`) } } + let prBase: PrBase | null = null; + if (refs.length === 1 && isGitHubPrUrl(refs[0])) { const parsed = parseGitHubPrUrl(refs[0]); if (!parsed) { @@ -147,15 +150,14 @@ range syntax (main..feature, main...feature) also work.`) process.exit(1); } - let baseRef: string; try { - baseRef = getPrBaseRef(parsed.number); + prBase = getPrBase(parsed.number); } catch { console.error(pc.red(`Error: Could not determine base branch for PR #${parsed.number}.`)); process.exit(1); } - refs[0] = baseRef; + refs[0] = prBase.oid; } // --base/--compare flags take precedence over positional args @@ -221,6 +223,10 @@ range syntax (main..feature, main...feature) also work.`) description = 'Unstaged changes'; } + if (prBase) { + description = `Changes from ${prBase.name}`; + } + let effectiveRef: string; if (refs.length > 0) { effectiveRef = refs.length === 2 ? `${refs[0]}..${refs[1]}` : refs[0]; diff --git a/packages/github/package.json b/packages/github/package.json index 72f2223..fa2afed 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -13,13 +13,16 @@ }, "scripts": { "build": "tsc", - "dev": "tsc --watch" + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest" }, "files": [ "dist" ], "devDependencies": { "@types/node": "^25.5.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.0" } } diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index f8fe159..39e849c 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -1,4 +1,4 @@ -export type { GitHubRemote, GitHubDetails, PrComment, PushResult, PulledThread } from './types.js'; +export type { GitHubRemote, GitHubDetails, PrBase, PrComment, PushResult, PulledThread } from './types.js'; export { detectRemote, fetchDetails, isCliInstalled, isAuthenticated } from './detection.js'; export { getFiles, getComments, getCommentCount, pushComments, pullComments } from './pr.js'; -export { isGitHubPrUrl, parseGitHubPrUrl, checkoutPr, getPrBaseRef } from './pr-url.js'; +export { isGitHubPrUrl, parseGitHubPrUrl, checkoutPr, getPrBase, parsePrBase } from './pr-url.js'; diff --git a/packages/github/src/pr-url.ts b/packages/github/src/pr-url.ts index 405fba3..f7df46e 100644 --- a/packages/github/src/pr-url.ts +++ b/packages/github/src/pr-url.ts @@ -1,4 +1,5 @@ import { exec } from './exec.js'; +import type { PrBase } from './types.js'; const PR_URL_REGEX = /(?:https?:\/\/)?github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/; @@ -23,6 +24,19 @@ export function checkoutPr(prNumber: number): void { exec(`gh pr checkout ${prNumber}`); } -export function getPrBaseRef(prNumber: number): string { - return exec(`gh pr view ${prNumber} --json baseRefName --jq '.baseRefName'`); +export function parsePrBase(json: string): PrBase { + const { baseRefName, baseRefOid } = JSON.parse(json) as { + baseRefName?: string; + baseRefOid?: string; + }; + + if (!baseRefName || !baseRefOid) { + throw new Error('Pull request response is missing baseRefName or baseRefOid'); + } + + return { name: baseRefName, oid: baseRefOid }; +} + +export function getPrBase(prNumber: number): PrBase { + return parsePrBase(exec(`gh pr view ${prNumber} --json baseRefName,baseRefOid`)); } diff --git a/packages/github/src/types.ts b/packages/github/src/types.ts index 0815258..dc77850 100644 --- a/packages/github/src/types.ts +++ b/packages/github/src/types.ts @@ -12,6 +12,16 @@ export interface GitHubDetails { commentCount: number; } +export interface PrBase { + /** The base branch's name, for display. */ + name: string; + /** + * The commit the pull request is based on. The diff must be taken from this, not from the + * local branch of the same name, which is usually behind the remote. + */ + oid: string; +} + export interface PulledThreadComment { body: string; authorName: string; diff --git a/packages/github/tests/pr-base.test.ts b/packages/github/tests/pr-base.test.ts new file mode 100644 index 0000000..a7458e8 --- /dev/null +++ b/packages/github/tests/pr-base.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { parsePrBase } from '../src/pr-url.js'; + +describe('parsePrBase', () => { + it('returns the base branch name and the commit it points at', () => { + const json = JSON.stringify({ + baseRefName: 'master', + baseRefOid: '24e3eeab4c62927d05341e3eb9347c272fa7e3af', + }); + + expect(parsePrBase(json)).toEqual({ + name: 'master', + oid: '24e3eeab4c62927d05341e3eb9347c272fa7e3af', + }); + }); + + it('rejects a response without the oid, rather than diffing against a local branch', () => { + const json = JSON.stringify({ baseRefName: 'master' }); + + expect(() => parsePrBase(json)).toThrow(/baseRefName or baseRefOid/); + }); + + it('rejects a response without the branch name', () => { + const json = JSON.stringify({ baseRefOid: '24e3eeab4c' }); + + expect(() => parsePrBase(json)).toThrow(/baseRefName or baseRefOid/); + }); +});