If you've looked at a commit graph for a repo with a few long-lived branches, you've seen this: every branch gets its own column, columns never go away, and eventually the graph is wider than the commit messages next to it.
I ran into this while building the history view for a desktop Git client. The first version gave each line its own column for good. On one 122-commit repo that came to 14 lanes, 276px of graph before any text. This post covers how I rebuilt the layout so lanes get reused, what that costs, and how the graph is drawn.
Where the layout lives
The app has a .NET 8 backend and an Electron + Angular front end. The backend does very little for the graph: it runs git log --topo-order and returns the commits. Topo order matters because git won't show a parent before all of its children, which is what a single top-to-bottom pass relies on.
The lane layout runs in the front end, in TypeScript. Lanes, colours and curves are presentation decisions, so they live next to the rendering code, and the backend stays a thin wrapper around git.
One pass, newest first
The layout walks the commits once, newest first, keeping a list of columns. Each column is waiting for one parent commit.
For each commit:
- If a column is waiting for it, the commit goes in that column.
- If nothing is waiting for it, it's a branch tip and gets a new column on the right.
- Its first parent continues in the same column, so a branch's main line runs straight down.
- On a merge, each other parent curves into an existing lane or gets a new lane next to the merge.
- When a line ends, its column is freed and the columns to its right shift left.
As a sketch (illustrative pseudocode, not the actual source):
// Illustrative only — not the real implementation.
const columns: (string | null)[] = []; // each slot: the parent hash it's waiting for
for (const commit of commitsNewestFirst) {
let lane = columns.indexOf(commit.hash);
if (lane === -1) { // branch tip: new column on the right
lane = columns.length;
columns.push(commit.hash);
}
const [first, ...others] = commit.parents;
columns[lane] = first ?? null; // first parent continues in this lane
for (const parent of others) {
placeMergeParent(columns, lane, parent); // existing lane, or new one beside the merge
}
compactFreedColumns(columns); // freed lanes close up, the rest shift left
emitRow(commit, lane, columns);
}
One extra rule applies to the checked-out branch. Once the pass reaches the HEAD commit, that branch's line moves to lane 0, the leftmost column. Rows above HEAD (newer commits on other branches) can still use lane 0, so this isn't "HEAD is always leftmost". It means that from HEAD down, the line you're on sits at the left edge.
The layout is deterministic: the same history always draws the same lanes. Refreshing doesn't reshuffle the graph, and it's easy to unit test.
Reusing lanes, and the 6-lane cap
Freeing columns and shifting left does most of the work. A merged branch stops taking space once its line ends, and the next branch tip can use it.
On top of that there's a hard cap of 6 lanes. Each lane is 18px plus padding, so 6 lanes come to 132px, inside a 150px limit for the graph column. On that 122-commit repo, the graph went from 14 lanes (276px) to 6 (132px).
The cap has a cost, and I'd rather be upfront about it: when more than six lines are active at once, the extra ones share the 6th lane, and lines can overlap there. I chose a graph that always fits beside the messages and is sometimes ambiguous at the right edge over one that is always exact and often too wide to read.
Colours follow similar rules. There are 8. A first parent keeps its child's colour, so a branch stays one colour top to bottom. A new lane takes the first colour not in use.
Drawing: one SVG per row
Each row is its own 40px-tall SVG. There's no canvas.
Inside a row, a line that stays in its lane is a straight vertical segment. A line that changes lanes is a cubic Bezier curve.
The detail that took the most adjusting was where in the row each curve happens. If a line shifting left and a merge curve bend at the same height, they draw over each other. So passing lines bend near the top of the row, and merge curves leave lower down. Each kind of curve gets its own part of the row.
// Illustrative only: a passing line moving from lane a to lane b,
// bending in the upper part of a row of height h.
`M ${x(a)} 0 C ${x(a)} ${h * 0.25}, ${x(b)} ${h * 0.25}, ${x(b)} ${h * 0.5} L ${x(b)} ${h}`
Commits, labels, HEAD
A normal commit is a small dot. HEAD is a bigger dot in the accent colour. A merge commit is a diamond.
Branch and tag names show as pills before the message, up to three per commit, in a fixed order: HEAD, tags, local branches, remote branches. The cap keeps a commit with many refs from pushing its message off screen.
If the working tree has uncommitted changes, an "Uncommitted changes" row sits just above HEAD, with a hollow dot and the number of changed files — roughly where your next commit will go.
Simplify: collapsing merge noise
Repos that merge main into feature branches a lot end up with long runs of merge commits that say little. A Simplify toggle handles that.
It collapses runs of two or more merge-only commits. A commit counts as merge-only if it has several parents, no branch or tag points at it, and its message starts with "Merge". A run becomes one row: "N merges from X", or "N merge commits" if the sources differ. Click it to expand or collapse.
The decision I care most about here: Simplify works on rows that have already been laid out. It doesn't re-run the lane algorithm; it hides rows and adds a summary row. So toggling it never moves a lane. Every line keeps its column and colour, and you don't have to find your branch again.
Performance, and what it doesn't do
- Virtual scrolling. Only visible rows render. Since each row is its own SVG, this fits naturally.
- Layout runs once each time history loads.
- Bounded history. 180 commits by default; you can raise it to 2,000.
- Stale loads are discarded. A superseded or cancelled load is thrown away rather than drawn.
What it doesn't do is incremental layout. When history reloads, the layout runs again from the top. For now, one simple pass that is easy to test is the trade I've made.
A short note on conflict buttons
The same view handles merge conflicts, with one rule: every button runs the git command its label names. Conflicts are detected with git status --porcelain=v2, and before acting it checks git ls-files -u (stage 1 = common ancestor, 2 = ours, 3 = theirs).
-
Keep ours / Keep theirs:
git checkout --ours/--theirs, thengit add. In a rebase or cherry-pick the labels adapt, e.g. "Ours (onto)" and "Theirs (picked)". -
Restore common ancestor: checks stage 1 exists, then
git checkout-index --stage=1andgit add. If the file was added on both sides there's no ancestor, so it refuses and explains why. It was renamed from "Restore pre-merge", since Keep ours already is the pre-merge version. -
Discard conflict work:
git checkout -m, which puts the markers back, behind a confirmation. - Plain Discard refuses conflicted files, because
git restore --source=HEADwould silently keep only your side.
Both the graph layout and these conflict actions have unit tests.
Trying it
The app is Gitify, a local-first desktop Git client for Windows, macOS and Linux. The current build is a prerelease, 0.1.0-rc.8. Builds are unsigned, so your OS will warn you on first open; the release notes have the steps.
- Site: https://gitify-desktop.ericnwaogwugwu.chatgpt.site/
- Release: https://github.com/nwaoga/gitify-downloads/releases/tag/v0.1.0-rc.8
- Issues: https://github.com/nwaoga/gitify-downloads/issues
If the graph draws something wrong for your history, a bug report describing the branch shape would help a lot.
Top comments (0)