From 9760ad5f44639f7d6acb655aaa241a9e15f3b61c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Thu, 23 Jul 2026 12:34:24 +0200 Subject: [PATCH 1/3] Add a mergeBase read across the IPC spine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMergeBase finds the last commit two branches agreed on (null for unrelated histories), exposed to the renderer as gitgrove.mergeBase. The branch-changes view needs it: diffing a branch from its fork point over-counts once the branch merged its upstream back in, while mergeBase..tip is exactly the branch's own work — what its PR shows. --- src/main/git/read.test.ts | 58 ++++++++++++++++++++++++++++++++++++++ src/main/git/read/files.ts | 15 ++++++++++ src/main/git/read/index.ts | 2 +- src/main/ipc/history.ts | 4 +++ src/preload/index.ts | 1 + src/shared/ipc.ts | 8 ++++++ 6 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/main/git/read.test.ts b/src/main/git/read.test.ts index 909fb0d..26285af 100644 --- a/src/main/git/read.test.ts +++ b/src/main/git/read.test.ts @@ -13,8 +13,10 @@ import { getConflictSides, getFileHistory, getLog, + getMergeBase, getMergePreview, getMergeToolName, + getRangeFiles, getRemoteWebUrl, getUnpushedCommits, parseBlamePorcelain, @@ -322,6 +324,62 @@ describe('getCommitFiles', () => { }) }) +describe('getMergeBase', () => { + let branchRepo: string + let forkPoint: string + let mainTip: string + + beforeAll(() => { + // A feature branch that merged its upstream back in — the update-merge a + // long-lived branch does to stay current. The fork point no longer tells + // the truth about "what the branch changed"; the merge base does. + branchRepo = mkdtempSync(join(tmpdir(), 'gitgrove-mergebase-')) + git(['init', '-q', '-b', 'main'], branchRepo) + git(['config', 'commit.gpgsign', 'false'], branchRepo) + writeFileSync(join(branchRepo, 'base.txt'), 'base\n') + git(['add', '.'], branchRepo) + git(['commit', '-q', '-m', 'base'], branchRepo) + forkPoint = git(['rev-parse', 'HEAD'], branchRepo) + git(['checkout', '-q', '-b', 'feature'], branchRepo) + writeFileSync(join(branchRepo, 'feature.txt'), 'feature\n') + git(['add', '.'], branchRepo) + git(['commit', '-q', '-m', 'feature work'], branchRepo) + git(['checkout', '-q', 'main'], branchRepo) + writeFileSync(join(branchRepo, 'mainline.txt'), 'mainline\n') + git(['add', '.'], branchRepo) + git(['commit', '-q', '-m', 'mainline work'], branchRepo) + mainTip = git(['rev-parse', 'HEAD'], branchRepo) + git(['checkout', '-q', 'feature'], branchRepo) + git(['merge', '-q', '--no-edit', 'main'], branchRepo) + }) + + afterAll(() => { + rmSync(branchRepo, { recursive: true, force: true }) + }) + + it('finds the last commit two branches agreed on', async () => { + expect(await getMergeBase(branchRepo, 'main', 'feature')).toBe(mainTip) + }) + + it("diffs only the branch's own work from the merge base", async () => { + // From the fork point the range over-counts: it includes the mainline + // work the branch merged back in… + const fromFork = await getRangeFiles(branchRepo, forkPoint, 'feature') + expect(fromFork.map((f) => f.path)).toEqual(['feature.txt', 'mainline.txt']) + // …from the merge base it is exactly the branch's own changes — what the + // branch's pull request shows. + const base = await getMergeBase(branchRepo, 'main', 'feature') + const own = await getRangeFiles(branchRepo, base, 'feature') + expect(own.map((f) => f.path)).toEqual(['feature.txt']) + }) + + it('returns null when the commits share no history', async () => { + git(['checkout', '-q', '--orphan', 'unrelated'], branchRepo) + git(['commit', '-q', '-m', 'unrelated root'], branchRepo) + expect(await getMergeBase(branchRepo, 'main', 'unrelated')).toBeNull() + }) +}) + describe('parseRawNumstat', () => { it('marks gitlink (mode 160000) entries as submodules', () => { const raw = [ diff --git a/src/main/git/read/files.ts b/src/main/git/read/files.ts index 336be33..5b6f57c 100644 --- a/src/main/git/read/files.ts +++ b/src/main/git/read/files.ts @@ -109,6 +109,21 @@ export async function getRangeFiles( return parseRawNumstat(out) } +/** + * The merge base of two commits — the newest commit reachable from both — or + * null when they share no history. The branch-changes view diffs a branch + * from merge-base(upstream, tip) rather than its fork point: after the branch + * merges its upstream back in, the fork-point diff would count everything the + * upstream did in between as the branch's own changes, while the merge base + * is the last point both sides agreed — so `mergeBase..tip` is exactly the + * branch's own work, matching what its pull request shows. + */ +export async function getMergeBase(repoPath: string, a: string, b: string): Promise { + // Exit code 1 = no common ancestor (unrelated histories), not an error. + const out = await runGit(repoPath, ['merge-base', a, b], [1]) + return out.trim() || null +} + export async function getCommitFiles(repoPath: string, hash: string): Promise { // Diff against the first parent so merge commits report only what the merge // introduced on top of the mainline rather than the union of every parent. diff --git a/src/main/git/read/index.ts b/src/main/git/read/index.ts index f8c7c6f..33788b9 100644 --- a/src/main/git/read/index.ts +++ b/src/main/git/read/index.ts @@ -17,7 +17,7 @@ export { runGit } from './core' export { getCommitDiff, getRangeDiff, getWorkingDiff } from './diff' -export { getCommitFiles, getRangeFiles, parseRawNumstat } from './files' +export { getCommitFiles, getMergeBase, getRangeFiles, parseRawNumstat } from './files' export { getGraphLog } from './graph' export { getCommitIndex, getFileHistory, getLog, getUnpushedCommits } from './log' export { diff --git a/src/main/ipc/history.ts b/src/main/ipc/history.ts index c2a2920..9c351a7 100644 --- a/src/main/ipc/history.ts +++ b/src/main/ipc/history.ts @@ -12,6 +12,7 @@ import { getFileHistory, getGraphLog, getLog, + getMergeBase, getPatchIds, getRangeDiff, getRangeFiles, @@ -41,6 +42,9 @@ export function registerHistoryHandlers(): void { ipcMain.handle(IPC.rangeFiles, (_e, repoPath: string, base: string | null, head: string) => getRangeFiles(repoPath, base, head) ) + ipcMain.handle(IPC.mergeBase, (_e, repoPath: string, a: string, b: string) => + getMergeBase(repoPath, a, b) + ) ipcMain.handle( IPC.rangeDiff, (_e, repoPath: string, base: string | null, head: string, file: ChangedFile) => diff --git a/src/preload/index.ts b/src/preload/index.ts index c157f76..feb28e5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -42,6 +42,7 @@ const api: GitGroveApi = { blame: (repoPath, path, ref) => ipcRenderer.invoke(IPC.blame, repoPath, path, ref), commitFiles: (repoPath, hash) => ipcRenderer.invoke(IPC.commitFiles, repoPath, hash), rangeFiles: (repoPath, base, head) => ipcRenderer.invoke(IPC.rangeFiles, repoPath, base, head), + mergeBase: (repoPath, a, b) => ipcRenderer.invoke(IPC.mergeBase, repoPath, a, b), workingDiff: (repoPath, file: ChangedFile, area?: DiffArea) => ipcRenderer.invoke(IPC.workingDiff, repoPath, file, area), commitDiff: (repoPath, hash, file: ChangedFile) => diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index d56c463..a931598 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -82,6 +82,7 @@ export const IPC = { blame: 'repo:blame', commitFiles: 'repo:commit:files', rangeFiles: 'repo:range:files', + mergeBase: 'repo:merge-base', workingDiff: 'repo:diff:working', commitDiff: 'repo:diff:commit', rangeDiff: 'repo:diff:range', @@ -319,6 +320,13 @@ export interface GitGroveApi { * branch starts at a root commit; the diff runs against the empty tree. */ rangeFiles(repoPath: string, base: string | null, head: string): Promise + /** + * The merge base of two commits — the newest commit reachable from both — + * or null when they share no history. The branch-changes view diffs from + * merge-base(upstream, tip) instead of the fork point, so a branch that + * merged its upstream back in doesn't count the upstream's files as its own. + */ + mergeBase(repoPath: string, a: string, b: string): Promise workingDiff(repoPath: string, file: ChangedFile, area?: DiffArea): Promise commitDiff(repoPath: string, hash: string, file: ChangedFile): Promise /** One file's diff across `base..head` — see rangeFiles. */ From 3a5e762971b9137e937b3c0ab85656be440a2157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Thu, 23 Jul 2026 12:34:38 +0200 Subject: [PATCH 2/3] Give each graph row the upstream its changes compare against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphRow.upstreamHash names the tip of the branch this one grew from — or, when the branch's tip was already merged away, that target just before the landing merge, the way a pull request keeps comparing after it lands. Only a merge ON the fork parent's chain counts as landing, so a sync merge that pulled main into a child branch never turns the child into main's upstream. mergeChildOf becomes mergeChildrenOf (newest first) to make that distinction possible. --- .../src/components/graph/layout.test.ts | 48 +++++++++++++++++++ src/renderer/src/components/graph/layout.ts | 43 ++++++++++++++--- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/graph/layout.test.ts b/src/renderer/src/components/graph/layout.test.ts index d4f9eb7..34e865d 100644 --- a/src/renderer/src/components/graph/layout.test.ts +++ b/src/renderer/src/components/graph/layout.test.ts @@ -275,6 +275,54 @@ describe('layoutGraph', () => { expect(rowNamed(layout, 'main').baseHash).toBeNull() }) + test('an unmerged branch takes its fork parent tip as upstream', () => { + // main: a ── b feature: f1 ── f2, forked at a, not merged back. + const layout = layoutGraph( + input([ + commit('f2', ['f1'], 'feature'), + commit('b', ['a'], 'HEAD -> main'), + commit('f1', ['a']), + commit('a', []) + ]) + ) + // The branch-changes view will diff from merge-base(b, f2), so an + // update-merge from main never counts as the branch's own changes. + expect(rowNamed(layout, 'feature').upstreamHash).toBe('b') + expect(rowNamed(layout, 'main').upstreamHash).toBeNull() + }) + + test('a merged branch takes its target just before the landing merge as upstream', () => { + // main: a ── b ── m (merges f2) feature: f1 ── f2 + const layout = layoutGraph( + input([ + commit('m', ['b', 'f2'], 'HEAD -> main'), + commit('f2', ['f1'], 'feature'), + commit('f1', ['a']), + commit('b', ['a']), + commit('a', []) + ]) + ) + // Pre-merge main ('b'), the way a pull request keeps comparing after it + // lands — never 'm', whose diff against the tip would be empty. + expect(rowNamed(layout, 'feature').upstreamHash).toBe('b') + }) + + test('a sync merge pulling main into a child never becomes main upstream', () => { + // feature stays current by merging main's tip b (s = merge of b): b is a + // merge SOURCE, but main was not merged away — its upstream must not + // become the child branch. + const layout = layoutGraph( + input([ + commit('s', ['f1', 'b'], 'feature'), + commit('b', ['a'], 'HEAD -> main'), + commit('f1', ['a']), + commit('a', []) + ]) + ) + expect(rowNamed(layout, 'main').upstreamHash).toBeNull() + expect(rowNamed(layout, 'feature').upstreamHash).toBe('b') + }) + test('mainline keeps color slot 0; other branches get stable non-zero slots', () => { const build = () => layoutGraph( diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index 39eec0e..b4c6f65 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -55,6 +55,14 @@ export interface GraphRow { * or null for a chain that starts at a root commit. Feeds the * branch-changes view: everything in `base..tip` is what the branch did. */ baseHash: string | null + /** Tip of the branch this one grew from — or, when this branch's tip was + * already merged away, that branch as it stood just before the merge (the + * merge commit's first parent), the way a pull request keeps comparing + * after it lands. The branch-changes view diffs from + * merge-base(upstream, tip) instead of baseHash, so upstream work the + * branch merged back in doesn't count as its own changes. Null when the + * window shows no upstream (the mainline, root chains, empty branches). */ + upstreamHash: string | null /** True for a zero-commit branch: a ref pointing at another chain's commit * (freshly created, nothing committed yet). Its lane is one reserved slot * right of that anchor commit; tipHash and baseHash both name the anchor, @@ -316,13 +324,20 @@ export function layoutGraph(input: GraphInput): GraphLayout { // Every non-first parent: the commits merges pulled in. Drives hideMerged // (a tip that is a merge source has been merged), structureOnly (merge - // sources are structure), unnamed-chain naming, and the merge lead-out each - // chain's packing interval reserves. + // sources are structure), unnamed-chain naming, the merge lead-out each + // chain's packing interval reserves, and the upstream a merged branch + // compares against. Children are listed newest-first (commits arrive in + // date order). const mergeSources = collectMergeSourceHashes(commits) - const mergeChildOf = new Map() + const mergeChildrenOf = new Map() for (const c of commits) { for (const parent of c.parents.slice(1)) { - if (!mergeChildOf.has(parent)) mergeChildOf.set(parent, c) + let children = mergeChildrenOf.get(parent) + if (!children) { + children = [] + mergeChildrenOf.set(parent, children) + } + children.push(c) } } @@ -397,7 +412,7 @@ export function layoutGraph(input: GraphInput): GraphLayout { if (!visibleBranches && !input.hideMerged) { for (const c of commits) { if (chainOf.has(c.hash)) continue - const mergeChild = mergeChildOf.get(c.hash) + const mergeChild = mergeChildrenOf.get(c.hash)?.[0] const name = (mergeChild && branchNameFromMergeSubject(mergeChild.subject)) ?? c.shortHash claim(c.hash, { name, kind: 'unnamed', tipHash: c.hash }) } @@ -497,6 +512,21 @@ export function layoutGraph(input: GraphInput): GraphLayout { const base = baseHashOf(id) return base === null ? undefined : chainOf.get(base) } + // The ref the branch-changes view resolves its diff base against (see + // GraphRow.upstreamHash). A tip merged into its fork parent compares to + // the parent just before that landing merge; everything else compares to + // the fork parent's tip. Only a merge ON the parent chain counts as + // landing — a sync merge that pulled this tip into a child branch must + // not turn the child into this branch's upstream. + const upstreamHashOf = (id: number): string | null => { + if (chains[id].empty) return null + const parent = parentChainOf(id) + if (parent === undefined) return null + const landing = mergeChildrenOf + .get(chains[id].tipHash) + ?.find((child) => chainOf.get(child.hash) === parent) + return landing ? (landing.parents[0] ?? null) : chains[parent].tipHash + } // Release lines stack directly under the mainline, newest version first — // maintenance branches always live in the same rows, whatever else is on. const releaseRank = new Map() @@ -536,7 +566,7 @@ export function layoutGraph(input: GraphInput): GraphLayout { const forkColumn = columnOf.get(baseHashOf(id) ?? '') // An empty chain's tipHash names another chain's commit — a merge of that // commit is the OWNER's lead-out to reserve, not the empty lane's. - const mergeChild = chains[id].empty ? undefined : mergeChildOf.get(chains[id].tipHash) + const mergeChild = chains[id].empty ? undefined : mergeChildrenOf.get(chains[id].tipHash)?.[0] const mergeColumn = mergeChild ? columnOf.get(mergeChild.hash) : undefined packChains.push({ id, @@ -564,6 +594,7 @@ export function layoutGraph(input: GraphInput): GraphLayout { isHead: false, tipHash: chain.tipHash, baseHash: baseHashOf(id), + upstreamHash: upstreamHashOf(id), color: id === mainChain ? 0 : colorForName(chain.name), startColumn: span[id].start, endColumn: span[id].end, From f0dff8db9a5fcbb09aff393f42ff075acd05275b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Thu, 23 Jul 2026 12:34:38 +0200 Subject: [PATCH 3/3] Diff branch changes from the merge base, like a pull request Clicking a branch label diffed forkPoint..tip, so a branch that had merged its upstream back in counted everything the upstream did in between as its own changes (ai-extra-features showed 42 files where PR #75 shows 23). openRange now refines the base to merge-base(upstream, tip) before loading files, keeping the pane header, file list and per-file diffs on the same base; branches with no known upstream (the mainline, root chains) keep the old behavior. --- src/renderer/src/App.tsx | 7 ++++- .../src/components/graph/useBranchRange.ts | 27 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f97cbb3..c4cd6a5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1621,7 +1621,12 @@ export function App() { onSelectBranch={(row) => { resetDetail() clearDiff() - openRange({ name: row.name, base: row.baseHash, head: row.tipHash }) + openRange({ + name: row.name, + base: row.baseHash, + head: row.tipHash, + upstream: row.upstreamHash + }) }} commitMenuFor={commitMenuFor} onCheckoutBranch={checkout} diff --git a/src/renderer/src/components/graph/useBranchRange.ts b/src/renderer/src/components/graph/useBranchRange.ts index ff7f01b..5a09d4e 100644 --- a/src/renderer/src/components/graph/useBranchRange.ts +++ b/src/renderer/src/components/graph/useBranchRange.ts @@ -15,6 +15,13 @@ export interface BranchRange { head: string } +/** A branch to open the changes view for. `upstream` (GraphRow.upstreamHash) + * refines `base` to merge-base(upstream, head) before diffing, so work the + * branch merged in FROM its upstream doesn't count as its own changes. */ +export interface BranchRangeRequest extends BranchRange { + upstream: string | null +} + interface Params { getRepoPath: () => string | undefined fail: (e: unknown) => void @@ -51,21 +58,33 @@ export function useBranchRange({ getRepoPath, fail, loadRangeDiff, clearDiff }: /** Open the branch-changes view for `next` (or close it with null). */ const openRange = useCallback( - async (next: BranchRange | null) => { + async (next: BranchRangeRequest | null) => { const repoPath = getRepoPath() const id = ++req.current - setRange(next) + setRange(next && { name: next.name, base: next.base, head: next.head }) setRangeFiles([]) setRangeSelPath(null) if (!next || !repoPath) return setRangeFilesLoading(true) try { - const files = await window.gitgrove.rangeFiles(repoPath, next.base, next.head) + // The fork point over-counts once the branch merged its upstream back + // in — the merge base is the last commit both sides agreed on, so + // base..head is exactly the branch's own work (what its PR shows). + let base = next.base + if (next.upstream) { + const mergeBase = await window.gitgrove.mergeBase(repoPath, next.upstream, next.head) + if (id !== req.current) return + if (mergeBase && mergeBase !== base) { + base = mergeBase + setRange({ name: next.name, base, head: next.head }) + } + } + const files = await window.gitgrove.rangeFiles(repoPath, base, next.head) if (id !== req.current) return setRangeFiles(files) if (files.length > 0) { setRangeSelPath(files[0].path) - loadRangeDiff(next.base, next.head, files[0]) + loadRangeDiff(base, next.head, files[0]) } else { clearDiff() }