From 9bde2bba4eb49e0271dbfbd45f08b2c58a6ff410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 20:19:33 +0200 Subject: [PATCH 1/3] Detect branches that landed by squash or rebase merge Squash and rebase merges leave no merge commit, so a finished pull request's tip never becomes an ancestor of the default branch. Recover the landing by content: a mainline commit newer than the branch's merge base whose patch-id equals the branch's whole base..tip diff (squash) or its tip commit (rebase). Three streamed patch-id passes, never a spawn per branch. --- src/main/git/read/index.ts | 1 + src/main/git/read/patch-ids.ts | 38 +++++-- src/main/git/read/squash-landings.test.ts | 120 ++++++++++++++++++++++ src/main/git/read/squash-landings.ts | 62 +++++++++++ src/main/ipc/history.ts | 14 ++- src/preload/index.ts | 2 + src/shared/ipc.ts | 13 +++ src/shared/types.ts | 13 +++ 8 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 src/main/git/read/squash-landings.test.ts create mode 100644 src/main/git/read/squash-landings.ts diff --git a/src/main/git/read/index.ts b/src/main/git/read/index.ts index 33788b9..493ba8f 100644 --- a/src/main/git/read/index.ts +++ b/src/main/git/read/index.ts @@ -29,3 +29,4 @@ export { } from './merge' export { getPatchIds } from './patch-ids' export { getRemoteCloneUrl, getRemoteWebUrl, toWebUrl } from './remotes' +export { getSquashLandings } from './squash-landings' diff --git a/src/main/git/read/patch-ids.ts b/src/main/git/read/patch-ids.ts index 15b9d04..c6af74e 100644 --- a/src/main/git/read/patch-ids.ts +++ b/src/main/git/read/patch-ids.ts @@ -1,6 +1,8 @@ -// Patch-id equivalence for the Graph's backport twins: two commits with the -// same patch-id carry the same change — a cherry-picked backport. Batch API: -// the renderer sends the window's commit hashes and gets hash → patch-id back. +// Patch-id equivalence for the Graph: two commits with the same patch-id carry +// the same change — a cherry-picked backport (the twins), or a branch that +// landed as one squash commit (squash-landings.ts, via getRangePatchIds). +// Batch API: the renderer sends the window's commit hashes and gets +// hash → patch-id back. // // Unlike the rest of the read side this can't go through runGit's execFile: // the patches themselves can be huge, so `git diff-tree --stdin --root -p` is @@ -14,11 +16,33 @@ import { spawn } from 'node:child_process' import { locateGit } from '../bin' -export async function getPatchIds( +export function getPatchIds(repoPath: string, hashes: string[]): Promise> { + return streamPatchIds(repoPath, hashes) +} + +/** + * Patch-id of each branch's WHOLE change, `base..tip` as one diff — what a + * squash merge carries — keyed by tip. diff-tree's stdin form reads + * " …" lines, so naming the base as the tip's only parent + * diffs exactly that range, headed by the tip hash patch-id reports. Both + * must be full hashes: abbreviated ones on a stdin line are silently skipped. + */ +export function getRangePatchIds( + repoPath: string, + ranges: readonly { tip: string; base: string }[] +): Promise> { + return streamPatchIds( + repoPath, + ranges.map((range) => `${range.tip} ${range.base}`) + ) +} + +/** Feed diff-tree's stdin lines through `patch-id --stable`: commit → id. */ +async function streamPatchIds( repoPath: string, - hashes: string[] + stdinLines: readonly string[] ): Promise> { - if (hashes.length === 0) return {} + if (stdinLines.length === 0) return {} const bin = await locateGit() return new Promise((resolve, reject) => { const options = { cwd: repoPath, windowsHide: true } @@ -74,7 +98,7 @@ export async function getPatchIds( settle() }) - diff.stdin.write(`${hashes.join('\n')}\n`) + diff.stdin.write(`${stdinLines.join('\n')}\n`) diff.stdin.end() }) } diff --git a/src/main/git/read/squash-landings.test.ts b/src/main/git/read/squash-landings.test.ts new file mode 100644 index 0000000..3816df8 --- /dev/null +++ b/src/main/git/read/squash-landings.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { SquashCandidate } from '@shared/types' +import { getSquashLandings } from './squash-landings' + +// Integration tests: drive the real `git` binary against a throwaway repo — +// the pull-request host's merge buttons replayed locally: a two-commit branch +// squash-merged, a branch rebase-merged, and a branch that never landed. + +let repo: string +let configHome: string +let squashedTip: string +let squashCommit: string +let rebasedTip: string +let rebaseLanding: string +let openTip: string + +function git(args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Test Author', + GIT_AUTHOR_EMAIL: 'author@example.com', + GIT_COMMITTER_NAME: 'Test Author', + GIT_COMMITTER_EMAIL: 'author@example.com' + } + }).trim() +} + +function commitFile(name: string, content: string, message: string): string { + writeFileSync(join(repo, name), content) + git(['add', '.']) + git(['commit', '-q', '-m', message]) + return git(['rev-parse', 'HEAD']) +} + +const mainline = () => git(['rev-list', '--first-parent', 'main']).split('\n') +const candidate = (tip: string): SquashCandidate => ({ + tip, + base: git(['merge-base', 'main', tip]) +}) + +beforeAll(() => { + // Hermetic git: point global + system config at an empty file so the + // developer's machine config never leaks in (see read.test.ts). + configHome = mkdtempSync(join(tmpdir(), 'gitgrove-config-')) + const emptyConfig = join(configHome, 'gitconfig') + writeFileSync(emptyConfig, '') + process.env.GIT_CONFIG_GLOBAL = emptyConfig + process.env.GIT_CONFIG_SYSTEM = emptyConfig + + repo = mkdtempSync(join(tmpdir(), 'gitgrove-squash-')) + git(['init', '-q', '-b', 'main']) + git(['config', 'commit.gpgsign', 'false']) + commitFile('a.txt', 'one\n', 'initial') + + git(['checkout', '-q', '-b', 'feature']) + commitFile('f.txt', 'first\n', 'feature part 1') + squashedTip = commitFile('f.txt', 'first\nsecond\n', 'feature part 2') + + git(['checkout', '-q', '-b', 'rebased', 'main']) + rebasedTip = commitFile('r.txt', 'rebased\n', 'rebased change') + + git(['checkout', '-q', '-b', 'open', 'main']) + openTip = commitFile('o.txt', 'still open\n', 'open change') + + // The mainline moves on before anything lands, then takes both branches. + git(['checkout', '-q', 'main']) + commitFile('a.txt', 'one\ntwo\n', 'unrelated mainline work') + git(['merge', '-q', '--squash', 'feature']) + git(['commit', '-q', '-m', 'Feature (#1)']) + squashCommit = git(['rev-parse', 'HEAD']) + git(['cherry-pick', rebasedTip]) + rebaseLanding = git(['rev-parse', 'HEAD']) +}) + +afterAll(() => { + rmSync(repo, { recursive: true, force: true }) + rmSync(configHome, { recursive: true, force: true }) +}) + +describe('getSquashLandings', () => { + it('finds the squash commit carrying a whole multi-commit branch', async () => { + const landings = await getSquashLandings(repo, mainline(), [candidate(squashedTip)]) + expect(landings).toEqual({ [squashedTip]: squashCommit }) + }) + + it('finds a rebase merge by the replayed tip commit', async () => { + const landings = await getSquashLandings(repo, mainline(), [candidate(rebasedTip)]) + expect(landings).toEqual({ [rebasedTip]: rebaseLanding }) + }) + + it('leaves a branch that never landed out of the result', async () => { + const landings = await getSquashLandings(repo, mainline(), [ + candidate(openTip), + candidate(squashedTip) + ]) + expect(openTip in landings).toBe(false) + expect(landings[squashedTip]).toBe(squashCommit) + }) + + it('ignores mainline commits older than the branch base', async () => { + // Only the base itself and older are searched here: nothing can match. + const base = git(['merge-base', 'main', squashedTip]) + const landings = await getSquashLandings(repo, mainline().slice(mainline().indexOf(base)), [ + { tip: squashedTip, base } + ]) + expect(landings).toEqual({}) + }) + + it('returns an empty record for an empty request', async () => { + expect(await getSquashLandings(repo, mainline(), [])).toEqual({}) + expect(await getSquashLandings(repo, [], [candidate(squashedTip)])).toEqual({}) + }) +}) diff --git a/src/main/git/read/squash-landings.ts b/src/main/git/read/squash-landings.ts new file mode 100644 index 0000000..d72655e --- /dev/null +++ b/src/main/git/read/squash-landings.ts @@ -0,0 +1,62 @@ +// Squash and rebase merges leave no merge commit behind: the branch's changes +// land on the default branch as NEW commits, so its tip never becomes an +// ancestor of it and the Graph would draw a finished pull request as a live, +// unmerged branch. This recovers the landing by content instead of ancestry: +// a branch landed when some mainline commit newer than its merge base carries +// the same patch-id as either +// - the branch's whole `base..tip` diff (a squash merge: one commit), or +// - the tip commit itself (a rebase merge: the tip is replayed last). +// patch-id --stable ignores line numbers and whitespace, so the match +// survives the mainline moving on underneath the branch; a squash that needed +// conflict resolution changes the patch and honestly stays unmatched. +// +// The renderer computes the candidates (tips off the mainline plus their +// merge bases — all within the loaded window, see graph/squash.ts), so this +// costs three streamed patch-id passes, never a spawn per branch. + +import type { SquashCandidate } from '@shared/types' +import { getPatchIds, getRangePatchIds } from './patch-ids' + +export async function getSquashLandings( + repoPath: string, + mainline: string[], + candidates: SquashCandidate[] +): Promise> { + if (mainline.length === 0 || candidates.length === 0) return {} + const positionOf = new Map(mainline.map((hash, i) => [hash, i])) + // Only commits newer than a candidate's base can have landed it; a base + // outside the list (off the first-parent chain) searches the whole list. + const baseIndexOf = (c: SquashCandidate) => positionOf.get(c.base) ?? mainline.length + const searched = mainline.slice(0, Math.max(...candidates.map(baseIndexOf))) + const [mainlineIds, rangeIds, tipIds] = await Promise.all([ + getPatchIds(repoPath, searched), + getRangePatchIds(repoPath, candidates), + getPatchIds( + repoPath, + candidates.map((c) => c.tip) + ) + ]) + + // patch-id → mainline positions, oldest first: the FIRST landing of a + // change is the one that merged the branch (a later revert-and-reapply + // carries the same patch again). + const positionsById = new Map() + for (let i = searched.length - 1; i >= 0; i--) { + const id = mainlineIds[searched[i]] + if (!id) continue + const positions = positionsById.get(id) ?? [] + positions.push(i) + positionsById.set(id, positions) + } + + const landings: Record = {} + for (const candidate of candidates) { + const baseIndex = baseIndexOf(candidate) + const ids = [rangeIds[candidate.tip], tipIds[candidate.tip]] + const position = ids + .flatMap((id) => (id ? (positionsById.get(id) ?? []) : [])) + .find((i) => i < baseIndex) + if (position !== undefined) landings[candidate.tip] = searched[position] + } + return landings +} diff --git a/src/main/ipc/history.ts b/src/main/ipc/history.ts index 9c351a7..113c722 100644 --- a/src/main/ipc/history.ts +++ b/src/main/ipc/history.ts @@ -2,7 +2,13 @@ // history, blame, a commit's changed files, and the working/commit diffs. import { IPC } from '@shared/ipc' -import type { ChangedFile, DiffArea, GraphLogOptions, LogOptions } from '@shared/types' +import type { + ChangedFile, + DiffArea, + GraphLogOptions, + LogOptions, + SquashCandidate +} from '@shared/types' import { ipcMain } from 'electron' import { getBlame, @@ -16,6 +22,7 @@ import { getPatchIds, getRangeDiff, getRangeFiles, + getSquashLandings, getWorkingDiff } from '../git/read' @@ -27,6 +34,11 @@ export function registerHistoryHandlers(): void { ipcMain.handle(IPC.graphPatchIds, (_e, repoPath: string, hashes: string[]) => getPatchIds(repoPath, hashes) ) + ipcMain.handle( + IPC.graphSquashLandings, + (_e, repoPath: string, mainline: string[], candidates: SquashCandidate[]) => + getSquashLandings(repoPath, mainline, candidates) + ) ipcMain.handle(IPC.commitIndex, (_e, repoPath: string, hash: string) => getCommitIndex(repoPath, hash) ) diff --git a/src/preload/index.ts b/src/preload/index.ts index feb28e5..cdbf49e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -37,6 +37,8 @@ const api: GitGroveApi = { graphLog: (repoPath, options?: GraphLogOptions) => ipcRenderer.invoke(IPC.graphLog, repoPath, options), graphPatchIds: (repoPath, hashes) => ipcRenderer.invoke(IPC.graphPatchIds, repoPath, hashes), + graphSquashLandings: (repoPath, mainline, candidates) => + ipcRenderer.invoke(IPC.graphSquashLandings, repoPath, mainline, candidates), commitIndex: (repoPath, hash) => ipcRenderer.invoke(IPC.commitIndex, repoPath, hash), fileHistory: (repoPath, path, ref) => ipcRenderer.invoke(IPC.fileHistory, repoPath, path, ref), blame: (repoPath, path, ref) => ipcRenderer.invoke(IPC.blame, repoPath, path, ref), diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a931598..8d5b5fc 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -50,6 +50,7 @@ import type { RepoOpenResult, RepoOpKind, ResetMode, + SquashCandidate, StashEntry, SubmoduleInfo, UndoResult, @@ -77,6 +78,7 @@ export const IPC = { log: 'repo:log', graphLog: 'repo:graph:log', graphPatchIds: 'repo:graph:patch-ids', + graphSquashLandings: 'repo:graph:squash-landings', commitIndex: 'repo:commit:index', fileHistory: 'repo:file-history', blame: 'repo:blame', @@ -305,6 +307,17 @@ export interface GitGroveApi { * the result. */ graphPatchIds(repoPath: string, hashes: string[]): Promise> + /** + * Where each candidate branch landed on the default branch by content + * rather than ancestry — a squash or rebase merge: tip → the mainline + * commit carrying the branch's changes. `mainline` is the default branch's + * first-parent chain, newest first. Candidates that never landed drop out. + */ + graphSquashLandings( + repoPath: string, + mainline: string[], + candidates: SquashCandidate[] + ): Promise> /** How many commits sit between HEAD and `hash` (i.e. `hash`'s 0-based index * in `git log HEAD`), so the History list can page far enough to reveal it. * `-1` when `hash` isn't an ancestor of HEAD. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index c85b3fe..dd33e2a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -464,6 +464,19 @@ export interface GraphLogOptions { since?: string } +/** + * A branch tip that isn't reachable from the default branch, paired with its + * merge base there — the question "did this branch land by squash?" (see + * main/git/read/squash-landings.ts). `base` is where the branch's own changes + * start: its whole `base..tip` diff is what a squash merge would carry. + */ +export interface SquashCandidate { + /** Full hash of the branch tip. */ + tip: string + /** Full hash of the tip's merge base with the default branch. */ + base: string +} + /** * One side of an image diff, shipped ready to paint: a `data:` URL the * renderer feeds straight to an (no file:// access from the sandboxed From df625cfb2d6498511d3157ce9a2038f8680b189f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 20:19:33 +0200 Subject: [PATCH 2/3] Draw squash-merged branches as merged in the Graph A branch that landed by squash or rebase merge now reads as merged: a dashed merge connector into its landing commit, the landing wears the branch's merge ring, and it counts as merged everywhere a merge source does (Hide merged, claim priority, structure-only, upstream comparison, packing lead-out). --- .../src/components/graph/GraphView.tsx | 14 ++- .../src/components/graph/layout.test.ts | 51 ++++++++ src/renderer/src/components/graph/layout.ts | 77 +++++++++++-- src/renderer/src/components/graph/render.ts | 6 +- .../src/components/graph/squash.test.ts | 104 +++++++++++++++++ src/renderer/src/components/graph/squash.ts | 109 ++++++++++++++++++ .../src/components/graph/useSquashLandings.ts | 50 ++++++++ 7 files changed, 398 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/graph/squash.test.ts create mode 100644 src/renderer/src/components/graph/squash.ts create mode 100644 src/renderer/src/components/graph/useSquashLandings.ts diff --git a/src/renderer/src/components/graph/GraphView.tsx b/src/renderer/src/components/graph/GraphView.tsx index aacb53c..ff01b1e 100644 --- a/src/renderer/src/components/graph/GraphView.tsx +++ b/src/renderer/src/components/graph/GraphView.tsx @@ -27,6 +27,7 @@ import { releaseLineVersion, releaseVersionWithOverride } from './releases' import { computeSearchHits } from './searchGlow' import { useBackportLinks } from './useBackportLinks' import { useGraphLog } from './useGraphLog' +import { useSquashLandings } from './useSquashLandings' interface Props { repoPath: string @@ -118,6 +119,14 @@ export function GraphView({ return pins && Object.keys(pins).length > 0 ? new Map(Object.entries(pins)) : null }, [releasePins, repoPath]) + // Branches landed by squash / rebase merge: merged, though ancestry says not. + const squashLandings = useSquashLandings( + repoPath, + commits, + remotes, + branch?.defaultBranch ?? null + ) + const input = useMemo( () => ({ commits, @@ -125,9 +134,10 @@ export function GraphView({ headBranch: branch && !branch.detached ? branch.current : '', detached: branch?.detached ?? false, defaultBranch: branch?.defaultBranch ?? null, - releaseOverrides + releaseOverrides, + squashLandings }), - [commits, remotes, branch, releaseOverrides] + [commits, remotes, branch, releaseOverrides, squashLandings] ) const branches = useMemo(() => collectBranchNames(input), [input]) const layout = useMemo( diff --git a/src/renderer/src/components/graph/layout.test.ts b/src/renderer/src/components/graph/layout.test.ts index 34e865d..272b786 100644 --- a/src/renderer/src/components/graph/layout.test.ts +++ b/src/renderer/src/components/graph/layout.test.ts @@ -712,3 +712,54 @@ describe('empty branches (zero-commit refs)', () => { expect(rows[0].empty).toBe(true) }) }) + +describe('squash-merged branches', () => { + // main: a ── b ── s (squash of feature) ── c feature: f1 ── f2 (from a) + const commits = () => [ + commit('c', ['s'], 'HEAD -> main'), + commit('s', ['b'], '', 'Feature (#1)'), + commit('f2', ['f1'], 'feature'), + commit('f1', ['a']), + commit('b', ['a']), + commit('a', []) + ] + const squashLandings = new Map([['f2', 's']]) + + test('the tip gets a dashed squash edge into its landing commit', () => { + const layout = layoutGraph(input(commits(), { squashLandings })) + const squash = layout.edges.filter((e) => e.kind === 'squash') + expect(squash).toHaveLength(1) + expect(squash[0]).toMatchObject({ fromHash: 's', toHash: 'f2' }) + expect(squash[0].color).toBe(rowNamed(layout, 'feature').color) + // The landing wears the branch's merge ring, like a real merge commit. + expect(layout.nodeByHash.get('s')?.mergeColor).toBe(rowNamed(layout, 'feature').color) + }) + + test('without landings the branch reads as unmerged', () => { + const layout = layoutGraph(input(commits())) + expect(layout.edges.some((e) => e.kind === 'squash')).toBe(false) + expect(layout.nodeByHash.get('s')?.mergeColor).toBeNull() + }) + + test('hideMerged hides a squash-merged branch', () => { + const layout = layoutGraph(input(commits(), { squashLandings, hideMerged: true })) + expect(layout.rows.map((r) => r.name)).toEqual(['main']) + expect(layout.edges.some((e) => e.kind === 'squash')).toBe(false) + }) + + test('the branch compares against the mainline just before it landed', () => { + const layout = layoutGraph(input(commits(), { squashLandings })) + expect(rowNamed(layout, 'feature').upstreamHash).toBe('b') + }) + + test('structure-only keeps the landing commit', () => { + const layout = layoutGraph(input(commits(), { squashLandings, structureOnly: true })) + expect(layout.nodeByHash.has('s')).toBe(true) + expect(layout.nodeByHash.has('b')).toBe(false) + }) + + test('landings outside the window are ignored', () => { + const layout = layoutGraph(input(commits(), { squashLandings: new Map([['f2', 'gone']]) })) + expect(layout.edges.some((e) => e.kind === 'squash')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index b4c6f65..fd49835 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -109,7 +109,10 @@ export interface GraphNode { truncated: boolean } -export type GraphEdgeKind = 'line' | 'merge' | 'fork' +/** `squash`: a branch that landed on the mainline as a new commit (squash or + * rebase merge — see GraphInput.squashLandings). Routed like a merge, drawn + * dashed: merged by content, not by ancestry. */ +export type GraphEdgeKind = 'line' | 'merge' | 'fork' | 'squash' /** An edge from a child commit to one of its parents (newer → older). */ export interface GraphEdge { @@ -175,6 +178,13 @@ export interface GraphInput { * what makes a 100k-commit repo readable. */ structureOnly?: boolean + /** + * Branch tip → the mainline commit it landed as, for branches merged by + * squash or rebase (no merge commit, so ancestry alone reads them as never + * merged — see squash.ts). Such a tip counts as merged everywhere a merge + * source does, and gets a dashed `squash` edge into its landing commit. + */ + squashLandings?: ReadonlyMap | null } /** Estimated width of a row's label pill, in columns. The pill anchors at @@ -225,11 +235,35 @@ function branchNameFromMergeSubject(subject: string): string | null { return m ? m[1] : null } -/** Every non-first parent in the window: the tips merges pulled in. */ -function collectMergeSourceHashes(commits: readonly Commit[]): Set { +/** Landing commit → the branch tips squash-merged into it, keeping only + * pairs whose both ends are in the window. */ +function squashedTipsByLanding(input: GraphInput): Map { + const inWindow = new Set(input.commits.map((c) => c.hash)) + const byLanding = new Map() + for (const [tip, landing] of input.squashLandings ?? []) { + if (!inWindow.has(tip) || !inWindow.has(landing)) continue + const tips = byLanding.get(landing) ?? [] + tips.push(tip) + byLanding.set(landing, tips) + } + return byLanding +} + +/** What a commit merged in: its non-first parents, plus the tips squashed + * into it — a squash commit lands a branch as surely as a merge commit, it + * just doesn't record it. */ +function mergedIn(commit: Commit, squashedAt: ReadonlyMap): string[] { + return [...commit.parents.slice(1), ...(squashedAt.get(commit.hash) ?? [])] +} + +/** Every tip merged in within the window — by merge commit or by squash. */ +function collectMergeSourceHashes( + commits: readonly Commit[], + squashedAt: ReadonlyMap +): Set { const sources = new Set() for (const c of commits) { - for (const parent of c.parents.slice(1)) sources.add(parent) + for (const parent of mergedIn(c, squashedAt)) sources.add(parent) } return sources } @@ -290,7 +324,7 @@ function groupTips(input: GraphInput): { base: string; tips: Tip[] }[] { // merge: it claims before the checked-out branch and other unmerged tips, // or a branch forked from its middle would walk down the first parents and // steal its spine — leaving the merged branch a single orphaned commit. - const mergeSources = collectMergeSourceHashes(input.commits) + const mergeSources = collectMergeSourceHashes(input.commits, squashedTipsByLanding(input)) for (const { base } of named.filter((g) => mergeSources.has(g.tips[0].hash)).reverse()) { pin(base) } @@ -322,16 +356,17 @@ export function layoutGraph(input: GraphInput): GraphLayout { const headHash = commits.find((c) => isHeadDecoration(c.refs))?.hash ?? null - // Every non-first parent: the commits merges pulled in. Drives hideMerged + // Every merged-in tip (non-first parents, plus squash landings): drives hideMerged // (a tip that is a merge source has been merged), structureOnly (merge // 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 squashedAt = squashedTipsByLanding(input) + const mergeSources = collectMergeSourceHashes(commits, squashedAt) const mergeChildrenOf = new Map() for (const c of commits) { - for (const parent of c.parents.slice(1)) { + for (const parent of mergedIn(c, squashedAt)) { let children = mergeChildrenOf.get(parent) if (!children) { children = [] @@ -445,7 +480,12 @@ export function layoutGraph(input: GraphInput): GraphLayout { if (forkPoint) structuralHashes.add(forkPoint) } for (const c of commits) { - if (c.parents.length > 1 || c.refs !== '' || mergeSources.has(c.hash)) { + if ( + c.parents.length > 1 || + squashedAt.has(c.hash) || + c.refs !== '' || + mergeSources.has(c.hash) + ) { structuralHashes.add(c.hash) } } @@ -552,7 +592,7 @@ export function layoutGraph(input: GraphInput): GraphLayout { addStubPair(id, parentChainOf(id), columnOf.get(baseHashOf(id) ?? '')) }) for (const c of kept) { - for (const parent of c.parents.slice(1)) { + for (const parent of mergedIn(c, squashedAt)) { addStubPair(chainOf.get(parent), chainOf.get(c.hash), columnOf.get(c.hash)) } } @@ -669,6 +709,23 @@ export function layoutGraph(input: GraphInput): GraphLayout { toRow: target.row }) }) + // Squash landings: the tip → landing connector a merge commit would have + // recorded. A tip dropped from view (hideMerged, a filter) just has none. + for (const tip of squashedAt.get(node.commit.hash) ?? []) { + const target = nodeByHash.get(tip) + if (!target) continue + if (node.mergeColor === null) node.mergeColor = target.color + edges.push({ + kind: 'squash', + color: target.color, + fromHash: node.commit.hash, + toHash: tip, + fromColumn: node.column, + fromRow: node.row, + toColumn: target.column, + toRow: target.row + }) + } } // Empty lanes still show WHERE the branch will grow from: a fork connector diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index a6e8f06..dcbf16e 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -425,6 +425,9 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, const cy = nodeY(edge.fromRow) ctx.strokeStyle = branchStroke(palette, edge.color) ctx.globalAlpha = lit ? 0.8 : 0.12 + // A squash landing is routed exactly like a merge but dashed: the branch + // IS merged, by content — no ancestry line actually joins the two. + ctx.setLineDash(edge.kind === 'squash' ? [4, 3] : []) ctx.beginPath() if (py === cy) { // Same-row hop (criss-cross merge / packed-row fork): a shallow arc. @@ -444,7 +447,7 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, ctx.quadraticCurveTo(px, cy, px + r, cy) ctx.lineTo(cx - NODE_R, cy) } else { - // Merge: run along the source branch's row (its lead-out — the packing + // Merge (or squash landing): run along the source branch's row (its lead-out — the packing // reserved this stretch), then straight into the merge commit's column. const dir = Math.sign(cy - py) const r = Math.min(120, Math.abs(cx - px) * 0.66, Math.abs(cy - py) * 0.66) @@ -455,6 +458,7 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, } ctx.stroke() } + ctx.setLineDash([]) ctx.globalAlpha = 1 } diff --git a/src/renderer/src/components/graph/squash.test.ts b/src/renderer/src/components/graph/squash.test.ts new file mode 100644 index 0000000..d1f163c --- /dev/null +++ b/src/renderer/src/components/graph/squash.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test' +import type { Commit } from '@shared/types' +import { squashQuery } from './squash' + +/** Minimal commit; only hash/parents/refs matter here. */ +function commit(hash: string, parents: string[], refs = ''): Commit { + return { + hash, + shortHash: hash.slice(0, 7), + subject: `subject ${hash}`, + body: '', + authorName: 'Ada', + authorEmail: 'ada@example.com', + date: '2026-07-01T10:00:00+00:00', + relativeDate: 'now', + refs, + parents + } +} + +describe('squashQuery', () => { + test('asks about branches off the mainline, each with its merge base', () => { + // main: a ── b ── s (squash of feature) feature: f1 ── f2 (from a) + const query = squashQuery( + [ + commit('s', ['b'], 'HEAD -> main'), + commit('f2', ['f1'], 'feature'), + commit('f1', ['a']), + commit('b', ['a']), + commit('a', []) + ], + ['origin'], + 'main' + ) + expect(query).toEqual({ + mainline: ['s', 'b', 'a'], + candidates: [{ tip: 'f2', base: 'a' }] + }) + }) + + test('never asks about a branch already merged by ancestry', () => { + const query = squashQuery( + [ + commit('m', ['b', 'f1'], 'HEAD -> main'), + commit('f1', ['a'], 'feature'), + commit('b', ['a']), + commit('a', []) + ], + ['origin'], + 'main' + ) + expect(query).toBeNull() + }) + + test('follows the remote default branch when it is ahead of the local one', () => { + // The host squashed onto origin/main; local main hasn't pulled yet. + const query = squashQuery( + [ + commit('s', ['a'], 'origin/main'), + commit('f1', ['a'], 'feature'), + commit('a', [], 'HEAD -> main') + ], + ['origin'], + 'main' + ) + expect(query?.mainline).toEqual(['s', 'a']) + expect(query?.candidates).toEqual([{ tip: 'f1', base: 'a' }]) + }) + + test('takes the newest meeting point after the branch synced the mainline', () => { + // feature merged main (at b) back in, so its own changes start at b. + const query = squashQuery( + [ + commit('f2', ['f1', 'b'], 'feature'), + commit('b', ['a'], 'main'), + commit('f1', ['a']), + commit('a', []) + ], + ['origin'], + 'main' + ) + expect(query?.candidates).toEqual([{ tip: 'f2', base: 'b' }]) + }) + + test('skips tags, bare HEAD, and tips whose base is outside the window', () => { + const query = squashQuery( + [ + commit('t', ['a'], 'tag: v1'), + commit('d', ['a'], 'HEAD'), + commit('o', ['gone'], 'orphan'), + commit('a', [], 'main') + ], + ['origin'], + 'main' + ) + expect(query).toBeNull() + }) + + test('has nothing to ask without a default branch in view', () => { + const commits = [commit('f1', ['a'], 'feature'), commit('a', [])] + expect(squashQuery(commits, ['origin'], null)).toBeNull() + expect(squashQuery(commits, ['origin'], 'main')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/graph/squash.ts b/src/renderer/src/components/graph/squash.ts new file mode 100644 index 0000000..2f139e4 --- /dev/null +++ b/src/renderer/src/components/graph/squash.ts @@ -0,0 +1,109 @@ +// Squash-merge candidates: which branch tips COULD have landed on the default +// branch by content (a squash or rebase merge) rather than by ancestry — the +// question main/git/read/squash-landings.ts answers with patch-ids. Pure and +// window-scoped: everything is derived from the loaded commits, so the main +// process never spawns git per branch. useSquashLandings feeds the answer to +// the layout, which then draws those branches as merged (a dashed merge +// connector into the landing commit — see layout.ts). + +import type { Commit, SquashCandidate } from '@shared/types' +import { parseRefs } from '@/lib/format' + +export interface SquashQuery { + /** The default branch's first-parent chain in the window, newest first. */ + mainline: string[] + /** Branch tips off the default branch, each with its merge base there. */ + candidates: SquashCandidate[] +} + +/** + * The default branch's newest tip in the window — local or remote, whichever + * is newer: the pull-request host squashes on the remote, so `origin/main` + * is often ahead of a local `main` that hasn't pulled yet. + */ +function mainlineTip( + commits: readonly Commit[], + remotes: readonly string[], + defaultBranch: string +): Commit | undefined { + const names = new Set([defaultBranch, ...remotes.map((r) => `${r}/${defaultBranch}`)]) + return commits.find((c) => parseRefs(c.refs).some((ref) => !ref.isTag && names.has(ref.name))) +} + +/** Every commit reachable from `tip` within the window. */ +function ancestorsOf(tip: Commit, commitByHash: ReadonlyMap): Set { + const seen = new Set([tip.hash]) + const stack = [tip] + for (let c = stack.pop(); c; c = stack.pop()) { + for (const parent of c.parents) { + const next = commitByHash.get(parent) + if (next && !seen.has(parent)) { + seen.add(parent) + stack.push(next) + } + } + } + return seen +} + +/** + * The tip's merge base with the mainline: walk its ancestry until it meets + * commits the mainline already has, and take the newest meeting point (the + * window is date-ordered, so the lowest index). Null when the walk leaves the + * window first — the base is older than what's loaded, nothing to compare. + */ +function mergeBaseOf( + tip: Commit, + onMainline: ReadonlySet, + commitByHash: ReadonlyMap, + orderOf: ReadonlyMap +): string | null { + let best: string | null = null + const seen = new Set([tip.hash]) + const stack = [tip] + for (let c = stack.pop(); c; c = stack.pop()) { + for (const parent of c.parents) { + if (seen.has(parent)) continue + seen.add(parent) + if (onMainline.has(parent)) { + if (best === null || (orderOf.get(parent) ?? 0) < (orderOf.get(best) ?? 0)) best = parent + continue + } + const next = commitByHash.get(parent) + if (next) stack.push(next) + } + } + return best +} + +/** + * The squash-landing question for this window, or null when there is nothing + * to ask (no default branch in view, or every branch is already merged by + * ancestry). Tag-only and HEAD-only commits aren't branches and never ask. + */ +export function squashQuery( + commits: readonly Commit[], + remotes: readonly string[], + defaultBranch: string | null +): SquashQuery | null { + if (!defaultBranch) return null + const tip = mainlineTip(commits, remotes, defaultBranch) + if (!tip) return null + const commitByHash = new Map(commits.map((c) => [c.hash, c])) + const orderOf = new Map(commits.map((c, i) => [c.hash, i])) + const onMainline = ancestorsOf(tip, commitByHash) + + const mainline: string[] = [] + for (let c: Commit | undefined = tip; c; c = commitByHash.get(c.parents[0] ?? '')) { + mainline.push(c.hash) + } + + const candidates: SquashCandidate[] = [] + for (const commit of commits) { + if (onMainline.has(commit.hash)) continue + if (!parseRefs(commit.refs).some((ref) => !ref.isTag && ref.name !== 'HEAD')) continue + const base = mergeBaseOf(commit, onMainline, commitByHash, orderOf) + if (base) candidates.push({ tip: commit.hash, base }) + } + return candidates.length > 0 ? { mainline, candidates } : null +} diff --git a/src/renderer/src/components/graph/useSquashLandings.ts b/src/renderer/src/components/graph/useSquashLandings.ts new file mode 100644 index 0000000..2fe97e7 --- /dev/null +++ b/src/renderer/src/components/graph/useSquashLandings.ts @@ -0,0 +1,50 @@ +// Data feed for squash-merged branches: asks main which branch tips landed on +// the default branch by content (squash / rebase merge — see squash.ts and +// main/git/read/squash-landings.ts) and hands the layout tip → landing. +// Re-asks only when the question changes (a new mainline tip or candidate +// set), so watcher refreshes that change nothing never re-run git. The last +// answer stays up while a new one loads: no flicker back to "unmerged". +// Best-effort decoration: a failure keeps the previous answer, never an error. + +import type { Commit } from '@shared/types' +import { useEffect, useMemo, useState } from 'react' +import { squashQuery } from './squash' + +const NO_LANDINGS: ReadonlyMap = new Map() + +export function useSquashLandings( + repoPath: string, + commits: readonly Commit[], + remotes: readonly string[], + defaultBranch: string | null +): ReadonlyMap { + const query = useMemo( + () => squashQuery(commits, remotes, defaultBranch), + [commits, remotes, defaultBranch] + ) + // A string key: fresh arrays every refresh must not re-trigger the fetch. + const key = query + ? `${repoPath}|${query.mainline[0]}|${query.candidates.map((c) => `${c.tip}:${c.base}`).join(',')}` + : null + const [answer, setAnswer] = useState<{ repoPath: string; landings: ReadonlyMap }>( + { repoPath, landings: NO_LANDINGS } + ) + + // biome-ignore lint/correctness/useExhaustiveDependencies: key encodes repoPath + query + useEffect(() => { + if (!query) return + let cancelled = false + window.gitgrove + .graphSquashLandings(repoPath, query.mainline, query.candidates) + .then((record) => { + if (!cancelled) setAnswer({ repoPath, landings: new Map(Object.entries(record)) }) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [key]) + + // Never leak another repo's answer across a switch. + return query && answer.repoPath === repoPath ? answer.landings : NO_LANDINGS +} From 61b090fafb7fcba16116ae33d0aa7150cd31c3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Tue, 22 Sep 2026 20:34:12 +0200 Subject: [PATCH 3/3] Draw squash landings as plain merges; name them in the detail pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dashed connector read as pending or provisional — dashes already mean WIP, guessed branch names and backport twins here — while the branch is plainly merged. Draw the squash connector exactly like a merge instead, and name the squash where an expert looks: the landing commit's detail pane says "Squash of ", linking to that branch's changes. --- src/renderer/src/App.tsx | 39 ++++++++++---- .../src/components/graph/GraphDetailPane.tsx | 51 ++++++++++++++++++- .../src/components/graph/GraphView.tsx | 9 ++++ src/renderer/src/components/graph/layout.ts | 7 +-- src/renderer/src/components/graph/render.ts | 7 +-- .../src/components/graph/squash.test.ts | 32 +++++++++++- src/renderer/src/components/graph/squash.ts | 25 ++++++++- src/renderer/src/styles/features/graph.css | 24 +++++++++ 8 files changed, 172 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c4cd6a5..47a1ac0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -42,6 +42,7 @@ import { Toast } from './components/common/Toast' import { TooltipLayer } from './components/common/TooltipLayer' import { GraphDetailPane } from './components/graph/GraphDetailPane' import { GraphView } from './components/graph/GraphView' +import type { GraphRow } from './components/graph/layout' import { useBranchRange } from './components/graph/useBranchRange' import { CommitSummary } from './components/history/CommitSummary' import { commitMenuItems } from './components/history/commitMenuItems' @@ -400,6 +401,28 @@ export function App() { [branchRange] ) + /** Open a Graph branch's whole-branch changes view (label click, or the + * detail pane's "Squash of …" link). */ + const openGraphBranch = useCallback( + (row: GraphRow) => { + resetDetail() + clearDiff() + openRange({ + name: row.name, + base: row.baseHash, + head: row.tipHash, + upstream: row.upstreamHash + }) + }, + [resetDetail, clearDiff, openRange] + ) + + // Landing commit → the branches squashed into it (reported by GraphView): + // the Graph draws a squash like a merge, the detail pane names it. + const [squashedBranches, setSquashedBranches] = useState>( + () => new Map() + ) + /** Select a commit, dismissing any open branch-changes view. */ const selectCommitOnly = useCallback( (commit: Commit) => { @@ -1565,6 +1588,10 @@ export function App() { repoPath={repo.path} commit={selectedCommit} range={branchRange} + squashedBranches={ + selectedCommit ? (squashedBranches.get(selectedCommit.hash) ?? []) : [] + } + onSelectBranch={openGraphBranch} files={branchRange ? rangeFiles : commitFiles} filesLoading={branchRange ? rangeFilesLoading : commitFilesLoading} selectedFilePath={branchRange ? rangeSelPath : commitSelPath} @@ -1618,16 +1645,8 @@ export function App() { } }} selectedBranch={selectedGraphBranch} - onSelectBranch={(row) => { - resetDetail() - clearDiff() - openRange({ - name: row.name, - base: row.baseHash, - head: row.tipHash, - upstream: row.upstreamHash - }) - }} + onSelectBranch={openGraphBranch} + onSquashedBranchesChange={setSquashedBranches} commitMenuFor={commitMenuFor} onCheckoutBranch={checkout} onBranchAction={onBranchAction} diff --git a/src/renderer/src/components/graph/GraphDetailPane.tsx b/src/renderer/src/components/graph/GraphDetailPane.tsx index 68369e9..4466b85 100644 --- a/src/renderer/src/components/graph/GraphDetailPane.tsx +++ b/src/renderer/src/components/graph/GraphDetailPane.tsx @@ -24,6 +24,7 @@ import { coAuthorsOf } from '@/lib/coauthors' import { pluralize } from '@/lib/format' import { Icon } from '@/lib/icons' import { useSpinDelay } from '@/lib/useSpinDelay' +import type { GraphRow } from './layout' import type { BranchRange } from './useBranchRange' interface Props { @@ -32,6 +33,11 @@ interface Props { commit: Commit | null /** The open branch-changes selection; wins over `commit` when set. */ range: BranchRange | null + /** Branches squash-merged into the selected commit (the diagram draws them + * as plain merges — this is where the squash gets named). */ + squashedBranches: readonly GraphRow[] + /** Open a branch's whole-branch changes view. */ + onSelectBranch: (row: GraphRow) => void files: ChangedFile[] filesLoading: boolean selectedFilePath: string | null @@ -47,10 +53,14 @@ interface Props { function CommitHead({ commit, repoPath, + squashedBranches, + onSelectBranch, onSetupAi }: { commit: Commit repoPath: string + squashedBranches: readonly GraphRow[] + onSelectBranch: (row: GraphRow) => void onSetupAi: () => void }) { const explain = useAiExplainCommit({ repoPath, hash: commit.hash, onSetupAi }) @@ -67,6 +77,7 @@ function CommitHead({ + {/* Keyed by hash: switching commits remounts the body, resetting its collapse state and re-probing overflow (see CommitBody). */} @@ -76,6 +87,36 @@ function CommitHead({ ) } +/** "Squash of ": the landing commit carries a whole branch, merged by + * content — no ancestry joins them, so `git log` won't list the branch's + * commits here. Each name opens that branch's changes. */ +function SquashNote({ + branches, + onSelectBranch +}: { + branches: readonly GraphRow[] + onSelectBranch: (row: GraphRow) => void +}) { + if (branches.length === 0) return null + return ( +
+ + Squash of + {branches.map((row) => ( + + ))} +
+ ) +} + function RangeHead({ range }: { range: BranchRange }) { return (
@@ -108,6 +149,8 @@ export function GraphDetailPane({ repoPath, commit, range, + squashedBranches, + onSelectBranch, files, filesLoading, selectedFilePath, @@ -149,7 +192,13 @@ export function GraphDetailPane({ {range ? ( ) : commit ? ( - + ) : null}
diff --git a/src/renderer/src/components/graph/GraphView.tsx b/src/renderer/src/components/graph/GraphView.tsx index ff01b1e..b130106 100644 --- a/src/renderer/src/components/graph/GraphView.tsx +++ b/src/renderer/src/components/graph/GraphView.tsx @@ -25,6 +25,7 @@ import { linkableChains, twinHashes } from './links' import { relatedBranches } from './related' import { releaseLineVersion, releaseVersionWithOverride } from './releases' import { computeSearchHits } from './searchGlow' +import { squashedBranchesByLanding } from './squash' import { useBackportLinks } from './useBackportLinks' import { useGraphLog } from './useGraphLog' import { useSquashLandings } from './useSquashLandings' @@ -53,6 +54,9 @@ interface Props { onBranchAction: (action: BranchAction, name: string) => void /** WIP node clicked — take the user to their uncommitted changes. */ onOpenChanges: () => void + /** Landing commit → branches squashed into it, re-reported per layout — + * what the detail pane's "Squash of …" names. */ + onSquashedBranchesChange: (byLanding: ReadonlyMap) => void onError: (e: unknown) => void } @@ -72,6 +76,7 @@ export function GraphView({ onCheckoutBranch, onBranchAction, onOpenChanges, + onSquashedBranchesChange, onError }: Props) { const [branchFilter, setBranchFilter] = useState | null>(null) @@ -153,6 +158,10 @@ export function GraphView({ return linkableChains(layout.rows, input.defaultBranch, releaseOverrides) }, [hideTwins, layout, input, releaseOverrides]) const links = useBackportLinks(repoPath, layout, linkable) + useEffect( + () => onSquashedBranchesChange(squashedBranchesByLanding(layout)), + [layout, onSquashedBranchesChange] + ) const authors = useMemo((): AuthorOption[] => { const byEmail = new Map() diff --git a/src/renderer/src/components/graph/layout.ts b/src/renderer/src/components/graph/layout.ts index fd49835..5d9083d 100644 --- a/src/renderer/src/components/graph/layout.ts +++ b/src/renderer/src/components/graph/layout.ts @@ -110,8 +110,9 @@ export interface GraphNode { } /** `squash`: a branch that landed on the mainline as a new commit (squash or - * rebase merge — see GraphInput.squashLandings). Routed like a merge, drawn - * dashed: merged by content, not by ancestry. */ + * rebase merge — see GraphInput.squashLandings). Drawn exactly like a merge + * (the branch IS merged); kept distinct so the detail pane can say "Squash + * of …" — the one place the missing ancestry is worth naming. */ export type GraphEdgeKind = 'line' | 'merge' | 'fork' | 'squash' /** An edge from a child commit to one of its parents (newer → older). */ @@ -182,7 +183,7 @@ export interface GraphInput { * Branch tip → the mainline commit it landed as, for branches merged by * squash or rebase (no merge commit, so ancestry alone reads them as never * merged — see squash.ts). Such a tip counts as merged everywhere a merge - * source does, and gets a dashed `squash` edge into its landing commit. + * source does, and gets a `squash` edge into its landing commit. */ squashLandings?: ReadonlyMap | null } diff --git a/src/renderer/src/components/graph/render.ts b/src/renderer/src/components/graph/render.ts index dcbf16e..da0cac6 100644 --- a/src/renderer/src/components/graph/render.ts +++ b/src/renderer/src/components/graph/render.ts @@ -425,9 +425,6 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, const cy = nodeY(edge.fromRow) ctx.strokeStyle = branchStroke(palette, edge.color) ctx.globalAlpha = lit ? 0.8 : 0.12 - // A squash landing is routed exactly like a merge but dashed: the branch - // IS merged, by content — no ancestry line actually joins the two. - ctx.setLineDash(edge.kind === 'squash' ? [4, 3] : []) ctx.beginPath() if (py === cy) { // Same-row hop (criss-cross merge / packed-row fork): a shallow arc. @@ -447,7 +444,8 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, ctx.quadraticCurveTo(px, cy, px + r, cy) ctx.lineTo(cx - NODE_R, cy) } else { - // Merge (or squash landing): run along the source branch's row (its lead-out — the packing + // Merge — or a squash landing, drawn the same: the branch IS merged, and + // a dash would read as pending (the detail pane names the squash). Run along the source branch's row (its lead-out — the packing // reserved this stretch), then straight into the merge commit's column. const dir = Math.sign(cy - py) const r = Math.min(120, Math.abs(cx - px) * 0.66, Math.abs(cy - py) * 0.66) @@ -458,7 +456,6 @@ function drawEdges(ctx: CanvasRenderingContext2D, scene: SceneState, c0: number, } ctx.stroke() } - ctx.setLineDash([]) ctx.globalAlpha = 1 } diff --git a/src/renderer/src/components/graph/squash.test.ts b/src/renderer/src/components/graph/squash.test.ts index d1f163c..dad6fb6 100644 --- a/src/renderer/src/components/graph/squash.test.ts +++ b/src/renderer/src/components/graph/squash.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import type { Commit } from '@shared/types' -import { squashQuery } from './squash' +import { layoutGraph } from './layout' +import { squashedBranchesByLanding, squashQuery } from './squash' /** Minimal commit; only hash/parents/refs matter here. */ function commit(hash: string, parents: string[], refs = ''): Commit { @@ -102,3 +103,32 @@ describe('squashQuery', () => { expect(squashQuery(commits, ['origin'], 'main')).toBeNull() }) }) + +describe('squashedBranchesByLanding', () => { + const commits = [ + commit('s', ['b'], 'HEAD -> main'), + commit('f2', ['f1'], 'feature'), + commit('f1', ['a']), + commit('b', ['a']), + commit('a', []) + ] + const layout = (squashLandings: Map) => + layoutGraph({ + commits, + remotes: ['origin'], + headBranch: 'main', + detached: false, + defaultBranch: 'main', + squashLandings + }) + + test('names the branch squashed into each landing commit', () => { + const byLanding = squashedBranchesByLanding(layout(new Map([['f2', 's']]))) + expect([...byLanding.keys()]).toEqual(['s']) + expect(byLanding.get('s')?.map((row) => row.name)).toEqual(['feature']) + }) + + test('is empty when nothing landed by squash', () => { + expect(squashedBranchesByLanding(layout(new Map())).size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/graph/squash.ts b/src/renderer/src/components/graph/squash.ts index 2f139e4..7819a71 100644 --- a/src/renderer/src/components/graph/squash.ts +++ b/src/renderer/src/components/graph/squash.ts @@ -3,11 +3,12 @@ // question main/git/read/squash-landings.ts answers with patch-ids. Pure and // window-scoped: everything is derived from the loaded commits, so the main // process never spawns git per branch. useSquashLandings feeds the answer to -// the layout, which then draws those branches as merged (a dashed merge -// connector into the landing commit — see layout.ts). +// the layout, which then draws those branches as merged (a merge connector +// into the landing commit — see layout.ts). import type { Commit, SquashCandidate } from '@shared/types' import { parseRefs } from '@/lib/format' +import type { GraphLayout, GraphRow } from './layout' export interface SquashQuery { /** The default branch's first-parent chain in the window, newest first. */ @@ -107,3 +108,23 @@ export function squashQuery( } return candidates.length > 0 ? { mainline, candidates } : null } + +/** + * Landing commit → the branches squashed into it, as laid out. The diagram + * draws a squash exactly like a merge, so this is where the missing ancestry + * gets named: the landing commit's detail pane says "Squash of …". + */ +export function squashedBranchesByLanding(layout: GraphLayout): Map { + const rowOfChain = new Map(layout.rows.map((row) => [row.chain, row])) + const byLanding = new Map() + for (const edge of layout.edges) { + if (edge.kind !== 'squash') continue + const chain = layout.nodeByHash.get(edge.toHash)?.chain + const row = chain === undefined ? undefined : rowOfChain.get(chain) + if (!row) continue + const rows = byLanding.get(edge.fromHash) ?? [] + rows.push(row) + byLanding.set(edge.fromHash, rows) + } + return byLanding +} diff --git a/src/renderer/src/styles/features/graph.css b/src/renderer/src/styles/features/graph.css index 866b1b8..e9d9a79 100644 --- a/src/renderer/src/styles/features/graph.css +++ b/src/renderer/src/styles/features/graph.css @@ -486,6 +486,30 @@ font-size: 11.5px; color: var(--fg-faint); } +/* "Squash of " (SquashNote): the one place a squash landing is named + — the diagram draws it as a plain merge. Branch names are links into that + branch's changes view. */ +.graph-detail__squash { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 6px; + margin-top: 6px; + font-size: 11.5px; + color: var(--fg-faint); +} +.graph-detail__squash-branch { + padding: 0; + font: inherit; + font-weight: 600; + color: var(--accent); + background: none; + border: 0; + cursor: pointer; +} +.graph-detail__squash-branch:hover { + text-decoration: underline; +} /* Shared commit widgets (history/CommitSummary.tsx): History indents body and refs under its 34px avatar column; the sidebar head has no such column, so they span the pane's full width. graph.css loads after history.css, so