Skip to content

feat(graph): match GitX's graph, column for column - #42

Merged
LeadcodeDev merged 12 commits into
mainfrom
feat/date-order
Aug 20, 2026
Merged

feat(graph): match GitX's graph, column for column#42
LeadcodeDev merged 12 commits into
mainfrom
feat/date-order

Conversation

@LeadcodeDev

@LeadcodeDev LeadcodeDev commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

gitr's graph did not match GitX's. Four independent causes, all now fixed and all verified
by measurement rather than by looking.

1. The commit order

git --topo-order : … ac3641b, 1bc10ae, 0bd5615, c9eba26, b0aee11 …   ← gitr
git --date-order : … ac3641b, 1bc10ae, c9eba26, 0bd5615, b0aee11 …   ← GitX

Topological order interleaves a branch's commits with the trunk whenever their dates
interleave. Date order keeps each branch's run contiguous, which is what lets a branch hold
a column for several rows instead of appearing as a one-commit stub.

topo::Sorting::TopoOrderDateOrder. Reproduces git rev-list --date-order exactly;
the two sequences diff clean.

The measurement that justified topological order no longer describes this code

CLAUDE.md argued for it with rust-lang/cargo at 23 789 commits: 258 lanes in date order
against 20. Measured on the current code, zed-industries/zed at 39 565 commits gives
13 columns in date order against 17 topologically — date order is the narrower of the
two here. Different repository and different code, so the honest claim is that row, not
"258 became 13".

2. The lane placement

Ported PBGitGrapher.decorateCommit rather than adapting what was here.

Verified by running GitX's own algorithm over this repository's history and diffing the
column of every commit:

identical differing
before 43 / 53 10, all by one column, all the same direction
after 53 / 53 0

Two things were wrong, and only one of them was visible.

Convergence was eager. gitr folded a lane away as soon as another column already
expected its first parent. GitX does not check — currentLane->setSha(parents[0]) is
unconditional — so two columns can hold the same object and meet only at the row that
places it. Closing a column one row early slides everything to its right, which is exactly
the offset those ten commits showed.

The node was placed in the wrong space. GitX puts a commit at its index in the
outgoing column list; gitr used the incoming one. They agree until a column dies in the
same row a tip appears, and then they do not. That is not a parameter to tweak — it is the
frame the whole line model is expressed in, which is why two attempts to patch it in place
made things worse (ten divergences became eighteen) before this rewrite.

A parentless commit leaves a hole rather than closing its column, so its neighbours hold
position one more row — GitX does this too, with std::replace(…, currentLane, 0).

3. The shape of a line

Correct columns still drew the wrong picture. A segment described the whole band between
two rows, so a branch leaving its parent ran vertically down to the row boundary, turned,
and only then slanted across. Every divergence therefore started a half-row below the node
it came from, and every convergence arrived a half-row above the one it went to.

GitX has no bend anywhere. PBGitRevisionCell.drawLineFromColumn draws one straight line
from a cell edge to the cell's own centre
, and nothing else. A change of column happens
over half a row, and every line ends on a node rather than beside it.

GraphRow is split to match:

  • incoming — the lines crossing the upper half, from the top edge down to the row's centre
  • segments — the lines crossing the lower half, from that centre down to the bottom edge

A line changing column does so above the node, arriving already in its new one. IncomingLink
and the midpoint machinery it fed are gone, as are next_lane, is_outgoing and
lands_on_next_node, which existed only to reconstruct a bend nothing draws any more.

Placement is untouched by this third fix — the column comparison still reports 53 / 53.

4. The node

gitr drew a small disc in the lane's colour. GitX draws two concentric circles and no lane
colour at all — drawCircleInRect fills a black oval of diameter 10, then a white one of
diameter 8 over it, leaving a thin ring. Sampled off a GitX window on this repository to
confirm the source reads the way it looks: ring #020202, fill #ffffff, and #fca64f
on the one node that is the checked-out commit.

