feat(git): commit graph in history view and cross-spawn everywhere

- render a VSCode-style commit graph in the source control history:
  lane-assignment algorithm, SVG strip with colored rails, merge and
  branch curves, commit dots, and branch/tag badges per commit
- /commits returns parents and ref decorations across all branches
  (--branches --remotes --tags --topo-order) using unit-separator
  fields so pipes in commit subjects can't break parsing
- collect stats via a single --shortstat pass instead of one
  `git show --stat` call per commit; raise the history limit to 50
- replace child_process spawn with cross-spawn in all runtimes,
  routes, and services: it resolves .cmd shims and PATHEXT on
  Windows (fixing taskmaster's npx invocations) and delegates to
  native spawn elsewhere, removing the per-file win32 ternaries
- unit tests for the log parser and lane assignment
This commit is contained in:
Haileyesus
2026-07-06 16:09:39 +03:00
parent 09a21b3754
commit 6daae87443
22 changed files with 525 additions and 65 deletions

View File

@@ -1,7 +1,8 @@
import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types';
export const DEFAULT_BRANCH = 'main';
export const RECENT_COMMITS_LIMIT = 10;
// High enough for the commit graph to show meaningful branch structure.
export const RECENT_COMMITS_LIMIT = 50;
export const FILE_STATUS_GROUPS: GitStatusGroupEntry[] = [
{ key: 'modified', status: 'M' },

View File

@@ -51,6 +51,10 @@ export type GitCommitSummary = {
date: string;
message: string;
stats?: string;
/** Parent commit hashes — drives the History view commit graph. */
parents?: string[];
/** Ref decorations, e.g. "HEAD -> main", "origin/main", "tag: v1.0". */
refs?: string[];
};
export type GitDiffMap = Record<string, string>;

View File

@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { computeCommitGraph } from './commitGraph';
test('linear history stays in a single lane', () => {
const rows = computeCommitGraph([
{ hash: 'c3', parents: ['c2'] },
{ hash: 'c2', parents: ['c1'] },
{ hash: 'c1', parents: [] },
]);
assert.deepEqual(rows.map((row) => row.nodeLane), [0, 0, 0]);
assert.deepEqual(rows.map((row) => row.laneCount), [1, 1, 1]);
// Tip has no line from above; root has no line below.
assert.equal(rows[0].hasTopContinuation, false);
assert.equal(rows[0].hasParentContinuation, true);
assert.equal(rows[2].hasParentContinuation, false);
assert.deepEqual(rows[1].passThrough, []);
});
test('merge commit opens a second lane that joins back at the fork point', () => {
// main: m2 --- m1 --- base
// feature: \- f1 -/ (m2 merges f1; both branch from base)
const rows = computeCommitGraph([
{ hash: 'm2', parents: ['m1', 'f1'] },
{ hash: 'm1', parents: ['base'] },
{ hash: 'f1', parents: ['base'] },
{ hash: 'base', parents: [] },
]);
// Merge commit sits in lane 0 and branches a line out to lane 1.
assert.equal(rows[0].nodeLane, 0);
assert.deepEqual(rows[0].outbound, [1]);
assert.equal(rows[0].laneCount, 2);
// m1 passes lane 1 (feature line) straight through.
assert.equal(rows[1].nodeLane, 0);
assert.deepEqual(rows[1].passThrough, [1]);
// f1 is the feature commit in lane 1; lane 0 (main) passes through.
assert.equal(rows[2].nodeLane, 1);
assert.deepEqual(rows[2].passThrough, [0]);
// base: both lanes converge — lane 1 merges into the node in lane 0.
assert.equal(rows[3].nodeLane, 0);
assert.deepEqual(rows[3].inbound, [1]);
assert.deepEqual(rows[3].bottomLanes, []);
});
test('independent branch tips get their own lanes', () => {
// Two branch tips pointing at the same parent (e.g. main and a feature).
const rows = computeCommitGraph([
{ hash: 'tipA', parents: ['base'] },
{ hash: 'tipB', parents: ['base'] },
{ hash: 'base', parents: [] },
]);
assert.equal(rows[0].nodeLane, 0);
assert.equal(rows[1].nodeLane, 1);
// Both lanes collapse into base.
assert.equal(rows[2].nodeLane, 0);
assert.deepEqual(rows[2].inbound, [1]);
});
test('freed lanes are reused by later branch tips', () => {
const rows = computeCommitGraph([
{ hash: 'a2', parents: ['a1'] },
{ hash: 'a1', parents: [] }, // lane 0 ends here
{ hash: 'b1', parents: [] }, // new tip should reuse lane 0
]);
assert.equal(rows[2].nodeLane, 0);
assert.equal(rows[2].laneCount, 1);
});
test('commits without parents metadata degrade gracefully', () => {
const rows = computeCommitGraph([{ hash: 'x' }, { hash: 'y' }]);
// Without parent info every commit is a standalone tip; the second one
// reuses the freed lane.
assert.deepEqual(rows.map((row) => row.nodeLane), [0, 0]);
assert.deepEqual(rows.map((row) => row.hasParentContinuation), [false, false]);
});

View File

@@ -0,0 +1,138 @@
/**
* Lane assignment for the History view commit graph (VSCode Git Graph style).
*
* Commits must arrive in graph order (children before their parents — the
* backend guarantees this via `git log --all --topo-order`). Each commit is
* assigned a lane; lines connect commits to their parents across rows.
*/
export type CommitGraphRow = {
/** Lane the commit dot sits in. */
nodeLane: number;
/** Total lanes visible in this row — determines the strip width. */
laneCount: number;
/** A line arrives at the node from the row above (some child expects this commit). */
hasTopContinuation: boolean;
/** The node's own lane continues below toward its first parent. */
hasParentContinuation: boolean;
/** Extra top lanes that merge into the node (multiple children / branch tips joining). */
inbound: number[];
/** Bottom lanes branching out of the node toward its extra parents (merge commits). */
outbound: number[];
/** Lanes whose lines pass straight through this row untouched. */
passThrough: number[];
/** Every lane still active below this row — rails continue through expanded content. */
bottomLanes: number[];
};
type GraphCommit = {
hash: string;
parents?: string[];
};
// Colors cycle per lane, VSCode Git Graph style. Chosen to stay readable on
// both light and dark backgrounds.
const GRAPH_COLORS = [
'#0ea5e9', // sky
'#f97316', // orange
'#a855f7', // purple
'#22c55e', // green
'#ef4444', // red
'#eab308', // yellow
'#14b8a6', // teal
'#ec4899', // pink
'#6366f1', // indigo
'#84cc16', // lime
];
export const laneColor = (lane: number) => GRAPH_COLORS[lane % GRAPH_COLORS.length];
export function computeCommitGraph(commits: GraphCommit[]): CommitGraphRow[] {
// Each slot holds the commit hash that lane is waiting to reach, or null
// when the lane is free.
const lanes: (string | null)[] = [];
const rows: CommitGraphRow[] = [];
const takeFirstFreeLane = (): number => {
const free = lanes.indexOf(null);
if (free !== -1) {
return free;
}
lanes.push(null);
return lanes.length - 1;
};
for (const commit of commits) {
const activeBefore = new Set<number>();
lanes.forEach((expected, index) => {
if (expected !== null) {
activeBefore.add(index);
}
});
// Lanes whose next expected commit is this one.
const waiting: number[] = [];
lanes.forEach((expected, index) => {
if (expected === commit.hash) {
waiting.push(index);
}
});
const hasTopContinuation = waiting.length > 0;
const nodeLane = hasTopContinuation ? waiting[0] : takeFirstFreeLane();
// Additional lanes converging on this commit merge into the node and free up.
const inbound = waiting.slice(1);
for (const lane of inbound) {
lanes[lane] = null;
}
const parents = commit.parents ?? [];
lanes[nodeLane] = parents.length > 0 ? parents[0] : null;
// Extra parents (merge commits) either join a lane already heading to that
// parent or open a new lane for it.
const outbound: number[] = [];
for (const parent of parents.slice(1)) {
const existing = lanes.findIndex((expected) => expected === parent);
if (existing !== -1 && existing !== nodeLane) {
outbound.push(existing);
} else {
const lane = takeFirstFreeLane();
lanes[lane] = parent;
outbound.push(lane);
}
}
const passThrough = [...activeBefore]
.filter((lane) => lane !== nodeLane && !waiting.includes(lane))
.sort((a, b) => a - b);
const bottomLanes: number[] = [];
lanes.forEach((expected, index) => {
if (expected !== null) {
bottomLanes.push(index);
}
});
const laneCount = Math.max(lanes.length, nodeLane + 1);
// Keep the lane array tight so later rows don't inherit phantom width.
while (lanes.length > 0 && lanes[lanes.length - 1] === null) {
lanes.pop();
}
rows.push({
nodeLane,
laneCount,
hasTopContinuation,
hasParentContinuation: parents.length > 0,
inbound,
outbound,
passThrough,
bottomLanes,
});
}
return rows;
}

View File

@@ -0,0 +1,95 @@
import type { CommitGraphRow } from '../../utils/commitGraph';
import { laneColor } from '../../utils/commitGraph';
// Geometry: each lane is a fixed-width column; the commit dot sits at NODE_Y
// inside a fixed-height top zone (matching the collapsed row header), and
// plain rails continue below it so lines stretch through expanded content.
const LANE_WIDTH = 12;
const NODE_ZONE_HEIGHT = 56;
const NODE_Y = 28;
const NODE_RADIUS = 3.5;
const STROKE_WIDTH = 2;
const laneX = (lane: number) => lane * LANE_WIDTH + LANE_WIDTH / 2;
type CommitGraphStripProps = {
row: CommitGraphRow;
};
export default function CommitGraphStrip({ row }: CommitGraphStripProps) {
const width = row.laneCount * LANE_WIDTH;
const nodeX = laneX(row.nodeLane);
const nodeColor = laneColor(row.nodeLane);
return (
<div aria-hidden className="relative shrink-0 self-stretch overflow-hidden" style={{ width }}>
<svg
className="absolute left-0 top-0"
width={width}
height={NODE_ZONE_HEIGHT}
fill="none"
>
{/* Lines passing straight through the row */}
{row.passThrough.map((lane) => (
<path
key={`pass-${lane}`}
d={`M ${laneX(lane)} 0 V ${NODE_ZONE_HEIGHT}`}
stroke={laneColor(lane)}
strokeWidth={STROKE_WIDTH}
/>
))}
{/* The node's own lane arriving from above / continuing below */}
{row.hasTopContinuation && (
<path d={`M ${nodeX} 0 V ${NODE_Y}`} stroke={nodeColor} strokeWidth={STROKE_WIDTH} />
)}
{row.hasParentContinuation && (
<path d={`M ${nodeX} ${NODE_Y} V ${NODE_ZONE_HEIGHT}`} stroke={nodeColor} strokeWidth={STROKE_WIDTH} />
)}
{/* Extra children merging into the node from the row above */}
{row.inbound.map((lane) => (
<path
key={`in-${lane}`}
d={`M ${laneX(lane)} 0 Q ${laneX(lane)} ${NODE_Y} ${nodeX} ${NODE_Y}`}
stroke={laneColor(lane)}
strokeWidth={STROKE_WIDTH}
/>
))}
{/* Extra parents branching out of the node toward the row below */}
{row.outbound.map((lane) => (
<path
key={`out-${lane}`}
d={`M ${nodeX} ${NODE_Y} Q ${laneX(lane)} ${NODE_Y} ${laneX(lane)} ${NODE_ZONE_HEIGHT}`}
stroke={laneColor(lane)}
strokeWidth={STROKE_WIDTH}
/>
))}
{/* Commit dot — slightly larger for merge/fork points */}
<circle
cx={nodeX}
cy={NODE_Y}
r={row.inbound.length > 0 || row.outbound.length > 0 ? NODE_RADIUS + 0.5 : NODE_RADIUS}
fill={nodeColor}
/>
</svg>
{/* Rails continuing below the node zone (through expanded content) */}
{row.bottomLanes.map((lane) => (
<div
key={`rail-${lane}`}
className="absolute"
style={{
left: laneX(lane) - STROKE_WIDTH / 2,
top: NODE_ZONE_HEIGHT,
bottom: 0,
width: STROKE_WIDTH,
backgroundColor: laneColor(lane),
}}
/>
))}
</div>
);
}

View File

@@ -1,8 +1,11 @@
import { ChevronDown, ChevronRight } from 'lucide-react';
import { ChevronDown, ChevronRight, GitBranch, Tag } from 'lucide-react';
import { useMemo } from 'react';
import type { GitCommitSummary } from '../../types/types';
import type { CommitGraphRow } from '../../utils/commitGraph';
import { laneColor } from '../../utils/commitGraph';
import { getStatusBadgeClass, parseCommitFiles } from '../../utils/gitPanelUtils';
import GitDiffViewer from '../shared/GitDiffViewer';
import CommitGraphStrip from './CommitGraphStrip';
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
@@ -12,12 +15,36 @@ function formatDate(dateString: string): string {
});
}
// One "HEAD -> main" / "origin/x" / "tag: v1" decoration pill next to the
// commit message, tinted with the commit's graph lane color.
function RefBadge({ refName, color }: { refName: string; color: string }) {
const isTag = refName.startsWith('tag: ');
const isHead = refName.startsWith('HEAD -> ');
const label = isTag ? refName.slice(5) : isHead ? refName.slice(8) : refName;
return (
<span
className="inline-flex max-w-40 items-center gap-1 rounded-full border px-1.5 py-px text-[10px] font-medium leading-4"
style={{
borderColor: color,
color,
backgroundColor: isHead ? `${color}22` : 'transparent',
}}
title={refName}
>
{isTag ? <Tag className="h-2.5 w-2.5 shrink-0" /> : <GitBranch className="h-2.5 w-2.5 shrink-0" />}
<span className="truncate">{label}</span>
</span>
);
}
type CommitHistoryItemProps = {
commit: GitCommitSummary;
isExpanded: boolean;
diff?: string;
isMobile: boolean;
wrapText: boolean;
graphRow?: CommitGraphRow;
onToggle: () => void;
};
@@ -27,6 +54,7 @@ export default function CommitHistoryItem({
diff,
isMobile,
wrapText,
graphRow,
onToggle,
}: CommitHistoryItemProps) {
const fileSummary = useMemo(() => {
@@ -34,8 +62,12 @@ export default function CommitHistoryItem({
return parseCommitFiles(diff);
}, [diff]);
const badgeColor = graphRow ? laneColor(graphRow.nodeLane) : 'var(--color-primary, #0ea5e9)';
return (
<div className="border-b border-border last:border-0">
<div className="flex border-b border-border last:border-0">
{graphRow && <CommitGraphStrip row={graphRow} />}
<div className="min-w-0 flex-1">
<button
type="button"
aria-expanded={isExpanded}
@@ -48,6 +80,13 @@ export default function CommitHistoryItem({
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{commit.refs && commit.refs.length > 0 && (
<span className="mb-0.5 flex flex-wrap gap-1">
{commit.refs.map((refName) => (
<RefBadge key={refName} refName={refName} color={badgeColor} />
))}
</span>
)}
<p className="truncate text-sm font-medium text-foreground">{commit.message}</p>
<p className="mt-1 text-sm text-muted-foreground">
{commit.author}
@@ -145,6 +184,7 @@ export default function CommitHistoryItem({
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { History, RefreshCw } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { GitDiffMap, GitCommitSummary } from '../../types/types';
import { computeCommitGraph } from '../../utils/commitGraph';
import CommitHistoryItem from './CommitHistoryItem';
type HistoryViewProps = {
@@ -22,6 +23,15 @@ export default function HistoryView({
}: HistoryViewProps) {
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
// Lane layout for the commit graph; rows align 1:1 with recentCommits.
// Older API responses without `parents` degrade to plain rows (no strip).
const graphRows = useMemo(() => {
if (!recentCommits.some((commit) => commit.parents !== undefined)) {
return null;
}
return computeCommitGraph(recentCommits);
}, [recentCommits]);
const toggleCommitExpanded = useCallback(
(commitHash: string) => {
const isExpanding = !expandedCommits.has(commitHash);
@@ -59,7 +69,7 @@ export default function HistoryView({
</div>
) : (
<div className={isMobile ? 'pb-4' : ''}>
{recentCommits.map((commit) => (
{recentCommits.map((commit, index) => (
<CommitHistoryItem
key={commit.hash}
commit={commit}
@@ -67,6 +77,7 @@ export default function HistoryView({
diff={commitDiffs[commit.hash]}
isMobile={isMobile}
wrapText={wrapText}
graphRow={graphRows?.[index]}
onToggle={() => toggleCommitExpanded(commit.hash)}
/>
))}