feat(graph): match GitX's graph, column for column - #42
Merged
Conversation
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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::TopoOrder→DateOrder. Reproducesgit rev-list --date-orderexactly;the two sequences diff clean.
The measurement that justified topological order no longer describes this code
CLAUDE.mdargued for it withrust-lang/cargoat 23 789 commits: 258 lanes in date orderagainst 20. Measured on the current code,
zed-industries/zedat 39 565 commits gives13 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.decorateCommitrather than adapting what was here.Verified by running GitX's own algorithm over this repository's history and diffing the
column of every commit:
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])isunconditional — 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.drawLineFromColumndraws one straight linefrom 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.
GraphRowis split to match:incoming— the lines crossing the upper half, from the top edge down to the row's centresegments— the lines crossing the lower half, from that centre down to the bottom edgeA line changing column does so above the node, arriving already in its new one.
IncomingLinkand the midpoint machinery it fed are gone, as are
next_lane,is_outgoingandlands_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 —
drawCircleInRectfills a black oval of diameter 10, then a white one ofdiameter 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#fca64fon 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..+2inside 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 fractionalhalf-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.mdrecords it.
The checked-out commit keeps GitX's
#fca64fand is the only filled node in the table, soit reads down the whole gutter without depending on the ring's colour.
HEAD reaches the delegate as an
ObjectIdnow, not only as a branch name.HeadState::target()is what GitX compares against, and it is the only thing still sayingwhere 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
segmentsno 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.rscarries the pixel-level half: a line from above endsexactly 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 --workspacegreen;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.mdrecords the details the port turns on and thatsame check as the way to validate any future change here.