From 6af92de61e9bd0e1da008b1ee8d2d9126dbbbf2d Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 8 Apr 2026 22:24:23 +0200 Subject: [PATCH] feat: add GitGraph component and graphviz theme defaults Add a reusable GitGraph Astro component with two modes: - Linear mode (pure SVG): for stacked PR diagrams - Graph mode (Graphviz): for branching git history diagrams Both share a locked-down color palette and consistent sizing. Convert merge-strategies page from raw Graphviz to GitGraph. Also add layered theme defaults to the remark-graphviz plugin for remaining raw dot blocks (graph + git-commits presets). Co-Authored-By: Claude Opus 4.6 (1M context) Change-Id: I720770b645e7a50ee7fcb8288fa1651675fda44a Claude-Session-Id: a911b6ad-5466-48a4-b4d3-85198ebc7c28 --- plugins/remark-graphviz.ts | 38 ++- src/components/GitGraph.astro | 244 ++++++++++++++++++ .../docs/merge-queue/merge-strategies.mdx | 209 ++++++--------- 3 files changed, 365 insertions(+), 126 deletions(-) create mode 100644 src/components/GitGraph.astro diff --git a/plugins/remark-graphviz.ts b/plugins/remark-graphviz.ts index b940addc95..c2fee178bd 100644 --- a/plugins/remark-graphviz.ts +++ b/plugins/remark-graphviz.ts @@ -8,6 +8,40 @@ const viz = await instance(); const validLanguages = [`dot`, `circo`, `neato`]; +// Layered style defaults injected into dot blocks based on their CSS classes. +// Diagrams can override any of these by redeclaring the same attributes. +const themes: Record = { + // Base theme: applied to all class="graph" blocks + graph: ` + fontname="sans-serif"; + node [fontname="sans-serif", style=filled, fontcolor="white"]; + edge [fontname="sans-serif", color="#374151"]; + `, + // Git commit graph: circles on a left-to-right line + 'git-commits': ` + rankdir="LR"; + splines=line; + node [shape=circle, width=0.5, height=0.5, fixedsize=true]; + `, +}; + +function injectDefaults(source: string, attrString: string | null): string { + if (!attrString?.includes('graph')) return source; + + const braceIndex = source.indexOf('{'); + if (braceIndex === -1) return source; + + // Build the defaults string by layering matching themes + let defaults = ''; + for (const [key, value] of Object.entries(themes)) { + if (attrString.includes(key)) { + defaults += value; + } + } + + return `${source.slice(0, braceIndex + 1)}${defaults}${source.slice(braceIndex + 1)}`; +} + export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { const codeNodes = []; @@ -26,8 +60,10 @@ export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { /** This transformer can try to re-transform nodes which are now SVG element, we need to prevent that */ if (node.value?.includes(' = { + gray: '#6B7280', + green: '#347D39', + blue: '#2563EB', + amber: '#D97706', + red: '#DC2626', + ghost: '#9CA3AF', +}; + +// ── Types ────────────────────────────────────────────── + +interface PR { + label: string; + commits: number | [number, number]; + color?: 'blue' | 'red'; + annotation?: string; +} + +interface GraphNode { + id: string; + label: string; + color?: 'gray' | 'green' | 'blue' | 'amber' | 'red'; + ghost?: boolean; +} + +interface GraphEdge { + from: string; + to: string; + dashed?: boolean; + label?: string; +} + +interface BranchLabel { + label: string; + node: string; +} + +type Props = + | { + /** Linear mode: commit labels left to right. */ + commits: string[]; + commitColor?: 'gray' | 'green'; + branch?: string; + prs?: PR[]; + nodes?: never; + } + | { + /** Graph mode: explicit nodes, edges, and branch labels. */ + nodes: GraphNode[]; + edges?: GraphEdge[]; + branch?: BranchLabel; + commits?: never; + }; + +// ── Rendering ────────────────────────────────────────── + +const props = Astro.props; +let svgHtml: string; + +if ('commits' in props && props.commits) { + svgHtml = renderLinear(props); +} else if ('nodes' in props && props.nodes) { + svgHtml = await renderGraph(props); +} else { + svgHtml = ''; +} + +// ── Linear mode (stacks): pure SVG ──────────────────── + +function renderLinear(p: { + commits: string[]; + commitColor?: string; + branch?: string; + prs?: PR[]; +}): string { + const { commits, commitColor = 'gray', branch = 'main', prs = [] } = p; + + const R = 18; + const SPACING = 80; + const PAD_LEFT = 50; + const COMMIT_Y = 32; + const PR_Y = 100; + const PR_H = 26; + const PR_R = 6; + const FONT = 11; + const CHAR_W = 7; + const PR_PAD_X = 16; + const ANNO_GAP = 16; + + const fill = COLORS[commitColor] || COLORS.gray; + const cx = (i: number) => PAD_LEFT + i * SPACING; + + const prData = prs.map((pr) => { + const [from, to] = Array.isArray(pr.commits) ? pr.commits : [pr.commits, pr.commits]; + const center = (cx(from) + cx(to)) / 2; + const w = Math.max(pr.label.length * CHAR_W + PR_PAD_X, 54); + return { ...pr, from, to, center, w, fill: COLORS[pr.color || 'blue'] }; + }); + + const hasAnno = prData.some((pr) => pr.annotation); + const annoY = PR_Y + PR_H / 2 + ANNO_GAP; + const maxX = Math.max( + cx(commits.length - 1) + R + 30, + ...prData.map((pr) => pr.center + pr.w / 2 + 20) + ); + const maxY = hasAnno ? annoY + 12 : PR_Y + PR_H / 2 + 12; + + const lines: string[] = []; + const push = (s: string) => lines.push(s); + + push( + `` + ); + + // branch line + push( + `` + ); + + // branch label + push( + `${branch}` + ); + + // commits + for (let i = 0; i < commits.length; i++) { + if (i < commits.length - 1) { + push( + `` + ); + } + push(``); + push( + `${commits[i]}` + ); + } + + // PRs + for (const pr of prData) { + for (let i = pr.from; i <= pr.to; i++) { + push( + `` + ); + } + push( + `` + ); + push( + `${pr.label}` + ); + if (pr.annotation) { + push( + `${pr.annotation}` + ); + } + } + + push(''); + return lines.join('\n'); +} + +// ── Graph mode (merge strategies): Graphviz ─────────── + +async function renderGraph(p: { + nodes: GraphNode[]; + edges?: GraphEdge[]; + branch?: BranchLabel; +}): Promise { + const { nodes, edges = [], branch } = p; + const viz = await instance(); + + const dot: string[] = []; + dot.push('strict digraph {'); + dot.push(' fontname="sans-serif";'); + dot.push(' rankdir="LR";'); + dot.push(' splines=line;'); + dot.push( + ' node [style=filled, shape=circle, fontname="sans-serif", fontcolor="white", width=0.5, height=0.5, fixedsize=true];' + ); + dot.push(' edge [color="#374151", fontname="sans-serif"];'); + + // nodes + for (const n of nodes) { + const c = COLORS[n.color || 'gray']; + const style = n.ghost ? 'style="filled,dashed"' : ''; + dot.push(` ${n.id} [label="${n.label}", fillcolor="${c}" ${style}];`); + } + + // branch label + if (branch) { + const targetNode = nodes.find((n) => n.id === branch.node); + const branchColor = targetNode ? COLORS[targetNode.color || 'gray'] : COLORS.gray; + dot.push( + ` _branch [label="${branch.label}", shape=plaintext, fontcolor="${branchColor}", fontsize=10];` + ); + dot.push(` _branch -> ${branch.node} [style=dashed, color="${branchColor}", arrowhead=none];`); + dot.push(` { rank=same; ${branch.node}; _branch; }`); + } + + // edges + for (const e of edges) { + const attrs: string[] = []; + if (e.dashed) { + attrs.push(`style=dashed, color="${COLORS.ghost}"`); + } + if (e.label) { + const edgeColor = e.dashed ? COLORS.ghost : '#374151'; + attrs.push(`label="${e.label}", fontsize=9, fontcolor="${edgeColor}"`); + } + const attrStr = attrs.length ? ` [${attrs.join(', ')}]` : ''; + dot.push(` ${e.from} -> ${e.to}${attrStr};`); + } + + dot.push('}'); + + const svgString = viz.renderString(dot.join('\n'), { format: 'svg', engine: 'dot' }); + const $ = load(svgString); + $('svg').removeAttr('width').removeAttr('height'); + $('svg').attr('class', 'git-graph'); + // Strip Graphviz background + $('svg > g > polygon').first().attr('fill', 'none').attr('stroke', 'none'); + return $.html('svg'); +} +--- + + + + diff --git a/src/content/docs/merge-queue/merge-strategies.mdx b/src/content/docs/merge-queue/merge-strategies.mdx index 0c4c4dfd91..093349909d 100644 --- a/src/content/docs/merge-queue/merge-strategies.mdx +++ b/src/content/docs/merge-queue/merge-strategies.mdx @@ -5,6 +5,7 @@ description: Choose how pull requests are merged into your base branch and contr import { Image } from "astro:assets" import requiredPRbypassScreenshot from "../../images/merge-queue/batches/mergify-required-pull-request-bypass.png" +import GitGraph from '~/components/GitGraph.astro' The `merge_method` option in your queue rules controls how Mergify merges pull requests into your base branch. Each method produces a different git history @@ -31,32 +32,23 @@ queue_rules: Creates a merge commit joining the PR branch into the base branch. This is the default GitHub merge behavior. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - splines=line; - node [style=filled, shape=circle, fontname="sans-serif", fontcolor="white", width=0.5, fixedsize=true]; - edge [color="#374151", fontname="sans-serif"]; - - A [label="A", fillcolor="#6B7280"]; - B [label="B", fillcolor="#6B7280"]; - C [label="C", fillcolor="#347D39"]; - D [label="D", fillcolor="#347D39"]; - M [label="M", fillcolor="#2563EB"]; - - main [label="main", shape=plaintext, fontname="sans-serif", fontcolor="#2563EB", fontsize=10]; - - A -> B; - B -> C; - C -> D; - B -> M; - D -> M; - main -> M [style=dashed, color="#2563EB", arrowhead=none]; - - { rank=same; M; main; } -} -``` + - **History:** non-linear — the PR branch and base branch are visible as separate lines in `git log --graph` @@ -78,31 +70,23 @@ queue_rules: Squashes all PR commits into a single commit on the base branch. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - splines=line; - node [style=filled, shape=circle, fontname="sans-serif", fontcolor="white", width=0.5, fixedsize=true]; - edge [color="#374151", fontname="sans-serif"]; - - A [label="A", fillcolor="#6B7280"]; - B [label="B", fillcolor="#6B7280"]; - S [label="S", fillcolor="#D97706"]; - C [label="C", fillcolor="#347D39", style="filled,dashed"]; - D [label="D", fillcolor="#347D39", style="filled,dashed"]; - - main [label="main", shape=plaintext, fontname="sans-serif", fontcolor="#D97706", fontsize=10]; - - A -> B -> S; - B -> C [style=dashed, color="#9CA3AF"]; - C -> D [style=dashed, color="#9CA3AF"]; - D -> S [style=dashed, color="#9CA3AF", label="squashed\ninto one commit", fontsize=9, fontcolor="#9CA3AF"]; - main -> S [style=dashed, color="#D97706", arrowhead=none]; - - { rank=same; S; main; } -} -``` + - **History:** linear — one commit per PR on the base branch - **Merge commits:** no @@ -120,32 +104,24 @@ queue_rules: Replays each PR commit on top of the base branch, creating new commits with new SHAs. -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - splines=line; - node [style=filled, shape=circle, fontname="sans-serif", fontcolor="white", width=0.5, fixedsize=true]; - edge [color="#374151", fontname="sans-serif"]; - - A [label="A", fillcolor="#6B7280"]; - B [label="B", fillcolor="#6B7280"]; - C [label="C", fillcolor="#347D39", style="filled,dashed"]; - D [label="D", fillcolor="#347D39", style="filled,dashed"]; - Cp [label="C'", fillcolor="#D97706"]; - Dp [label="D'", fillcolor="#D97706"]; - - main [label="main", shape=plaintext, fontname="sans-serif", fontcolor="#D97706", fontsize=10]; - - A -> B -> Cp [label="replayed with\nnew SHAs", fontsize=9, fontcolor="#D97706"]; - Cp -> Dp; - A -> C [style=dashed, color="#9CA3AF"]; - C -> D [style=dashed, color="#9CA3AF"]; - main -> Dp [style=dashed, color="#D97706", arrowhead=none]; - - { rank=same; Dp; main; } -} -``` + - **History:** linear — no merge commits, individual commits are preserved @@ -186,28 +162,20 @@ preserves are the SHAs of the PR branch at merge time. details and migration options. ::: -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - splines=line; - node [style=filled, shape=circle, fontname="sans-serif", fontcolor="white", width=0.5, fixedsize=true]; - edge [color="#374151", fontname="sans-serif"]; - - A [label="A", fillcolor="#6B7280"]; - B [label="B", fillcolor="#6B7280"]; - C [label="C", fillcolor="#347D39"]; - D [label="D", fillcolor="#347D39"]; - - main [label="main", shape=plaintext, fontname="sans-serif", fontcolor="#347D39", fontsize=10]; - - A -> B -> C [label="same SHAs\non base branch", fontsize=9, fontcolor="#347D39"]; - C -> D; - main -> D [style=dashed, color="#347D39", arrowhead=none]; - - { rank=same; D; main; } -} -``` + - **History:** strictly linear — commits sit directly on the base branch @@ -298,32 +266,23 @@ uses the GitHub Pull Request merge API rather than advancing the git ref directly. The resulting merge commit message identifies which pull requests were included in the batch (e.g., "Merge queue: merged #42, #43, #44"). -```dot class="graph" -strict digraph { - fontname="sans-serif"; - rankdir="LR"; - splines=line; - node [style=filled, shape=circle, fontname="sans-serif", fontcolor="white", width=0.5, fixedsize=true]; - edge [color="#374151", fontname="sans-serif"]; - - A [label="A", fillcolor="#6B7280"]; - B [label="B", fillcolor="#6B7280"]; - D1 [label="D1", fillcolor="#347D39"]; - D2 [label="D2", fillcolor="#347D39"]; - M [label="M", fillcolor="#2563EB"]; - - main [label="main", shape=plaintext, fontname="sans-serif", fontcolor="#2563EB", fontsize=10]; - - A -> B; - B -> D1 [label="batch PR", fontsize=9, fontcolor="#347D39"]; - D1 -> D2; - B -> M; - D2 -> M [label="merge commit", fontsize=9, fontcolor="#2563EB"]; - main -> M [style=dashed, color="#2563EB", arrowhead=none]; - - { rank=same; M; main; } -} -``` + - **History:** non-linear — a single merge commit per batch appears on the base branch