mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Preserve Git history lanes through unrelated roots (#26932)
Preserve active graph lanes through unrelated root commits and share node/reference colors. Isolate real-Git fixtures from inherited Git settings and use the existing public process-host import after the current-main package migration. Co-authored-by: Matheus Breguêz <matbrgz@gmail.com>
This commit is contained in:
+110
@@ -0,0 +1,110 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildDefaultGitHistoryColorMap,
|
||||
buildGitHistoryViewModels,
|
||||
type GitHistoryItemViewModel
|
||||
} from '../../../../../../shared/git-history-graph'
|
||||
import type { GitHistoryItem } from '../../../../../../shared/git-history'
|
||||
import { GitHistoryGraphSvg } from './git-history-graph-svg'
|
||||
|
||||
function item(id: string, parentIds: string[]): GitHistoryItem {
|
||||
return { id, parentIds, subject: id, message: id }
|
||||
}
|
||||
|
||||
function renderGraph(viewModel: GitHistoryItemViewModel): string {
|
||||
return renderToStaticMarkup(<GitHistoryGraphSvg viewModel={viewModel} />)
|
||||
}
|
||||
|
||||
describe('Git history graph rendering', () => {
|
||||
it('shifts a surviving lane past a root to its left without changing the root color', () => {
|
||||
const markup = renderGraph({
|
||||
historyItem: item('root', []),
|
||||
kind: 'node',
|
||||
inputSwimlanes: [
|
||||
{ id: 'root', color: 'git-graph-lane-1' },
|
||||
{ id: 'other', color: 'git-graph-lane-2' }
|
||||
],
|
||||
outputSwimlanes: [{ id: 'other', color: 'git-graph-lane-2' }]
|
||||
})
|
||||
|
||||
expect(markup).toContain('d="M 22 0 V 6')
|
||||
expect(markup).toContain('cx="11" cy="12" r="3.5" fill="var(--git-graph-lane-1)"')
|
||||
})
|
||||
|
||||
it('draws a surviving lane straight past a root to its right', () => {
|
||||
const markup = renderGraph({
|
||||
historyItem: item('root', []),
|
||||
kind: 'node',
|
||||
inputSwimlanes: [
|
||||
{ id: 'other', color: 'git-graph-lane-1' },
|
||||
{ id: 'root', color: 'git-graph-lane-2' }
|
||||
],
|
||||
outputSwimlanes: [{ id: 'other', color: 'git-graph-lane-1' }]
|
||||
})
|
||||
|
||||
expect(markup).toContain('d="M 11 0 V 24"')
|
||||
expect(markup).toContain('cx="22" cy="12" r="3.5" fill="var(--git-graph-lane-2)"')
|
||||
})
|
||||
|
||||
it('retains a lane in the rendered production model through an unrelated root', () => {
|
||||
const rows = buildGitHistoryViewModels([
|
||||
item('merge', ['root', 'other']),
|
||||
item('root', []),
|
||||
item('other', ['base']),
|
||||
item('base', [])
|
||||
])
|
||||
expect(renderGraph(rows[1]!)).toContain('d="M 22 0 V 6')
|
||||
expect(renderGraph(rows[2]!)).toContain('d="M 11 0 V 12"')
|
||||
})
|
||||
|
||||
it('connects duplicate lanes to one root while shifting the other history', () => {
|
||||
const rows = buildGitHistoryViewModels([
|
||||
item('merge', ['step', 'other', 'root']),
|
||||
item('step', ['root']),
|
||||
item('root', []),
|
||||
item('other', [])
|
||||
])
|
||||
const markup = renderGraph(rows[2]!)
|
||||
|
||||
expect(markup).toContain('d="M 22 0 V 6')
|
||||
expect(markup).toContain('d="M 33 0 A 11 11 0 0 1 22 12 H 11"')
|
||||
expect(markup).toContain('cx="11" cy="12" r="3.5" fill="var(--git-graph-lane-1)"')
|
||||
expect(renderGraph(rows[3]!)).toContain('d="M 11 0 V 12"')
|
||||
})
|
||||
|
||||
it('keeps the normal parent edge when history is truncated before the parent', () => {
|
||||
const [row] = buildGitHistoryViewModels([item('tip', ['outside-page'])])
|
||||
expect(renderGraph(row!)).toContain('d="M 11 12 V 24"')
|
||||
})
|
||||
|
||||
it('keeps incoming and outgoing boundary colors and rings', () => {
|
||||
const currentRef = { id: 'refs/heads/feature', name: 'feature', revision: 'tip' }
|
||||
const remoteRef = {
|
||||
id: 'refs/remotes/origin/feature',
|
||||
name: 'origin/feature',
|
||||
revision: 'remote'
|
||||
}
|
||||
const rows = buildGitHistoryViewModels(
|
||||
[item('tip', ['base']), item('remote', ['base']), item('base', [])],
|
||||
buildDefaultGitHistoryColorMap({ currentRef, remoteRef }),
|
||||
currentRef,
|
||||
remoteRef,
|
||||
undefined,
|
||||
true,
|
||||
true,
|
||||
'base'
|
||||
)
|
||||
|
||||
for (const [kind, color] of [
|
||||
['incoming-changes', 'git-graph-remote-ref'],
|
||||
['outgoing-changes', 'git-graph-ref']
|
||||
]) {
|
||||
const boundary = rows.find((row) => row.kind === kind)!
|
||||
const markup = renderGraph(boundary)
|
||||
expect(markup).toContain(`fill="var(--${color})"`)
|
||||
expect(markup).toContain('stroke-dasharray="4 2"')
|
||||
expect(markup).toContain('V 24"')
|
||||
}
|
||||
})
|
||||
})
|
||||
+3
-7
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import type { GitHistoryGraphColorId } from '../../../../../../shared/git-history'
|
||||
import {
|
||||
getGitHistoryItemColor,
|
||||
getGitHistoryItemLaneIndex,
|
||||
getGitHistoryMergeParentLaneIndex,
|
||||
type GitHistoryItemViewModel
|
||||
@@ -47,12 +48,7 @@ export function GitHistoryGraphSvg({
|
||||
const outputSwimlanes = viewModel.outputSwimlanes
|
||||
const inputIndex = inputSwimlanes.findIndex((node) => node.id === historyItem.id)
|
||||
const circleIndex = getGitHistoryItemLaneIndex(viewModel)
|
||||
const circleColor =
|
||||
circleIndex < outputSwimlanes.length
|
||||
? outputSwimlanes[circleIndex]!.color
|
||||
: circleIndex < inputSwimlanes.length
|
||||
? inputSwimlanes[circleIndex]!.color
|
||||
: 'git-graph-ref'
|
||||
const circleColor = getGitHistoryItemColor(viewModel)
|
||||
|
||||
const paths: React.JSX.Element[] = []
|
||||
let outputSwimlaneIndex = 0
|
||||
@@ -72,7 +68,7 @@ export function GitHistoryGraphSvg({
|
||||
].join(' ')}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
} else if (historyItem.parentIds.length > 0) {
|
||||
outputSwimlaneIndex += 1
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -116,6 +116,42 @@ describe('git history graph model', () => {
|
||||
expect(viewModels[0]!.outputSwimlanes[1]!.color).toBe(GIT_HISTORY_REMOTE_REF_COLOR)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['A', 'V'],
|
||||
['V', 'A']
|
||||
])('keeps other lanes when parents are %s then %s', (...parents) => {
|
||||
const rootRef = branch('unrelated', 'V')
|
||||
const viewModels = buildGitHistoryViewModels(
|
||||
[item('M', parents), item('V', [], [rootRef]), item('A', ['B']), item('B', [])],
|
||||
new Map([[rootRef.id, undefined]])
|
||||
)
|
||||
const root = viewModels[1]!
|
||||
const rootLane = root.inputSwimlanes.find((lane) => lane.id === 'V')!
|
||||
|
||||
expect(root.inputSwimlanes.map((lane) => lane.id)).toEqual(parents)
|
||||
expect(root.outputSwimlanes.map((lane) => lane.id)).toEqual(['A'])
|
||||
expect(root.historyItem.references?.[0]?.color).toBe(rootLane.color)
|
||||
expect(viewModels[2]!.inputSwimlanes.map((lane) => lane.id)).toEqual(['A'])
|
||||
expect(viewModels[2]!.outputSwimlanes.map((lane) => lane.id)).toEqual(['B'])
|
||||
expect(viewModels[3]!.outputSwimlanes).toEqual([])
|
||||
})
|
||||
|
||||
it('ends every converging root lane while retaining another history', () => {
|
||||
const rootRef = branch('root', 'V')
|
||||
const rows = buildGitHistoryViewModels(
|
||||
[item('M', ['A', 'C', 'V']), item('A', ['V']), item('V', [], [rootRef]), item('C', [])],
|
||||
new Map([[rootRef.id, undefined]])
|
||||
)
|
||||
const root = rows[2]!
|
||||
const survivingLane = root.inputSwimlanes[1]!
|
||||
|
||||
expect(root.inputSwimlanes.map((lane) => lane.id)).toEqual(['V', 'C', 'V'])
|
||||
expect(root.outputSwimlanes).toEqual([survivingLane])
|
||||
expect(root.historyItem.references?.[0]?.color).toBe(root.inputSwimlanes[0]!.color)
|
||||
expect(rows[3]!.inputSwimlanes).toEqual([survivingLane])
|
||||
expect(rows[3]!.outputSwimlanes).toEqual([])
|
||||
})
|
||||
|
||||
it('avoids building a parent index for linear history', () => {
|
||||
const count = 64
|
||||
const reads = { count: 0 }
|
||||
@@ -136,7 +172,7 @@ describe('git history graph model', () => {
|
||||
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.
|
||||
// Roots retain other lanes; growing width must not add history-item reads.
|
||||
historyItems.push(trackedItem(`leaf-${index}`, [], reads))
|
||||
}
|
||||
historyItems.push(trackedItem(targetId, [], reads))
|
||||
|
||||
@@ -33,6 +33,25 @@ function cloneNode(node: GitHistoryGraphNode): GitHistoryGraphNode {
|
||||
return { id: node.id, color: node.color }
|
||||
}
|
||||
|
||||
function resolveNodeColor(
|
||||
id: string,
|
||||
hasParents: boolean,
|
||||
inputSwimlanes: GitHistoryGraphNode[],
|
||||
outputSwimlanes: GitHistoryGraphNode[]
|
||||
): GitHistoryGraphColorId {
|
||||
const inputIndex = inputSwimlanes.findIndex((node) => node.id === id)
|
||||
const circleIndex = inputIndex !== -1 ? inputIndex : inputSwimlanes.length
|
||||
// A root ends its own lane, so its output slot may now belong to another history.
|
||||
if (!hasParents && inputIndex !== -1) {
|
||||
return inputSwimlanes[inputIndex]!.color
|
||||
}
|
||||
return (
|
||||
outputSwimlanes[circleIndex]?.color ??
|
||||
inputSwimlanes[circleIndex]?.color ??
|
||||
GIT_HISTORY_REF_COLOR
|
||||
)
|
||||
}
|
||||
|
||||
function findLastIndex<T>(items: readonly T[], predicate: (item: T) => boolean): number {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
if (predicate(items[index] as T)) {
|
||||
@@ -106,25 +125,25 @@ export function buildGitHistoryViewModels(
|
||||
let historyItemsById: Map<string, GitHistoryItem> | undefined
|
||||
|
||||
for (const historyItem of historyItems) {
|
||||
const kind = historyItem.id === currentRef?.revision ? 'HEAD' : 'node'
|
||||
const historyItemId = historyItem.id
|
||||
const firstParentId = historyItem.parentIds[0]
|
||||
const kind = historyItemId === currentRef?.revision ? 'HEAD' : 'node'
|
||||
const inputSwimlanes = (viewModels.at(-1)?.outputSwimlanes ?? []).map(cloneNode)
|
||||
const outputSwimlanes: GitHistoryGraphNode[] = []
|
||||
let firstParentAdded = false
|
||||
|
||||
if (historyItem.parentIds.length > 0) {
|
||||
for (const node of inputSwimlanes) {
|
||||
if (node.id === historyItem.id) {
|
||||
if (!firstParentAdded) {
|
||||
outputSwimlanes.push({
|
||||
id: historyItem.parentIds[0]!,
|
||||
color: getLabelColorIdentifier(historyItem, colorMap) ?? node.color
|
||||
})
|
||||
firstParentAdded = true
|
||||
}
|
||||
continue
|
||||
for (const node of inputSwimlanes) {
|
||||
if (node.id === historyItemId) {
|
||||
if (!firstParentAdded && firstParentId !== undefined) {
|
||||
outputSwimlanes.push({
|
||||
id: firstParentId,
|
||||
color: getLabelColorIdentifier(historyItem, colorMap) ?? node.color
|
||||
})
|
||||
firstParentAdded = true
|
||||
}
|
||||
outputSwimlanes.push(cloneNode(node))
|
||||
continue
|
||||
}
|
||||
outputSwimlanes.push(cloneNode(node))
|
||||
}
|
||||
|
||||
for (let index = firstParentAdded ? 1 : 0; index < historyItem.parentIds.length; index += 1) {
|
||||
@@ -162,14 +181,12 @@ export function buildGitHistoryViewModels(
|
||||
.map((ref) => {
|
||||
let color = colorMap.get(ref.id)
|
||||
if (colorMap.has(ref.id) && color === undefined) {
|
||||
const inputIndex = inputSwimlanes.findIndex((node) => node.id === historyItem.id)
|
||||
const circleIndex = inputIndex !== -1 ? inputIndex : inputSwimlanes.length
|
||||
color =
|
||||
circleIndex < outputSwimlanes.length
|
||||
? outputSwimlanes[circleIndex]!.color
|
||||
: circleIndex < inputSwimlanes.length
|
||||
? inputSwimlanes[circleIndex]!.color
|
||||
: GIT_HISTORY_REF_COLOR
|
||||
color = resolveNodeColor(
|
||||
historyItemId,
|
||||
firstParentId !== undefined,
|
||||
inputSwimlanes,
|
||||
outputSwimlanes
|
||||
)
|
||||
}
|
||||
return { ...ref, color }
|
||||
})
|
||||
@@ -195,6 +212,15 @@ export function buildGitHistoryViewModels(
|
||||
return viewModels
|
||||
}
|
||||
|
||||
export function getGitHistoryItemColor(viewModel: GitHistoryItemViewModel): GitHistoryGraphColorId {
|
||||
return resolveNodeColor(
|
||||
viewModel.historyItem.id,
|
||||
viewModel.historyItem.parentIds.length > 0,
|
||||
viewModel.inputSwimlanes,
|
||||
viewModel.outputSwimlanes
|
||||
)
|
||||
}
|
||||
|
||||
export function getGitHistoryItemLaneIndex(viewModel: GitHistoryItemViewModel): number {
|
||||
const inputIndex = viewModel.inputSwimlanes.findIndex(
|
||||
(node) => node.id === viewModel.historyItem.id
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { resolveExecutableCommand } from './node-cli-command-resolution'
|
||||
import { loadGitHistoryFromExecutor } from './git-history'
|
||||
import { buildDefaultGitHistoryColorMap, buildGitHistoryViewModels } from './git-history-graph'
|
||||
|
||||
const repositories: string[] = []
|
||||
const gitBinary = resolveExecutableCommand(process.env.ORCA_TEST_GIT_BINARY ?? 'git')
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
repositories.splice(0).map((repo) => rm(repo, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
async function createRepository(inheritedEnv: NodeJS.ProcessEnv = process.env) {
|
||||
if (!gitBinary) {
|
||||
throw new Error('Git is required for the real history regression')
|
||||
}
|
||||
const repo = await mkdtemp(join(tmpdir(), 'orca-history-roots-'))
|
||||
repositories.push(repo)
|
||||
const emptyConfig = join(repo, 'empty-git-config')
|
||||
const emptyHooks = join(repo, 'empty-hooks')
|
||||
await writeFile(emptyConfig, '')
|
||||
await mkdir(emptyHooks)
|
||||
const fixtureEnv = Object.fromEntries(
|
||||
Object.entries(inheritedEnv).filter(([key]) => !key.toUpperCase().startsWith('GIT_'))
|
||||
)
|
||||
const git = async (args: string[], cwd = repo, date = '2000-01-04T00:00:00Z') => {
|
||||
const result = await runProcess({
|
||||
program: gitBinary,
|
||||
args,
|
||||
cwd,
|
||||
env: {
|
||||
...fixtureEnv,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: emptyConfig,
|
||||
GIT_AUTHOR_DATE: date,
|
||||
GIT_COMMITTER_DATE: date
|
||||
},
|
||||
timeoutMs: 10000,
|
||||
maxOutputBytes: 1024 * 1024
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut || result.outputTruncated) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
|
||||
}
|
||||
return { stdout: result.stdout }
|
||||
}
|
||||
await git(['init', '--quiet'])
|
||||
await git(['config', 'user.name', 'Graph Fixture'])
|
||||
await git(['config', 'user.email', 'graph-fixture@example.test'])
|
||||
await git(['config', 'commit.gpgsign', 'false'])
|
||||
await git(['config', 'core.hooksPath', emptyHooks])
|
||||
await git(['checkout', '-b', 'first'])
|
||||
await git(['commit', '--quiet', '--allow-empty', '-m', 'B'], repo, '2000-01-01T00:00:00Z')
|
||||
await git(['commit', '--quiet', '--allow-empty', '-m', 'A'], repo, '2000-01-02T00:00:00Z')
|
||||
await git(['checkout', '--orphan', 'unrelated'])
|
||||
await git(['commit', '--quiet', '--allow-empty', '-m', 'V'], repo, '2000-01-03T00:00:00Z')
|
||||
return { repo, git }
|
||||
}
|
||||
|
||||
describe('real unrelated Git histories', () => {
|
||||
it.each(['repository pointers', 'config overrides'] as const)(
|
||||
'ignores inherited Git %s when creating a fixture',
|
||||
async (contamination) => {
|
||||
const cleanEnv = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_'))
|
||||
)
|
||||
const caller = await createRepository(cleanEnv)
|
||||
await caller.git(['config', 'user.name', 'Caller Repository'])
|
||||
const protectedPaths = ['HEAD', 'config', 'index'].map((name) =>
|
||||
join(caller.repo, '.git', name)
|
||||
)
|
||||
const before = await Promise.all(protectedPaths.map((path) => readFile(path)))
|
||||
const refsBefore = (await caller.git(['show-ref'])).stdout
|
||||
const inheritedGitEnv =
|
||||
contamination === 'repository pointers'
|
||||
? {
|
||||
GIT_DIR: join(caller.repo, '.git'),
|
||||
GIT_WORK_TREE: caller.repo,
|
||||
GIT_COMMON_DIR: join(caller.repo, '.git'),
|
||||
GIT_INDEX_FILE: join(caller.repo, '.git', 'index'),
|
||||
GIT_OBJECT_DIRECTORY: join(caller.repo, '.git', 'objects'),
|
||||
GIT_ALTERNATE_OBJECT_DIRECTORIES: join(caller.repo, '.git', 'objects')
|
||||
}
|
||||
: {
|
||||
GIT_CONFIG: join(caller.repo, '.git', 'config'),
|
||||
GIT_CONFIG_PARAMETERS: "'user.name=Injected Author'",
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'core.bare',
|
||||
GIT_CONFIG_VALUE_0: 'true'
|
||||
}
|
||||
const fixture = await createRepository({ ...cleanEnv, ...inheritedGitEnv })
|
||||
|
||||
expect(
|
||||
await realpath((await fixture.git(['rev-parse', '--show-toplevel'])).stdout.trim())
|
||||
).toBe(await realpath(fixture.repo))
|
||||
expect((await fixture.git(['log', '-1', '--format=%an <%ae>'])).stdout.trim()).toBe(
|
||||
'Graph Fixture <graph-fixture@example.test>'
|
||||
)
|
||||
expect(await Promise.all(protectedPaths.map((path) => readFile(path)))).toEqual(before)
|
||||
expect((await caller.git(['show-ref'])).stdout).toBe(refsBefore)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps the other history when first parent is a root: %s',
|
||||
async (firstParentIsRoot) => {
|
||||
const { repo, git } = await createRepository()
|
||||
if (!firstParentIsRoot) {
|
||||
await git(['checkout', 'first'])
|
||||
}
|
||||
await git([
|
||||
'merge',
|
||||
'--quiet',
|
||||
'--no-ff',
|
||||
'--allow-unrelated-histories',
|
||||
'-m',
|
||||
'M',
|
||||
firstParentIsRoot ? 'first' : 'unrelated'
|
||||
])
|
||||
const history = await loadGitHistoryFromExecutor(git, repo, { limit: 10 })
|
||||
const rows = buildGitHistoryViewModels(
|
||||
history.items,
|
||||
buildDefaultGitHistoryColorMap(history),
|
||||
history.currentRef
|
||||
)
|
||||
const terminatingRoot = rows.find(
|
||||
(row) => row.historyItem.subject === (firstParentIsRoot ? 'B' : 'V')
|
||||
)!
|
||||
const surviving = history.items.find(
|
||||
(item) => item.subject === (firstParentIsRoot ? 'V' : 'A')
|
||||
)!
|
||||
|
||||
expect(history.items).toHaveLength(4)
|
||||
expect(history.items[0]!.parentIds).toHaveLength(2)
|
||||
expect(terminatingRoot.historyItem.parentIds).toEqual([])
|
||||
expect(terminatingRoot.inputSwimlanes).toHaveLength(2)
|
||||
expect(terminatingRoot.outputSwimlanes.map((lane) => lane.id)).toEqual([surviving.id])
|
||||
const nextRow = rows[rows.indexOf(terminatingRoot) + 1]!
|
||||
expect(nextRow.inputSwimlanes.map((lane) => lane.id)).toEqual([surviving.id])
|
||||
expect(rows.at(-1)!.outputSwimlanes).toEqual([])
|
||||
|
||||
const truncated = await loadGitHistoryFromExecutor(git, repo, { limit: 2 })
|
||||
expect(truncated.hasMore).toBe(true)
|
||||
expect(truncated.items).toHaveLength(2)
|
||||
}
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user