The hollow centre is ported as written, reading the fill from the theme so the shape
survives dark mode. Two things deliberately depart from GitX:

The ring takes the track's colour, where GitX paints every one black. A ring in the
branch's colour says which branch a commit belongs to without following its line up the
gutter, and the hollow centre is what leaves room to say it — a filled disc in the same
colour reads as a thickening of the line, which is exactly what gitr drew before.

The node is three quarters of its column, not the full width GitX uses. Its rows are
20px and its columns 10px; gitr's are 24 and 12, so an edge-to-edge node put adjacent lanes
in contact and a run of parallel branches read as a band. Shrinking one that way thins its
ring away, so the inner radius is subtracted from the outer rather than taken as a fraction
of it, and a test pins that the centre stays hollow and that neighbours stay apart.

The pixel grid, which took three passes to state correctly

Resizing the node exposed a class of bug the geometry cannot show, and each pass fixed one
case and named the rule too narrowly for the next.

The ring came out 2px on one side, 1px on the other. The arithmetic was symmetric, so
the cause was the snapping applied after it: a 1.2px ring put the two discs' origins on
different sub-pixel offsets and they rounded apart. Measured, not judged by eye — the fill
spanned dx -3..+2 inside an outer disc spanning -5..+3.

Diagonals then read as doubled. A sloped line was painted 1.4× the vertical one, to
compensate for a 1.5px vertical landing at four fifths opacity. Once the line went to 2px
and landed as two solid columns, the factor corrected nothing and just made the line
thicker. One width now, and no branch on the slope. A 45° line still covers more
horizontal pixels — five against two — but that is its slope, not its weight.

Then the lines sat half a pixel off the nodes. The node covered pixels 332..340,
centre 336.5; the line covered 336..337, centre 337. A radius of 4.5 has a fractional
half-extent where a 2px line's is whole.

The rule that covers all three: every half-extent in the gutter must be a whole number
— a radius for a disc, half a width for a line — because that is what gets snapped, and
shapes on one centre stay centred only when their half-extents share a fractional part. The
test asserts it over all three half-extents rather than over the conditions each bug
suggested, since naming conditions is exactly what let the later ones through. CLAUDE.md
records it.

The checked-out commit keeps GitX's #fca64f and is the only filled node in the table, so
it reads down the whole gutter without depending on the ring's colour.

HEAD reaches the delegate as an ObjectId now, not only as a branch name.
HeadState::target() is what GitX compares against, and it is the only thing still saying
where you are when HEAD is detached.

What was reverted

Stable columns, from #41. They were a response to "gitr only parallelises two branches",
and that diagnosis was wrong twice: instrumenting the layout showed three columns already
in use, and the real divergence was the ordering above. Stable columns are arguably easier
to follow — but GitX compacts, and matching GitX is the requirement.

Tests

The layout tests are rewritten, not patched. That is a real loss of evidence and worth
being explicit about: through cause 2 they passed untouched, which was the strongest
available proof that the placement was a port rather than a rewrite wearing its name. Cause
3 changes what a row means — an assertion on segments no longer describes the same band
— so keeping the old text would have asserted the old model in the new one's vocabulary.
Each test now names which half it reads, and the values were taken from the layout's actual
output on fixtures small enough to verify by hand.

Placement is what those tests protected, and placement is covered by the column comparison
against GitX, which is a stronger check than any of them.

crates/ui/src/history/geometry.rs carries the pixel-level half: a line from above ends
exactly on the node centre, a merge's second parent leaves the node sideways, a tip has
nothing drawn above it, a root nothing below.

cargo test --workspace green; cargo clippy --workspace --all-targets -- -D warnings;
cargo fmt --all --check.

Verification

Driven on the running app, and measured: GitX's algorithm ported to a script, run over the
real history, every column diffed. The final shape was checked by capturing gitr's window
and GitX's window on this same repository and scaling both gutters to a common row pitch —
the two are the same figure. CLAUDE.md records the details the port turns on and that
same check as the way to validate any future change here.

