From 490a7de5fae410b60cf3280d4432c3d351a6db05 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:05:16 -0700 Subject: [PATCH] perf(shared): index git history merge parents lazily (#17469) --- src/shared/git-history-graph.test.ts | 67 ++++++++++++++++++++++++++++ src/shared/git-history-graph.ts | 14 +++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/shared/git-history-graph.test.ts b/src/shared/git-history-graph.test.ts index 489b4a59ff3..dce6e789937 100644 --- a/src/shared/git-history-graph.test.ts +++ b/src/shared/git-history-graph.test.ts @@ -29,6 +29,19 @@ function item( } } +function trackedItem(id: string, parentIds: string[], reads: { count: number }): GitHistoryItem { + const result = item(id, parentIds) + Object.defineProperty(result, 'id', { + configurable: true, + enumerable: true, + get: () => { + reads.count += 1 + return id + } + }) + return result +} + function branch(name: string, revision: string): GitHistoryItemRef { return { id: `refs/heads/${name}`, @@ -80,6 +93,60 @@ describe('git history graph model', () => { expect(getGitHistoryMergeParentLaneIndex(viewModels[0]!, 'B')).toBe(1) }) + it('keeps the first matching parent when history contains duplicate ids', () => { + const headRef = branch('head', 'merge') + const firstParentRef = branch('first', 'duplicate') + const laterParentRef = remote('later', 'duplicate') + const colorMap = buildDefaultGitHistoryColorMap({ + currentRef: headRef, + remoteRef: firstParentRef, + baseRef: laterParentRef + }) + const viewModels = buildGitHistoryViewModels( + [ + item('merge', ['base', 'duplicate'], [headRef]), + item('base', []), + item('duplicate', [], [firstParentRef]), + item('duplicate', [], [laterParentRef]) + ], + colorMap, + headRef + ) + + expect(viewModels[0]!.outputSwimlanes[1]!.color).toBe(GIT_HISTORY_REMOTE_REF_COLOR) + }) + + it('avoids building a parent index for linear history', () => { + const count = 64 + const reads = { count: 0 } + const historyItems = Array.from({ length: count }, (_, index) => + trackedItem(`commit-${index}`, index + 1 < count ? [`commit-${index + 1}`] : [], reads) + ) + + buildGitHistoryViewModels(historyItems) + + // A linear graph reads each item while projecting; an eager index would add one read per row. + expect(reads.count).toBeLessThanOrEqual(count * 5) + }) + + it('indexes merge parents once instead of rescanning a large history', () => { + const mergeCount = 64 + const targetId = `target-${mergeCount}` + const reads = { count: 0 } + const historyItems: GitHistoryItem[] = [] + for (let index = 0; index < mergeCount; index += 1) { + historyItems.push(trackedItem(`merge-${index}`, [`missing-${index}`, targetId], reads)) + // Reset swimlanes between merges so this measures parent lookup, not lane growth. + historyItems.push(trackedItem(`leaf-${index}`, [], reads)) + } + historyItems.push(trackedItem(targetId, [], reads)) + + buildGitHistoryViewModels(historyItems) + + // Repeated Array#find scans grow with rows; one index build stays within a linear read budget. + expect(reads.count).toBeLessThan(historyItems.length * 8) + }) + it('inserts incoming and outgoing boundary rows at the merge base', () => { const currentRef = branch('feature', 'A') const remoteRef = remote('origin/feature', 'R') diff --git a/src/shared/git-history-graph.ts b/src/shared/git-history-graph.ts index 0af09f2008d..f3c6c24ad49 100644 --- a/src/shared/git-history-graph.ts +++ b/src/shared/git-history-graph.ts @@ -103,6 +103,7 @@ export function buildGitHistoryViewModels( ): GitHistoryItemViewModel[] { let colorIndex = -1 const viewModels: GitHistoryItemViewModel[] = [] + let historyItemsById: Map | undefined for (const historyItem of historyItems) { const kind = historyItem.id === currentRef?.revision ? 'HEAD' : 'node' @@ -131,7 +132,18 @@ export function buildGitHistoryViewModels( if (index === 0) { colorIdentifier = getLabelColorIdentifier(historyItem, colorMap) } else { - const parent = historyItems.find((item) => item.id === historyItem.parentIds[index]) + // Side-parent colors need a lookup; defer indexing so linear histories pay no map cost. + if (!historyItemsById) { + historyItemsById = new Map() + for (const candidate of historyItems) { + // Array#find returns the first duplicate, so retain first-wins ordering here. + const candidateId = candidate.id + if (!historyItemsById.has(candidateId)) { + historyItemsById.set(candidateId, candidate) + } + } + } + const parent = historyItemsById.get(historyItem.parentIds[index]!) colorIdentifier = parent ? getLabelColorIdentifier(parent, colorMap) : undefined }