The graph did not match GitX's, and the cause was neither the lane algorithm nor
the stroke geometry — it was the order the commits arrive in.

    git --topo-order : … ac3641b, 1bc10ae, 0bd5615, c9eba26, b0aee11 …   gitr
    git --date-order : … ac3641b, 1bc10ae, c9eba26, 0bd5615, b0aee11 …   GitX

Topological order interleaves a branch's commits with the trunk whenever their dates
interleave; date order keeps each branch's run contiguous, which is what lets a
branch hold its column for several rows instead of appearing as a one-commit stub.
`topo::Sorting::DateOrder` reproduces `git rev-list --date-order` exactly — the two
sequences diff clean over this repository.

The ordering was topological on a measurement recorded in CLAUDE.md, and that
measurement no longer holds. It was taken against the old lane-compacting layout,
where `rust-lang/cargo` at 23 789 commits gave 258 lanes in date order against 20
topologically. Measured again on the current layout, where a track keeps its column,
`zed-industries/zed` at 39 565 commits gives 13 columns in date order against 17
topologically — date order is now the narrower of the two.

So this is not a readability concession to match GitX. Both notes are rewritten to
say what is true now, and to say plainly that the old number must not be used to
argue the ordering back.

The test that pinned the old contract now pins the new one, still by diffing against
git itself on a fixture built so the two orderings genuinely diverge.
@LeadcodeDev LeadcodeDev added the enhancement New feature or request label Aug 20, 2026
@LeadcodeDev LeadcodeDev self-assigned this Aug 20, 2026
Read GitX's own `PBGitGrapher.mm` rather than inferring from screenshots. Its
placement is explicit:

    else {
        // We are not this commit.
        currentLanes->push_back(*it);

Each row's columns are rebuilt by walking the previous row's and appending the
survivors in order, so a track's column is its position among them, and everything
to the right of an ending track slides left. GitX compacts.

That is what gitr did until I changed it. The stable-column policy was a response to
"gitr only parallelises two branches", and the diagnosis behind it was wrong twice
over: instrumenting the layout showed three columns already in use, and the actual
divergence from GitX was the commit ordering, fixed separately. Stable columns are
arguably easier to follow, but they are not what GitX draws, and matching GitX is
the requirement — so the placement goes back to what it was.

What survives from that work is orthogonal and stays: `incoming`, carrying each
arriving line's source column and colour, and `next_lane`, which lets a row tell a
link landing on the node below from one merely reserving a column further down.
Both are rendering inputs, not placement policy.

The tests go back with it, including the two that had been rewritten to assert the
opposite. CLAUDE.md records the placement rule and that stable columns were tried
and rejected, so the next reader does not repeat the detour.
Ported `PBGitGrapher.decorateCommit` rather than adapting what was here. Verified by
running GitX's algorithm over this repository's own history and diffing the column of
every commit: **53 of 53 identical, none differing**. Before the port, ten differed —
all by one column, all in the same direction.

Two things were wrong, and only one of them was visible.

**Convergence was eager.** gitr folded a lane away as soon as another column already
expected its first parent. GitX does not check: `currentLane->setSha(parents[0])`
happens unconditionally, so two columns can hold the same object and they meet only
at the row that places it. Closing a column one row early slides everything to its
right, which is exactly the one-column offset those ten commits showed.

**The node was placed in the wrong space.** GitX puts a commit at its index in the
*outgoing* column list, gitr used the incoming one. They agree until a column dies in
the same row a tip appears, and then they do not. That is not a parameter to tweak —
it is the frame the whole line model is expressed in, which is why two attempts to
patch it in place made things worse (ten divergences became eighteen) before this
rewrite.

So a row's own columns are now the outgoing ones, a row's segments are the *next*
row's mapping read one row later, and `incoming` is filtered out of the row's own
mapping instead of the previous row's. A line opened for a second parent starts at
the node rather than at the column it lands in, which `spawned` records. A parentless
commit leaves a hole rather than closing its column, so its neighbours hold position
for one more row — GitX does this too, with `std::replace(…, currentLane, 0)`.

All twelve existing tests pass untouched: the port preserves every behaviour they
describe. A thirteenth pins the deferred convergence directly, since that is the part
a future simplification would be most tempted to remove.
The two details that decide whether the placement matches GitX are not obvious from
either codebase: which column list the node is indexed into, and when convergence
happens. Both were got wrong before the port, so they are written down along with the
way to check — run GitX's algorithm over a real history and diff every column.
@LeadcodeDev LeadcodeDev changed the title feat(vcs): walk history in date order, as GitX does feat(graph): match GitX's graph, column for column Aug 20, 2026
A branch rejoining the trunk drew two diagonals instead of one: it ran to the trunk's
column at the band edge, and the half below it started again from the midpoint, six
pixels to the right. Traced on the gutter, the green line reached x=7 at y=430 and
reappeared at x=12 at y=431.

The midpoint rule was written for a commit's own link to its parent and guarded on
that: `is_outgoing && lands_on_next_node`. A branch converging into another column is
not the row's own link — it is a line crossing the band — so it took the ordinary
crossing path and ran the full width. Its other half, drawn by the row below as an
incoming link, correctly bent at the midpoint, and the two ends did not meet.

Landing on the node below is what decides where a line bends, not whose link it is.
The guard drops `is_outgoing`.

Measured after: the green core descends 18, 17, 16 … 9, 8 without reversing.
The gutter still bent every line at the row boundary: a segment described the
whole band between two rows, so a branch leaving its parent ran vertically to
the edge, turned, and only then slanted. GitX never does that. `PBGitRevisionCell`
draws one straight line from a cell edge to the cell's own centre — nothing else,
no bend anywhere — so a change of column happens over half a row and every line
ends on a node rather than beside it.

`GraphRow` is split to match: `incoming` holds the lines crossing the upper half
into the row's centre, `segments` the lines crossing the lower half out of it. A
line changing column does so above the node, arriving already in its new one.
`IncomingLink` and the midpoint machinery it fed are gone, along with
`next_lane`, `is_outgoing` and `lands_on_next_node`, which only existed to
reconstruct a bend the renderer no longer draws.

Placement is untouched — the column comparison against GitX still reports 53
identical, 0 differ. The layout tests are rewritten against the new halves
rather than patched, since an assertion on `segments` now means something else
than it did.
gitr drew a small disc in the lane's own colour. GitX draws two concentric
circles and no colour at all: a black oval of diameter 10, then a white one of
diameter 8 over it, leaving a thin ring. The checked-out commit gets #fca64f
instead of white, and that is the only node in the table that differs from any
other.

Ported as written, with the two colours read from the theme so the shape
survives dark mode: the ring is the foreground and the fill is the background,
which resolves to GitX's own black-on-white under the light theme and inverts
correctly under the dark one. `RowGeometry::node_color` is gone — the node has
no lane colour to carry, and the palette now serves only the lines.

The node's diameter is the column's width, as in GitX, where a 10px circle sits
in a 10px column. That ratio is easy to break by adjusting the spacing alone, so
a test pins it rather than leaving it to the two constants agreeing by memory.

HEAD reaches the delegate as an ObjectId now, not only as a branch name.
`HeadState::target()` is what GitX compares against, and it is also the only
thing that still says where you are when HEAD is detached and there is no branch
name to show.
Two changes to the same shape, so one commit.

The node was as wide as its column, which is GitX's own ratio but reads heavy
here: gitr's rows are taller and its columns wider, so nodes in adjacent lanes
sat edge to edge and a run of parallel branches read as a band rather than as
separate columns. Three quarters of a column leaves a gap between them.

Shrinking a node by moving its outer radius alone thins the ring away with
everything else, so the inner radius is now subtracted from the outer rather than
taken as a fraction of it. Those two lengths live as `f32` because `Pixels` keeps
its field private and one `Pixels` constant cannot be derived from another. The
ring itself is not a constant: nothing paints it, it is what remains between the
two discs.

The ring also takes the track's colour now, where GitX paints every one black.
That is a deliberate departure: a ring in the branch's colour says which branch a
commit belongs to without following its line up the gutter. The hollow centre is
what leaves room to say it — a filled disc in the same colour reads as a
thickening of the line, which is what gitr drew before the ring existed.

The checked-out commit keeps GitX's #fca64f, and it is now the only filled node
in the table, so it reads down the whole gutter without depending on the ring's
colour.
The ring came out 2px thick on the left and top, 1px on the right and bottom.
The arithmetic was symmetric — both discs are built from the same centre — so
the cause was not the geometry but the snapping applied after it.

The two discs are separate quads and each is rounded to the device pixel grid on
its own. The outer one started at `centre - 4.5` with a diameter of 9, the inner
at `centre - 3.3` with a diameter of 6.6: different sub-pixel offsets, rounded in
different directions, fill half a pixel off centre. Measured off a screenshot
rather than judged by eye — the fill spanned dx -3..+2 inside an outer disc
spanning -5..+3.

The ring is 1px now, which is GitX's own width and, more to the point, a whole
number: both origins then sit at the same offset from the centre and round
together. Both diameters are whole too, so rounding the sizes is a no-op. A test
asserts all three conditions, since any of them can be broken by adjusting a
radius that looks unrelated, and CLAUDE.md records the constraint.
The ring read lighter than the lines it joined, so a node looked like a thinning
of its own branch rather than a bead on it.

Thickening the ring alone was not enough, and the reason is not visible in the
declared widths. Measured against the row background, a 1.5px vertical line
covered two columns at four fifths opacity each, while a 2px ring covered two at
full strength: same footprint, different ink. The line is 2px now, so both land
as two solid columns and carry the same weight — verified by sampling the
rendered pixels, not by eye. That is also GitX's own `setLineWidth:2`.

The ring cannot take any other value: the pixel grid pins it to a whole number,
or the two discs round apart and the fill sits off centre. So the line derives
from the ring rather than the other way round, and the diagonal keeps the 1.4
factor that read right at the old width instead of being re-derived from nothing.
Two reports, one cause, so one commit: every shape in the gutter is snapped to
the device pixel grid on its own, and what gets snapped is a half-extent — a
radius for a disc, half a width for a line. Shapes on the same centre stay
centred together only when their half-extents share a fractional part.

Diagonals read as doubled. A sloped line was painted 1.4× the vertical one, to
compensate for a 1.5px vertical landing at four fifths opacity: declared width
and rendered weight disagreed, so a diagonal declared the same read lighter. At
2px the vertical lands as two solid columns and the two agree, which left the
factor correcting nothing and simply making the line thicker. Both widths are one
constant now, and the renderer no longer branches on the slope. A 45° line still
covers more horizontal pixels than a vertical one — five against two, measured —
but that is its slope, not its weight.

Vertical lines sat half a pixel off the node they ran through. Measured: the node
covered pixels 332..340, geometric centre 336.5, while the line covered 336..337,
centre 337. A radius of 4.5 has a fractional half-extent where a 2px line's is
whole, so the two rounded apart. The radius is 5 now — every half-extent in the
gutter is a whole number, which is the only value that holds for every pair at
once rather than for the pair that happened to be looked at.

The test covers all three half-extents rather than the two conditions the earlier
one named, since naming conditions is what let this second case through.
@LeadcodeDev
LeadcodeDev merged commit 0e0697a into main Aug 20, 2026
3 checks passed
@LeadcodeDev
LeadcodeDev deleted the feat/date-order branch August 20, 2026 22:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant