From 8301c2a5ff789c5e00e32967b6582e6425ae4884 Mon Sep 17 00:00:00 2001 From: Claas Date: Thu, 6 Aug 2026 01:50:25 +0200 Subject: [PATCH] Lay out the commit graph in Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine emits instructions rather than pixels: a row, a column, a lane index, and the line segments crossing each row. Colours stay a styling concern, so light and dark themes cost nothing on this side. Rows are ordered by a lazy topological sort. The textbook approach — count every commit's children, then drain the zeroes — cannot stream, because nothing can be drawn until the last commit of a million-commit repository has been visited. Instead commits are loaded newest-first into a frontier, and a commit becomes emittable once everything that could still turn out to be its child has been loaded. Committer dates are not monotonic, so a skew window absorbs rebases and wrong clocks, and a pending ceiling stops a pathological history from stalling the walk or exhausting memory. Giving up on perfect ordering is reported through is_degraded rather than silently drawing a wrong graph. Columns are held by reservations, with the first parent inheriting its child's column so branches run straight down. Columns are reused but never shifted, which keeps pass-through lines vertical and lets a row be drawn without knowing anything about its neighbours. Measured on real repositories, the first 200 rows take 1.3-2.4ms whether the history is 4,800 commits or 58,000 — the lookahead is bounded by the skew window, not by how long the history is. A test asserts that property directly. 55 tests: the ordering against hand-built histories with no repository involved, the column rules in isolation, and both against the shapes real git produces — merges, octopus merges, criss-crosses, annotated tags, detached HEAD, and commits backdated before their parents. The topological property is checked against git's own answer for a commit's parents rather than against our own output. NOTICE.md credits Pierre Vigier's article, which the algorithms are written from, and records that its reference implementation gitamine is GPL-3.0 and was therefore never read or ported. Co-Authored-By: Claude Opus 5 --- NOTICE.md | 40 ++ crates/gigit-git/examples/walk.rs | 125 ++++++ crates/gigit-git/src/error.rs | 3 + crates/gigit-git/src/graph/layout.rs | 492 +++++++++++++++++++++ crates/gigit-git/src/graph/mod.rs | 283 ++++++++++++ crates/gigit-git/src/graph/order.rs | 638 +++++++++++++++++++++++++++ crates/gigit-git/src/lib.rs | 2 + crates/gigit-git/src/reference.rs | 2 +- crates/gigit-git/src/repository.rs | 8 + crates/gigit-git/src/testing.rs | 65 ++- crates/gigit-git/tests/graph.rs | 351 +++++++++++++++ 11 files changed, 2006 insertions(+), 3 deletions(-) create mode 100644 NOTICE.md create mode 100644 crates/gigit-git/examples/walk.rs create mode 100644 crates/gigit-git/src/graph/layout.rs create mode 100644 crates/gigit-git/src/graph/mod.rs create mode 100644 crates/gigit-git/src/graph/order.rs create mode 100644 crates/gigit-git/tests/graph.rs diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..46dddc0 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,40 @@ +# Notices and attribution + +gigit is licensed under the MIT licence. This file records the work it builds +on and the terms that work carries. + +## Commit graph layout + +The approach to laying out the commit graph — assigning each commit a row by a +topological order that prefers newer commits, then assigning columns by keeping +a list of active branches and letting the first parent continue its child's +column — follows the algorithms described in: + +> Pierre Vigier, *Commit graph drawing algorithms* (2019) +> + +The implementation in `crates/gigit-git/src/graph` was written from that +article's description. It is not a port, and it differs from the article in two +ways worth naming: + +- The row order is computed lazily so rows can be streamed before the whole + history has been visited, rather than sorting the complete graph up front. +- The column assignment does not compute the article's set of forbidden + columns. That refinement reduces edge crossings; without it the graph is + still correct, just occasionally busier than it needs to be. Tracked in + . + +The article's reference implementation, **gitamine** +(), is licensed under the **GPL-3.0**. +gigit is MIT licensed, so no gitamine source code has been read, copied, or +adapted. It is credited here as the prior art that made the article's ideas +concrete. + +## Dependencies + +Rust and JavaScript dependencies carry their own licences; see `Cargo.toml`, +`package.json`, and their respective lockfiles. Notably: + +- [gitoxide (`gix`)](https://github.com/GitoxideLabs/gitoxide) — MIT or Apache-2.0 +- [`@pierre/diffs`](https://github.com/pierrecomputer/pierre) — Apache-2.0 +- [`@claas.dev/material-tailwind`](https://github.com/SantaClaas/material-tailwind) — Apache-2.0 diff --git a/crates/gigit-git/examples/walk.rs b/crates/gigit-git/examples/walk.rs new file mode 100644 index 0000000..586e20f --- /dev/null +++ b/crates/gigit-git/examples/walk.rs @@ -0,0 +1,125 @@ +//! Time the graph walk against a real repository, and optionally draw it. +//! +//! ```text +//! cargo run --release -p gigit-git --example walk -- /path/to/repository +//! cargo run --release -p gigit-git --example walk -- /path/to/repository 40 +//! ``` +//! +//! What matters is the first number: how long until there is something to +//! draw. It should stay flat as repositories get bigger, because the walk only +//! loads as far ahead as the skew window requires. +//! +//! Passing a row count draws that many rows as text. The engine emits +//! instructions rather than pixels, so this is one possible reading of them — +//! useful for seeing at a glance whether a layout change made the graph better +//! or worse. + +use std::time::Instant; + +use gigit_git::{DotKind, EdgeKind, GraphRow, Repository}; + +const CHUNK: usize = 200; + +fn main() -> Result<(), Box> { + let mut arguments = std::env::args().skip(1); + let path = arguments.next().unwrap_or_else(|| ".".to_owned()); + let draw: usize = arguments + .next() + .and_then(|count| count.parse().ok()) + .unwrap_or(0); + + if draw > 0 { + let repository = Repository::discover(&path)?; + let mut walk = repository.graph()?; + + for row in walk.next_chunk(draw)? { + println!("{}", render(&row)); + } + + return Ok(()); + } + + time(&path) +} + +/// Draw one row as text: the lanes, then the commit. +fn render(row: &GraphRow) -> String { + let mut lanes = vec![' '; usize::from(row.width) * 2]; + + for edge in &row.edges { + let at = usize::from(edge.from_column) * 2; + let symbol = match edge.kind { + EdgeKind::Through => '│', + EdgeKind::ToCommit if edge.from_column == row.column => '│', + EdgeKind::ToCommit if edge.from_column > row.column => '╯', + EdgeKind::ToCommit => '╰', + EdgeKind::FromCommit => continue, + }; + + lanes[at] = symbol; + } + + let dot = match row.dot { + DotKind::Merge => '◍', + DotKind::Root => '◉', + DotKind::Normal => '●', + }; + lanes[usize::from(row.column) * 2] = dot; + + let refs: String = row + .refs + .iter() + .map(|badge| format!(" ({})", badge.shorthand)) + .collect(); + + format!( + "{:<24} {} lane {:>2}{} {}", + lanes.iter().collect::(), + &row.id[..7], + row.lane, + refs, + row.summary.chars().take(48).collect::(), + ) +} + +fn time(path: &str) -> Result<(), Box> { + let opened = Instant::now(); + let repository = Repository::discover(path)?; + let mut walk = repository.graph()?; + println!("opened {path} in {:?}", opened.elapsed()); + + let first = Instant::now(); + let chunk = walk.next_chunk(CHUNK)?; + println!( + "first {} rows in {:?} <- time to something on screen", + chunk.len(), + first.elapsed() + ); + + let rest = Instant::now(); + let mut total = chunk.len(); + let mut widest = chunk.iter().map(|row| row.width).max().unwrap_or(0); + + loop { + let chunk = walk.next_chunk(CHUNK)?; + if chunk.is_empty() { + break; + } + + total += chunk.len(); + widest = widest.max(chunk.iter().map(|row| row.width).max().unwrap_or(0)); + } + + println!( + "remaining {} rows in {:?}", + total - CHUNK.min(total), + rest.elapsed() + ); + println!("{total} rows, widest {widest} columns"); + + if walk.is_degraded() { + println!("note: history was skewed enough to fall back on approximate ordering"); + } + + Ok(()) +} diff --git a/crates/gigit-git/src/error.rs b/crates/gigit-git/src/error.rs index 830e0e5..81e0d45 100644 --- a/crates/gigit-git/src/error.rs +++ b/crates/gigit-git/src/error.rs @@ -25,4 +25,7 @@ pub enum Error { #[error("could not list references")] References(#[source] Box), + + #[error("could not walk the commit graph")] + Graph(#[source] Box), } diff --git a/crates/gigit-git/src/graph/layout.rs b/crates/gigit-git/src/graph/layout.rs new file mode 100644 index 0000000..d8df192 --- /dev/null +++ b/crates/gigit-git/src/graph/layout.rs @@ -0,0 +1,492 @@ +//! Deciding which column each commit sits in, and what lines cross each row. +//! +//! Columns are held by *reservations*: when a commit is placed, it reserves a +//! column for each of its parents, and whichever parent eventually arrives +//! takes that column over. The first parent inherits the commit's own column, +//! which is what keeps a branch running straight down the screen instead of +//! wandering sideways. Other parents get a column of their own, so a merge +//! visibly forks. +//! +//! A column is freed the moment its reservation is taken up, and free columns +//! are reused left-first, so the graph stays narrow. Columns are never shifted, +//! only reused — that keeps every line that is merely passing through a row +//! perfectly vertical, and means a row can be drawn knowing nothing about the +//! rows around it. +//! +//! This is a simpler scheme than the one described in Pierre Vigier's article +//! (see `NOTICE.md`), which additionally computes a set of forbidden columns to +//! avoid edges crossing where a cheap alternative exists. That refinement would +//! slot in at [`ColumnLayout::choose_column`]; it improves how the graph looks +//! but is not needed for it to be correct. Tracked, along with the settings +//! surface it wants to be switchable from, in +//! . + +use gix::ObjectId; +use serde::Serialize; + +/// How many distinct lane colours the frontend cycles through. The engine only +/// ever emits an index — the actual colours are a styling concern. +const LANE_COUNT: u16 = 10; + +/// What a commit looks like on its row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum DotKind { + /// Exactly one parent. + Normal, + /// More than one parent. + Merge, + /// No parents — the beginning of a history. + Root, +} + +impl DotKind { + fn for_parents(parents: &[ObjectId]) -> Self { + match parents.len() { + 0 => Self::Root, + 1 => Self::Normal, + _ => Self::Merge, + } + } +} + +/// How a line relates to the commit on the row it crosses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum EdgeKind { + /// Comes down from a child and ends at this commit's dot. + ToCommit, + /// Leaves this commit's dot heading down towards a parent. + FromCommit, + /// Another branch passing by, untouched by this commit. + Through, +} + +/// One line segment crossing a single row. +/// +/// Columns are measured at the row's edges: `from_column` where the line enters +/// at the top, `to_column` where it leaves at the bottom. A `Through` segment +/// always has both the same, so it draws as a straight vertical line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Edge { + pub from_column: u16, + pub to_column: u16, + pub lane: u16, + pub kind: EdgeKind, +} + +/// Where a commit ended up, and what its row looks like. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Placement { + pub column: u16, + pub lane: u16, + pub dot: DotKind, + pub edges: Vec, + /// How many columns are in use across this row, so the frontend knows how + /// wide to draw without measuring anything. + pub width: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Reservation { + /// The commit expected to take this column over. + id: ObjectId, + lane: u16, +} + +#[derive(Debug, Default)] +pub(crate) struct ColumnLayout { + columns: Vec>, + next_lane: u16, +} + +impl ColumnLayout { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Place a commit and describe its row. + pub(crate) fn place(&mut self, id: &ObjectId, parents: &[ObjectId]) -> Placement { + // Every line that exists as the row begins. Done first, because + // reserving columns for parents changes the picture underneath. + let mut edges = self.lines_entering_the_row(id); + + let (column, lane) = self.choose_column(id); + + // Whichever column was chosen, every other reservation for this commit + // is now satisfied and released; its edge was already recorded above. + self.release_reservations_for(id); + + for (position, parent) in parents.iter().enumerate() { + let is_first_parent = position == 0; + let target = self.reserve_for_parent(parent, column, lane, is_first_parent); + + edges.push(Edge { + from_column: column, + to_column: target, + lane: self.columns[usize::from(target)] + .expect("just reserved") + .lane, + kind: EdgeKind::FromCommit, + }); + } + + self.trim_trailing_free_columns(); + + // Every edge recorded before the commit was placed pointed at a column + // that was not yet known; fix up the ones that end at the dot. + for edge in &mut edges { + if edge.kind == EdgeKind::ToCommit { + edge.to_column = column; + } + } + + Placement { + column, + lane, + dot: DotKind::for_parents(parents), + edges, + width: u16::try_from(self.columns.len()) + .unwrap_or(u16::MAX) + .max(column + 1), + } + } + + /// The lines already in flight as this row starts: ones ending at this + /// commit, and ones merely passing by. + fn lines_entering_the_row(&self, id: &ObjectId) -> Vec { + self.columns + .iter() + .enumerate() + .filter_map(|(index, reservation)| { + let reservation = (*reservation)?; + let column = u16::try_from(index).ok()?; + + Some(if reservation.id == *id { + Edge { + from_column: column, + // Filled in once the commit's own column is known. + to_column: column, + lane: reservation.lane, + kind: EdgeKind::ToCommit, + } + } else { + Edge { + from_column: column, + to_column: column, + lane: reservation.lane, + kind: EdgeKind::Through, + } + }) + }) + .collect() + } + + /// Take the leftmost column reserved for this commit, or a free one. + /// + /// Taking the leftmost keeps a branch that was reserved from several places + /// as far left as it can be, which is what stops long-lived branches from + /// drifting rightwards over time. + fn choose_column(&mut self, id: &ObjectId) -> (u16, u16) { + let reserved = self.columns.iter().enumerate().find_map(|(index, slot)| { + let slot = (*slot)?; + (slot.id == *id).then_some((index, slot.lane)) + }); + + if let Some((index, lane)) = reserved { + return (u16::try_from(index).unwrap_or(u16::MAX), lane); + } + + // Nothing was expecting this commit, so it starts a new strand: a ref + // tip, or the first commit of an unmerged branch. + let lane = self.allocate_lane(); + (self.claim_free_column(), lane) + } + + /// Reserve a column for a parent and return where it went. + fn reserve_for_parent( + &mut self, + parent: &ObjectId, + commit_column: u16, + commit_lane: u16, + is_first_parent: bool, + ) -> u16 { + // Another child already reserved a column for this parent, so route + // there rather than opening a second column for the same commit. + let existing = self + .columns + .iter() + .position(|slot| slot.is_some_and(|reservation| reservation.id == *parent)); + + if let Some(index) = existing { + return u16::try_from(index).unwrap_or(u16::MAX); + } + + // The first parent continues this commit's line, so it inherits both + // the column and the colour. That is what makes branches straight. + let (column, lane) = if is_first_parent { + (commit_column, commit_lane) + } else { + (self.claim_free_column(), self.allocate_lane()) + }; + + let index = usize::from(column); + if index >= self.columns.len() { + self.columns.resize(index + 1, None); + } + self.columns[index] = Some(Reservation { id: *parent, lane }); + + column + } + + fn release_reservations_for(&mut self, id: &ObjectId) { + for slot in &mut self.columns { + if slot.is_some_and(|reservation| reservation.id == *id) { + *slot = None; + } + } + } + + /// The leftmost unused column, growing the row only when it has to. + fn claim_free_column(&mut self) -> u16 { + match self.columns.iter().position(Option::is_none) { + Some(index) => u16::try_from(index).unwrap_or(u16::MAX), + None => { + self.columns.push(None); + u16::try_from(self.columns.len() - 1).unwrap_or(u16::MAX) + } + } + } + + fn allocate_lane(&mut self) -> u16 { + let lane = self.next_lane; + self.next_lane = (self.next_lane + 1) % LANE_COUNT; + + lane + } + + /// Keep the row as narrow as the content requires. Only trailing columns + /// are dropped, so no existing column ever moves. + fn trim_trailing_free_columns(&mut self) { + while self.columns.last().is_some_and(Option::is_none) { + self.columns.pop(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(byte: u8) -> ObjectId { + ObjectId::Sha1([byte; 20]) + } + + /// Place commits in the given order, each with the given parents. + fn lay_out(history: &[(u8, &[u8])]) -> Vec { + let mut layout = ColumnLayout::new(); + + history + .iter() + .map(|(commit, parents)| { + let parents: Vec = parents.iter().copied().map(id).collect(); + layout.place(&id(*commit), &parents) + }) + .collect() + } + + #[test] + fn a_linear_history_stays_in_one_column() { + let rows = lay_out(&[(3, &[2]), (2, &[1]), (1, &[])]); + + assert!(rows.iter().all(|row| row.column == 0)); + assert!(rows.iter().all(|row| row.width == 1)); + } + + #[test] + fn a_linear_history_keeps_one_colour() { + let rows = lay_out(&[(3, &[2]), (2, &[1]), (1, &[])]); + let first = rows[0].lane; + + assert!( + rows.iter().all(|row| row.lane == first), + "a branch running straight down should not change colour" + ); + } + + #[test] + fn dots_reflect_the_number_of_parents() { + let rows = lay_out(&[(4, &[2, 3]), (3, &[1]), (2, &[1]), (1, &[])]); + + assert_eq!(rows[0].dot, DotKind::Merge); + assert_eq!(rows[1].dot, DotKind::Normal); + assert_eq!(rows[3].dot, DotKind::Root); + } + + #[test] + fn a_merge_sends_its_second_parent_to_a_new_column() { + let rows = lay_out(&[(4, &[2, 3]), (3, &[1]), (2, &[1]), (1, &[])]); + let merge = &rows[0]; + + assert_eq!(merge.column, 0); + + let leaving: Vec = merge + .edges + .iter() + .filter(|edge| edge.kind == EdgeKind::FromCommit) + .map(|edge| edge.to_column) + .collect(); + + assert_eq!( + leaving, + vec![0, 1], + "the first parent continues the column, the second forks right" + ); + } + + #[test] + fn the_first_parent_inherits_the_colour_and_the_second_does_not() { + let rows = lay_out(&[(4, &[2, 3]), (3, &[1]), (2, &[1]), (1, &[])]); + let merge = &rows[0]; + + let leaving: Vec<&Edge> = merge + .edges + .iter() + .filter(|edge| edge.kind == EdgeKind::FromCommit) + .collect(); + + assert_eq!(leaving[0].lane, merge.lane, "the branch continues"); + assert_ne!(leaving[1].lane, merge.lane, "the fork is a new strand"); + } + + #[test] + fn a_column_is_reused_once_its_branch_has_ended() { + // The side branch occupies column 1, then merges away; a later + // unrelated tip should take column 1 back rather than open column 2. + let rows = lay_out(&[ + (5, &[2, 3]), // merge, reserves 0 for 2 and 1 for 3 + (3, &[1]), // side branch, column 1, reserves 1 for 1 + (2, &[1]), // main branch, column 0, routes to the existing 1 + (1, &[]), // shared parent, frees everything + (9, &[]), // an unrelated tip + ]); + + assert_eq!(rows[4].column, 0, "a freed column should be reused"); + assert_eq!(rows[4].width, 1); + } + + #[test] + fn two_children_of_one_parent_route_to_the_same_column() { + // Both 3 and 2 have parent 1. The second one to be placed must route + // into the column already reserved for 1, not open another. + let rows = lay_out(&[(3, &[1]), (2, &[1]), (1, &[])]); + + let target = |row: &Placement| { + row.edges + .iter() + .find(|edge| edge.kind == EdgeKind::FromCommit) + .expect("should point at its parent") + .to_column + }; + + assert_eq!( + target(&rows[0]), + target(&rows[1]), + "one parent means one column" + ); + assert_eq!(rows[2].column, target(&rows[0])); + } + + #[test] + fn lines_passing_a_row_are_vertical() { + let rows = lay_out(&[(5, &[2, 3]), (3, &[1]), (2, &[1]), (1, &[])]); + + for row in &rows { + for edge in &row.edges { + if edge.kind == EdgeKind::Through { + assert_eq!( + edge.from_column, edge.to_column, + "a line merely passing a row must not bend" + ); + } + } + } + } + + #[test] + fn incoming_lines_end_at_the_commits_dot() { + // Row 1 is the side branch in column 1; the merge above reserved it. + let rows = lay_out(&[(5, &[2, 3]), (3, &[1]), (2, &[1]), (1, &[])]); + + for row in &rows { + for edge in &row.edges { + if edge.kind == EdgeKind::ToCommit { + assert_eq!( + edge.to_column, row.column, + "a line arriving at a commit must end at its dot" + ); + } + } + } + } + + #[test] + fn a_root_commit_leaves_nothing_behind() { + let rows = lay_out(&[(1, &[])]); + + assert!( + rows[0] + .edges + .iter() + .all(|edge| edge.kind != EdgeKind::FromCommit), + "a root has no parents to point at" + ); + assert_eq!(rows[0].width, 1); + } + + #[test] + fn an_octopus_merge_forks_once_per_extra_parent() { + let rows = lay_out(&[(5, &[2, 3, 4]), (4, &[1]), (3, &[1]), (2, &[1]), (1, &[])]); + + let leaving: Vec = rows[0] + .edges + .iter() + .filter(|edge| edge.kind == EdgeKind::FromCommit) + .map(|edge| edge.to_column) + .collect(); + + assert_eq!(leaving, vec![0, 1, 2]); + assert_eq!(rows[0].width, 3); + } + + #[test] + fn the_width_covers_every_column_the_row_uses() { + let rows = lay_out(&[(5, &[2, 3, 4]), (4, &[1]), (3, &[1]), (2, &[1]), (1, &[])]); + + for row in &rows { + assert!(row.width > row.column, "the dot must fit inside the width"); + + for edge in &row.edges { + assert!( + edge.from_column < row.width && edge.to_column < row.width, + "every edge must fit inside the width" + ); + } + } + } + + #[test] + fn lanes_stay_within_the_palette() { + // More strands than there are lane colours, so the palette wraps. + let history: Vec<(u8, &[u8])> = (1..=25u8).map(|commit| (commit, &[] as &[u8])).collect(); + + for row in lay_out(&history) { + assert!( + row.lane < LANE_COUNT, + "lane {} is off the palette", + row.lane + ); + } + } +} diff --git a/crates/gigit-git/src/graph/mod.rs b/crates/gigit-git/src/graph/mod.rs new file mode 100644 index 0000000..3bcd7de --- /dev/null +++ b/crates/gigit-git/src/graph/mod.rs @@ -0,0 +1,283 @@ +//! Turning a repository's history into rows that can be drawn. +//! +//! The engine emits *instructions*, never pixels and never colours: a row +//! index, a column, a lane number, and the line segments that cross the row. +//! Choosing what a lane looks like is the frontend's business. +//! +//! Rows come out one at a time and only as far as they are asked for, so the +//! first screenful of a very large repository is ready almost immediately. See +//! [`order`] for how that is possible without walking the whole history first. + +mod layout; +mod order; + +use std::collections::HashMap; + +use gix::ObjectId; +use serde::Serialize; + +use crate::{Error, RefKind, Result}; + +use layout::ColumnLayout; +use order::{CommitOrder, CommitSource, LoadedCommit}; + +pub use layout::{DotKind, Edge, EdgeKind}; + +/// A ref pointing at a commit, ready to be drawn as a badge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RefBadge { + /// The name as a human would write it, e.g. `main` or `origin/main`. + pub shorthand: String, + pub kind: RefKind, + /// Whether HEAD is currently on this ref. + pub is_head: bool, +} + +/// Everything needed to draw one row of the graph. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphRow { + pub id: String, + /// Position from the top, starting at zero. + pub row: u32, + pub column: u16, + /// An index into the frontend's lane palette, not a colour. + pub lane: u16, + pub dot: DotKind, + /// Line segments crossing this row. + pub edges: Vec, + /// How many columns this row spans. + pub width: u16, + /// First line of the commit message. + pub summary: String, + pub author: String, + /// Committer time, seconds since the epoch. + pub time: i64, + pub refs: Vec, +} + +/// The parts of a commit that only the row needs, kept until it is emitted. +#[derive(Debug, Clone)] +struct Details { + summary: String, + author: String, +} + +/// Reads commits and remembers the parts the row will need. +/// +/// Borrows only the two fields it uses so that the order, which is borrowed +/// mutably at the same time, stays disjoint from it. +struct Source<'a> { + repository: &'a gix::Repository, + details: &'a mut HashMap, +} + +impl CommitSource for Source<'_> { + type Error = Error; + + fn load(&mut self, id: &ObjectId) -> Result { + let commit = self + .repository + .find_commit(*id) + .map_err(|source| Error::Graph(Box::new(source)))?; + + let time = commit + .time() + .map_err(|source| Error::Graph(Box::new(source)))? + .seconds; + + let parents: Vec = commit.parent_ids().map(|parent| parent.detach()).collect(); + + let summary = commit + .message() + .map_err(|source| Error::Graph(Box::new(source)))? + .summary() + .to_string(); + + let author = commit + .author() + .map_err(|source| Error::Graph(Box::new(source)))? + .name + .to_string(); + + self.details.insert(*id, Details { summary, author }); + + Ok(LoadedCommit { time, parents }) + } +} + +/// A lazy walk over a repository's history, producing drawable rows. +pub struct GraphWalk { + // Owned rather than borrowed so a walk can outlive the call that made it — + // the actor holds one across many requests for more rows. Cloning a gix + // repository shares the object database rather than reopening it. + repository: gix::Repository, + order: CommitOrder, + layout: ColumnLayout, + details: HashMap, + refs: HashMap>, + row: u32, + exhausted: bool, +} + +impl GraphWalk { + pub(crate) fn new(repository: gix::Repository) -> Result { + let mut walk = Self { + repository, + order: CommitOrder::new(), + layout: ColumnLayout::new(), + details: HashMap::new(), + refs: HashMap::new(), + row: 0, + exhausted: false, + }; + + walk.collect_refs()?; + walk.seed()?; + + Ok(walk) + } + + /// Whether every commit has been emitted. + pub fn is_exhausted(&self) -> bool { + self.exhausted + } + + /// Whether the walk had to give up on perfect ordering. + /// + /// Only happens in a history skewed far beyond ordinary clock drift; the + /// graph is still drawable, but some commits may sit a row or two off. + pub fn is_degraded(&self) -> bool { + self.order.is_degraded() + } + + /// Take up to `limit` more rows. + /// + /// Returns fewer than asked for only at the end of the history. + pub fn next_chunk(&mut self, limit: usize) -> Result> { + let mut rows = Vec::with_capacity(limit); + + for _ in 0..limit { + match self.next_row()? { + Some(row) => rows.push(row), + None => break, + } + } + + Ok(rows) + } + + fn next_row(&mut self) -> Result> { + let mut source = Source { + repository: &self.repository, + details: &mut self.details, + }; + + let Some(emitted) = self.order.next(&mut source)? else { + self.exhausted = true; + return Ok(None); + }; + + let placement = self.layout.place(&emitted.id, &emitted.parents); + let details = self.details.remove(&emitted.id).unwrap_or_else(|| Details { + summary: String::new(), + author: String::new(), + }); + + let row = GraphRow { + id: emitted.id.to_string(), + row: self.row, + column: placement.column, + lane: placement.lane, + dot: placement.dot, + edges: placement.edges, + width: placement.width, + summary: details.summary, + author: details.author, + time: emitted.time, + refs: self.refs.get(&emitted.id).cloned().unwrap_or_default(), + }; + + self.row += 1; + + Ok(Some(row)) + } + + /// Index every ref by the commit it ultimately points at. + /// + /// Annotated tags are peeled here — a tag object is not a commit, and the + /// badge belongs on the commit the tag wraps. + fn collect_refs(&mut self) -> Result<()> { + let head = self.repository.head_name().ok().flatten(); + + let platform = self + .repository + .references() + .map_err(|source| Error::References(Box::new(source)))?; + + let iter = platform + .all() + .map_err(|source| Error::References(Box::new(source)))?; + + for reference in iter { + let reference = reference.map_err(Error::References)?; + + let name = reference.name().as_bstr().to_string(); + let shorthand = reference.name().shorten().to_string(); + let is_head = head + .as_ref() + .is_some_and(|head| head.as_bstr() == name.as_bytes()); + + // A ref that will not peel to an object — a dangling symbolic ref, + // say — simply gets no badge rather than failing the whole walk. + let Ok(target) = reference.into_fully_peeled_id() else { + continue; + }; + + self.refs + .entry(target.detach()) + .or_default() + .push(RefBadge { + shorthand, + kind: RefKind::from_full_name(&name), + is_head, + }); + } + + Ok(()) + } + + /// Start the walk from every ref, plus HEAD in case it is detached. + fn seed(&mut self) -> Result<()> { + let mut tips: Vec = self.refs.keys().copied().collect(); + + // Sorted so a repository always produces the same graph, rather than + // one that depends on hash map iteration order. + tips.sort_unstable(); + + if let Ok(head) = self.repository.head_id() { + let head = head.detach(); + if !tips.contains(&head) { + tips.insert(0, head); + } + } + + for tip in tips { + // A ref can point at a tree or a blob; those are not part of the + // commit graph and are skipped rather than treated as an error. + if self.repository.find_commit(tip).is_err() { + continue; + } + + let mut source = Source { + repository: &self.repository, + details: &mut self.details, + }; + + self.order.seed(tip, &mut source)?; + } + + Ok(()) + } +} diff --git a/crates/gigit-git/src/graph/order.rs b/crates/gigit-git/src/graph/order.rs new file mode 100644 index 0000000..d52bae3 --- /dev/null +++ b/crates/gigit-git/src/graph/order.rs @@ -0,0 +1,638 @@ +//! Deciding which commit gets the next row. +//! +//! The graph needs a topological order — a commit may only be placed once every +//! one of its children has been placed, because a commit takes over a column +//! that one of its children reserved for it. Within that constraint we want the +//! newest commits first, so the graph reads like `git log`. +//! +//! The obvious way to get that is to walk the whole history, count how many +//! children each commit has, and then drain commits whose count has reached +//! zero. That cannot stream: nothing can be drawn until the last commit of a +//! million-commit repository has been visited. +//! +//! So this does it lazily. Commits are loaded newest-first into a *frontier*, +//! and a commit becomes emittable once every commit that could still turn out +//! to be its child has already been loaded. A child is normally newer than its +//! parent, so "everything newer has been loaded" is the condition — which holds +//! as soon as the frontier's newest unexpanded commit is older than the +//! candidate. That lets the first rows come out after loading a handful of +//! commits rather than all of them. +//! +//! Committer dates are not actually monotonic, though: rebases, cherry-picks +//! and plain wrong clocks all produce a child that looks older than its parent. +//! [`SKEW_TOLERANCE_SECONDS`] widens the window so ordinary skew is absorbed, +//! and [`MAX_PENDING`] caps how much can pile up before the order gives up on +//! being perfect rather than stalling or exhausting memory. + +use std::cmp::Ordering; +use std::collections::{BinaryHeap, HashMap}; + +use gix::ObjectId; + +/// How far past a candidate the frontier must reach before the candidate is +/// considered safe to emit. A day covers routine clock skew and rebases +/// without making the walk load meaningfully more than it would anyway. +const SKEW_TOLERANCE_SECONDS: i64 = 24 * 60 * 60; + +/// A ceiling on commits loaded but not yet emitted. Reaching it means the +/// history is skewed beyond what the tolerance absorbs; the order then emits +/// its best candidate instead of loading forever. +const MAX_PENDING: usize = 50_000; + +/// What a caller has to load for the order to place a commit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedCommit { + /// Committer time in seconds since the epoch. Committer rather than author + /// time, because that is the one that tracks when the commit entered this + /// history. + pub time: i64, + pub parents: Vec, +} + +/// Where the commits come from. +/// +/// A trait rather than a closure so the ordering can be tested against +/// hand-built histories with no repository involved. +pub(crate) trait CommitSource { + type Error; + + fn load(&mut self, id: &ObjectId) -> Result; +} + +/// A commit that has been placed, handed back with what the layout needs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Emitted { + pub id: ObjectId, + pub parents: Vec, + pub time: i64, +} + +/// A heap entry. Ordered newest first, with the id breaking ties so that a +/// history with identical timestamps still comes out in a stable order. +#[derive(Debug, PartialEq, Eq)] +struct Slot { + time: i64, + id: ObjectId, +} + +impl Ord for Slot { + fn cmp(&self, other: &Self) -> Ordering { + self.time + .cmp(&other.time) + .then_with(|| self.id.cmp(&other.id)) + } +} + +impl PartialOrd for Slot { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Debug)] +struct Pending { + time: i64, + parents: Vec, + /// Children discovered so far that have not been emitted yet. A commit can + /// only be emitted at zero. + unemitted_children: usize, + /// Whether this commit's parents have been linked back to it. + expanded: bool, +} + +#[derive(Debug)] +pub(crate) struct CommitOrder { + pending: HashMap, + /// Loaded but not yet expanded, newest first. + frontier: BinaryHeap, + /// Expanded with no unemitted children left, newest first. + ready: BinaryHeap, + skew_tolerance_seconds: i64, + max_pending: usize, + /// Set when the pending ceiling forced an emit that the date order alone + /// would not have allowed. Surfaced so the caller can report it rather than + /// quietly drawing a slightly wrong graph. + degraded: bool, +} + +impl CommitOrder { + pub(crate) fn new() -> Self { + Self::with_limits(SKEW_TOLERANCE_SECONDS, MAX_PENDING) + } + + /// Tighter limits than the defaults, so tests can reach the degraded path + /// without building fifty thousand commits. + pub(crate) fn with_limits(skew_tolerance_seconds: i64, max_pending: usize) -> Self { + Self { + pending: HashMap::new(), + frontier: BinaryHeap::new(), + ready: BinaryHeap::new(), + skew_tolerance_seconds, + max_pending, + degraded: false, + } + } + + /// Whether the pending ceiling was ever hit. + pub(crate) fn is_degraded(&self) -> bool { + self.degraded + } + + /// Add a starting point — a ref tip, or HEAD. + /// + /// Seeding the same commit twice is harmless; the second is ignored. + pub(crate) fn seed( + &mut self, + id: ObjectId, + source: &mut S, + ) -> Result<(), S::Error> { + self.discover(id, source) + } + + /// The next commit to place, or `None` once the history is exhausted. + pub(crate) fn next( + &mut self, + source: &mut S, + ) -> Result, S::Error> { + loop { + self.discard_stale_ready(); + + let candidate = self.ready.peek().map(|slot| slot.time); + let unexpanded = self.frontier.peek().map(|slot| slot.time); + + match (candidate, unexpanded) { + // Something unexpanded is still new enough that it could turn + // out to be a child of the candidate, so the candidate is not + // safe yet — unless we have already loaded far too much. + (Some(candidate), Some(unexpanded)) + if unexpanded >= candidate - self.skew_tolerance_seconds => + { + if self.pending.len() >= self.max_pending { + self.degraded = true; + return Ok(Some(self.take_ready())); + } + + self.expand(source)?; + } + (Some(_), _) => return Ok(Some(self.take_ready())), + // Nothing is ready yet, but there is more to load. + (None, Some(_)) => self.expand(source)?, + (None, None) => { + // Both heaps are empty. Anything still pending would mean a + // cycle, which git cannot produce, so there is nothing left. + debug_assert!( + self.pending.is_empty(), + "commits left pending with nothing to expand: {:?}", + self.pending.keys().collect::>() + ); + + return Ok(None); + } + } + } + } + + /// Load a commit if it is new, and put it in the frontier. + fn discover(&mut self, id: ObjectId, source: &mut S) -> Result<(), S::Error> { + if self.pending.contains_key(&id) { + return Ok(()); + } + + let loaded = source.load(&id)?; + self.frontier.push(Slot { + time: loaded.time, + id, + }); + self.pending.insert( + id, + Pending { + time: loaded.time, + parents: loaded.parents, + unemitted_children: 0, + expanded: false, + }, + ); + + Ok(()) + } + + /// Take the newest unexpanded commit and link it to its parents. + fn expand(&mut self, source: &mut S) -> Result<(), S::Error> { + let Some(Slot { id, .. }) = self.frontier.pop() else { + return Ok(()); + }; + + // The frontier can hold ids that were emitted in the meantime. + let Some(pending) = self.pending.get_mut(&id) else { + return Ok(()); + }; + if pending.expanded { + return Ok(()); + } + + pending.expanded = true; + let parents = pending.parents.clone(); + let ready_now = pending.unemitted_children == 0; + let time = pending.time; + + if ready_now { + self.ready.push(Slot { time, id }); + } + + for parent in parents { + self.discover(parent, source)?; + + if let Some(pending) = self.pending.get_mut(&parent) { + pending.unemitted_children += 1; + } + } + + Ok(()) + } + + /// Drop heap entries that are no longer emittable — already emitted, or + /// given a new child since being queued. + fn discard_stale_ready(&mut self) { + while let Some(slot) = self.ready.peek() { + let is_emittable = self + .pending + .get(&slot.id) + .is_some_and(|pending| pending.expanded && pending.unemitted_children == 0); + + if is_emittable { + return; + } + + self.ready.pop(); + } + } + + /// Emit the newest ready commit and release its parents. + /// + /// Only call with a non-empty `ready`, after [`Self::discard_stale_ready`]. + fn take_ready(&mut self) -> Emitted { + let slot = self + .ready + .pop() + .expect("caller checked that ready is filled"); + let pending = self + .pending + .remove(&slot.id) + .expect("a ready commit is always pending"); + + for parent_id in &pending.parents { + let Some(parent) = self.pending.get_mut(parent_id) else { + continue; + }; + + // A commit can list the same parent twice; saturating keeps that + // from underflowing. + parent.unemitted_children = parent.unemitted_children.saturating_sub(1); + + if parent.expanded && parent.unemitted_children == 0 { + self.ready.push(Slot { + time: parent.time, + id: *parent_id, + }); + } + } + + Emitted { + id: slot.id, + parents: pending.parents, + time: pending.time, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A hand-built history. Ids are single bytes so a test reads like a + /// diagram: `id(1)` is one commit, `id(2)` another. + #[derive(Default)] + struct Synthetic { + commits: HashMap, + loads: usize, + } + + impl Synthetic { + fn add(&mut self, commit: u8, time: i64, parents: &[u8]) -> &mut Self { + self.commits.insert( + id(commit), + LoadedCommit { + time, + parents: parents.iter().copied().map(id).collect(), + }, + ); + + self + } + + /// Every commit's children, for checking the topological property. + fn children_of(&self, wanted: &ObjectId) -> Vec { + self.commits + .iter() + .filter(|(_, commit)| commit.parents.contains(wanted)) + .map(|(id, _)| *id) + .collect() + } + } + + impl CommitSource for Synthetic { + type Error = (); + + fn load(&mut self, wanted: &ObjectId) -> Result { + self.loads += 1; + self.commits.get(wanted).cloned().ok_or(()) + } + } + + fn id(byte: u8) -> ObjectId { + ObjectId::Sha1([byte; 20]) + } + + /// Drain an order completely, given its tips. + fn walk(history: &mut Synthetic, tips: &[u8]) -> Vec { + let mut order = CommitOrder::new(); + for tip in tips { + order.seed(id(*tip), history).expect("tips should load"); + } + + let mut emitted = Vec::new(); + while let Some(commit) = order.next(history).expect("should walk") { + emitted.push(commit.id); + } + + emitted + } + + /// The property the layout depends on: a commit is never placed before one + /// of its children. + fn assert_children_come_first(history: &Synthetic, emitted: &[ObjectId]) { + let position = |wanted: &ObjectId| { + emitted + .iter() + .position(|candidate| candidate == wanted) + .unwrap_or_else(|| panic!("{wanted} was never emitted")) + }; + + for commit in emitted { + for child in history.children_of(commit) { + assert!( + position(&child) < position(commit), + "{child} is a child of {commit} but came after it" + ); + } + } + } + + #[test] + fn linear_history_comes_out_newest_first() { + let mut history = Synthetic::default(); + history.add(3, 300, &[2]).add(2, 200, &[1]).add(1, 100, &[]); + + assert_eq!(walk(&mut history, &[3]), vec![id(3), id(2), id(1)]); + } + + #[test] + fn a_root_commit_ends_the_walk() { + let mut history = Synthetic::default(); + history.add(1, 100, &[]); + + assert_eq!(walk(&mut history, &[1]), vec![id(1)]); + } + + #[test] + fn both_sides_of_a_merge_precede_their_shared_parent() { + // 4 merge + // / \ + // 2 3 + // \ / + // 1 + let mut history = Synthetic::default(); + history + .add(4, 400, &[2, 3]) + .add(3, 300, &[1]) + .add(2, 200, &[1]) + .add(1, 100, &[]); + + let emitted = walk(&mut history, &[4]); + + assert_eq!(emitted.first(), Some(&id(4)), "the merge is newest"); + assert_eq!(emitted.last(), Some(&id(1)), "the shared parent is oldest"); + assert_children_come_first(&history, &emitted); + } + + #[test] + fn an_octopus_merge_places_every_side_first() { + let mut history = Synthetic::default(); + history + .add(5, 500, &[2, 3, 4]) + .add(4, 400, &[1]) + .add(3, 300, &[1]) + .add(2, 200, &[1]) + .add(1, 100, &[]); + + let emitted = walk(&mut history, &[5]); + + assert_eq!(emitted.len(), 5); + assert_eq!(emitted.last(), Some(&id(1))); + assert_children_come_first(&history, &emitted); + } + + #[test] + fn criss_crossed_merges_stay_in_order() { + // Two merges that each take both branches, in opposite orders. + // 5 6 + // |\ /| + // | X | + // |/ \| + // 3 4 + // \ / + // 1 + let mut history = Synthetic::default(); + history + .add(6, 600, &[4, 3]) + .add(5, 500, &[3, 4]) + .add(4, 400, &[1]) + .add(3, 300, &[1]) + .add(1, 100, &[]); + + let emitted = walk(&mut history, &[5, 6]); + + assert_eq!(emitted.len(), 5); + assert_children_come_first(&history, &emitted); + } + + #[test] + fn several_tips_are_all_walked() { + let mut history = Synthetic::default(); + history + .add(4, 400, &[1]) + .add(3, 300, &[1]) + .add(2, 200, &[1]) + .add(1, 100, &[]); + + let emitted = walk(&mut history, &[2, 3, 4]); + + assert_eq!(emitted.len(), 4); + assert_eq!(emitted.last(), Some(&id(1))); + assert_children_come_first(&history, &emitted); + } + + #[test] + fn a_child_older_than_its_parent_still_comes_first() { + // A rebase or a wrong clock: commit 2 is a child of 1 but looks older. + // Date order alone would put 1 first, which would break the layout. + let mut history = Synthetic::default(); + history.add(2, 100, &[1]).add(1, 500, &[]); + + assert_eq!(walk(&mut history, &[2]), vec![id(2), id(1)]); + } + + #[test] + fn skew_across_a_merge_does_not_reorder_children_after_parents() { + // The whole right-hand branch is backdated well before its parent. + let mut history = Synthetic::default(); + history + .add(5, 900, &[2, 4]) + .add(4, 50, &[3]) + .add(3, 40, &[1]) + .add(2, 800, &[1]) + .add(1, 700, &[]); + + let emitted = walk(&mut history, &[5]); + + assert_eq!(emitted.len(), 5); + assert_children_come_first(&history, &emitted); + } + + #[test] + fn commits_sharing_a_timestamp_come_out_in_a_stable_order() { + let build = || { + let mut history = Synthetic::default(); + history + .add(4, 100, &[2, 3]) + .add(3, 100, &[1]) + .add(2, 100, &[1]) + .add(1, 100, &[]); + walk(&mut history, &[4]) + }; + + assert_eq!( + build(), + build(), + "the same history should always lay out the same" + ); + } + + #[test] + fn ordinary_history_is_not_reported_as_degraded() { + let mut history = Synthetic::default(); + history.add(3, 300, &[2]).add(2, 200, &[1]).add(1, 100, &[]); + + let mut order = CommitOrder::new(); + order.seed(id(3), &mut history).expect("should seed"); + while order.next(&mut history).expect("should walk").is_some() {} + + assert!(!order.is_degraded()); + } + + #[test] + fn hitting_the_pending_ceiling_is_reported_rather_than_stalling() { + // A wide fan of tips that all stay pending, with a ceiling low enough + // to be reached. The walk must still finish and still emit everything. + let mut history = Synthetic::default(); + for tip in 2..=9u8 { + history.add(tip, 1_000, &[1]); + } + history.add(1, 100, &[]); + + let mut order = CommitOrder::with_limits(0, 3); + for tip in 2..=9u8 { + order.seed(id(tip), &mut history).expect("should seed"); + } + + let mut emitted = Vec::new(); + while let Some(commit) = order.next(&mut history).expect("should walk") { + emitted.push(commit.id); + } + + assert_eq!(emitted.len(), 9, "every commit is still emitted"); + assert!( + order.is_degraded(), + "giving up on perfect ordering must be reported, not silent" + ); + } + + #[test] + fn a_commit_is_loaded_only_once() { + // Every commit below is reachable by two routes; loading is the + // expensive part of the walk, so it must not be repeated. + let mut history = Synthetic::default(); + history + .add(4, 400, &[2, 3]) + .add(3, 300, &[1]) + .add(2, 200, &[1]) + .add(1, 100, &[]); + + walk(&mut history, &[4]); + + assert_eq!(history.loads, 4); + } + + #[test] + fn only_what_is_asked_for_is_loaded() { + // The point of the lazy order: taking one row from a long history must + // not walk the whole thing. Commits a week apart, so the skew window + // covers only the newest of them. + let mut history = Synthetic::default(); + for step in 1..=50u8 { + let parents: &[u8] = if step == 1 { &[] } else { &[step - 1] }; + history.add(step, i64::from(step) * 7 * 24 * 60 * 60, parents); + } + + let mut order = CommitOrder::new(); + order.seed(id(50), &mut history).expect("should seed"); + order.next(&mut history).expect("should emit a row"); + + assert!( + history.loads < 5, + "emitting one row loaded {} of 50 commits", + history.loads + ); + } + + #[test] + fn the_lookahead_is_bounded_by_the_skew_window_not_by_history_size() { + // Commits close together in time all fall inside the skew window, so + // they are loaded before the first row comes out. This is the cost of + // tolerating skew, and it is bounded by the window rather than by how + // long the history is: doubling the history does not double the work. + let short = loads_for_first_row(20, 60); + let long = loads_for_first_row(200, 60); + + assert_eq!( + short, long, + "a history ten times longer should not cost ten times more to start" + ); + } + + /// Build a linear history of `count` commits `spacing` seconds apart and + /// report how many had to be loaded to produce a single row. + fn loads_for_first_row(count: u8, spacing: i64) -> usize { + let mut history = Synthetic::default(); + for step in 1..=count { + let parents: &[u8] = if step == 1 { &[] } else { &[step - 1] }; + history.add(step, i64::from(step) * spacing, parents); + } + + let mut order = CommitOrder::with_limits(5 * 60, usize::MAX); + order.seed(id(count), &mut history).expect("should seed"); + order.next(&mut history).expect("should emit a row"); + + history.loads + } +} diff --git a/crates/gigit-git/src/lib.rs b/crates/gigit-git/src/lib.rs index df33985..bf6d165 100644 --- a/crates/gigit-git/src/lib.rs +++ b/crates/gigit-git/src/lib.rs @@ -6,6 +6,7 @@ //! to own it from a thread of their own rather than share it. mod error; +mod graph; mod reference; mod repository; @@ -13,6 +14,7 @@ mod repository; pub mod testing; pub use error::Error; +pub use graph::{DotKind, Edge, EdgeKind, GraphRow, GraphWalk, RefBadge}; pub use reference::{RefEntry, RefKind}; pub use repository::{Head, Repository, RepositorySummary}; diff --git a/crates/gigit-git/src/reference.rs b/crates/gigit-git/src/reference.rs index e319c70..d382390 100644 --- a/crates/gigit-git/src/reference.rs +++ b/crates/gigit-git/src/reference.rs @@ -14,7 +14,7 @@ pub enum RefKind { } impl RefKind { - fn from_full_name(full_name: &str) -> Self { + pub(crate) fn from_full_name(full_name: &str) -> Self { if full_name.starts_with("refs/heads/") { Self::LocalBranch } else if full_name.starts_with("refs/remotes/") { diff --git a/crates/gigit-git/src/repository.rs b/crates/gigit-git/src/repository.rs index a4fc383..bd4d255 100644 --- a/crates/gigit-git/src/repository.rs +++ b/crates/gigit-git/src/repository.rs @@ -134,6 +134,14 @@ impl Repository { Ok(entries) } + /// Start walking the commit graph. + /// + /// The walk owns its own view of the repository so it can be kept across + /// calls while more rows are asked for. + pub fn graph(&self) -> Result { + crate::GraphWalk::new(self.inner.clone()) + } + /// Everything the UI needs on open, in one round trip. pub fn summary(&self) -> Result { Ok(RepositorySummary { diff --git a/crates/gigit-git/src/testing.rs b/crates/gigit-git/src/testing.rs index 19d6bc0..e909aa8 100644 --- a/crates/gigit-git/src/testing.rs +++ b/crates/gigit-git/src/testing.rs @@ -58,6 +58,49 @@ impl Fixture { self.git(&["branch", name]); } + /// Create a branch and switch to it. + pub fn branch_off(&self, name: &str) { + self.git(&["checkout", "-b", name]); + } + + pub fn checkout(&self, name: &str) { + self.git(&["checkout", name]); + } + + /// Merge one or more branches, always as a real merge commit so the + /// fixture's shape is the one the test asked for. Passing more than one + /// branch makes it an octopus merge. + pub fn merge(&self, branches: &[&str]) { + let mut arguments = vec!["merge", "--no-ff", "--no-edit"]; + arguments.extend_from_slice(branches); + + self.git(&arguments); + } + + /// Commit with a committer date of your choosing, for testing what happens + /// when the clock disagrees with the topology. + /// + /// `--date` only moves the author date, so the committer date — the one the + /// graph orders by — has to come from the environment. + pub fn commit_dated(&self, message: &str, date: &str) { + let file = self.path().join(format!("{message}.txt")); + std::fs::write(&file, message).expect("could not write a fixture file"); + + self.git(&["add", "."]); + self.run( + &["commit", "--message", message], + &[("GIT_COMMITTER_DATE", date)], + ); + } + + /// Ask git for a commit's parents, so tests can check the graph against + /// git's own answer rather than against the graph's. + pub fn parents_of(&self, id: &str) -> Vec { + let output = self.capture(&["log", "-1", "--format=%P", id]); + + output.split_whitespace().map(ToOwned::to_owned).collect() + } + pub fn tag(&self, name: &str) { self.git(&["tag", name]); } @@ -76,12 +119,28 @@ impl Fixture { } pub fn git(&self, arguments: &[&str]) { - let output = Command::new("git") + self.run(arguments, &[]); + } + + /// Run git and return its standard output, trimmed. + pub fn capture(&self, arguments: &[&str]) -> String { + self.run(arguments, &[]) + } + + fn run(&self, arguments: &[&str], environment: &[(&str, &str)]) -> String { + let mut command = Command::new("git"); + command .args(arguments) .current_dir(self.path()) // Keep the developer's own git config from changing the outcome. .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null"); + + for (name, value) in environment { + command.env(name, value); + } + + let output = command .output() .expect("could not run git — it must be installed to run these tests"); @@ -90,5 +149,7 @@ impl Fixture { "git {arguments:?} failed:\n{}", String::from_utf8_lossy(&output.stderr) ); + + String::from_utf8_lossy(&output.stdout).trim().to_owned() } } diff --git a/crates/gigit-git/tests/graph.rs b/crates/gigit-git/tests/graph.rs new file mode 100644 index 0000000..5770f1f --- /dev/null +++ b/crates/gigit-git/tests/graph.rs @@ -0,0 +1,351 @@ +//! The graph engine against repositories that real git built. +//! +//! The unit tests in `src/graph` cover the ordering and column rules against +//! hand-built histories. These check that the shapes git actually produces — +//! merges, octopus merges, criss-crosses — come out drawable. + +use std::collections::{HashMap, HashSet}; + +use gigit_git::testing::Fixture; +use gigit_git::{DotKind, EdgeKind, GraphRow, Repository}; + +/// Walk a fixture's whole history. +fn rows(fixture: &Fixture) -> Vec { + let repository = Repository::discover(fixture.path()).expect("should open"); + let mut walk = repository.graph().expect("should start a walk"); + + let mut all = Vec::new(); + loop { + let chunk = walk.next_chunk(64).expect("should produce rows"); + if chunk.is_empty() { + break; + } + all.extend(chunk); + } + + assert!(walk.is_exhausted(), "the walk should know it finished"); + assert!(!walk.is_degraded(), "a small fixture should never degrade"); + + all +} + +/// Map every commit to the row it landed on. +fn positions(rows: &[GraphRow]) -> HashMap<&str, usize> { + rows.iter() + .enumerate() + .map(|(index, row)| (row.id.as_str(), index)) + .collect() +} + +#[test] +fn a_linear_history_is_a_single_straight_column() { + let fixture = Fixture::with_one_commit(); + fixture.commit("second"); + fixture.commit("third"); + + let rows = rows(&fixture); + + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|row| row.column == 0)); + assert!(rows.iter().all(|row| row.width == 1)); + assert_eq!(rows[0].summary, "third", "newest first"); + assert_eq!(rows[2].dot, DotKind::Root); +} + +#[test] +fn rows_are_numbered_from_the_top_without_gaps() { + let fixture = Fixture::with_one_commit(); + fixture.commit("second"); + fixture.commit("third"); + + for (index, row) in rows(&fixture).iter().enumerate() { + assert_eq!(usize::try_from(row.row), Ok(index)); + } +} + +#[test] +fn a_merge_forks_and_rejoins() { + let fixture = Fixture::with_one_commit(); + fixture.branch_off("side"); + fixture.commit("on the side"); + fixture.checkout("main"); + fixture.commit("on main"); + fixture.merge(&["side"]); + + let rows = rows(&fixture); + + assert_eq!( + rows.len(), + 4, + "a shared base, one commit on each branch, and the merge" + ); + assert_eq!(rows[0].dot, DotKind::Merge); + assert_eq!( + rows[0] + .edges + .iter() + .filter(|edge| edge.kind == EdgeKind::FromCommit) + .count(), + 2, + "a merge points at both parents" + ); + assert!( + rows.iter().any(|row| row.column > 0), + "the side branch needs a column of its own" + ); +} + +#[test] +fn an_octopus_merge_is_drawn_with_every_side() { + let fixture = Fixture::with_one_commit(); + fixture.branch_off("one"); + fixture.commit("first side"); + fixture.checkout("main"); + fixture.branch_off("two"); + fixture.commit("second side"); + fixture.checkout("main"); + fixture.commit("on main"); + fixture.merge(&["one", "two"]); + + let rows = rows(&fixture); + let merge = &rows[0]; + + assert_eq!(merge.dot, DotKind::Merge); + assert_eq!( + merge + .edges + .iter() + .filter(|edge| edge.kind == EdgeKind::FromCommit) + .count(), + 3, + "three parents means three lines leaving the dot" + ); + assert!(merge.width >= 3); +} + +#[test] +fn criss_crossed_merges_are_drawable() { + // Two branches that each merge the other, which is the shape that breaks + // naive layouts. + let fixture = Fixture::with_one_commit(); + fixture.branch_off("left"); + fixture.commit("left one"); + fixture.checkout("main"); + fixture.commit("main one"); + fixture.branch_off("right"); + fixture.commit("right one"); + fixture.checkout("left"); + fixture.merge(&["right"]); + fixture.checkout("right"); + fixture.merge(&["left"]); + + let rows = rows(&fixture); + + assert!(rows.len() >= 6); + assert_eq!( + rows.iter().filter(|row| row.dot == DotKind::Merge).count(), + 2 + ); +} + +#[test] +fn every_commit_appears_below_all_of_its_children() { + // The property the whole layout rests on. Checked against a real history + // by reading the parent links back out of the edges. + let fixture = Fixture::with_one_commit(); + fixture.branch_off("side"); + fixture.commit("side one"); + fixture.commit("side two"); + fixture.checkout("main"); + fixture.commit("main one"); + fixture.merge(&["side"]); + fixture.commit("after the merge"); + + let rows = rows(&fixture); + let positions = positions(&rows); + + // Ask git itself for the parent links rather than trusting our own output. + for row in &rows { + let child = positions[row.id.as_str()]; + + for parent in fixture.parents_of(&row.id) { + let parent_row = positions.get(parent.as_str()).unwrap_or_else(|| { + panic!("{parent} is a parent of {} but was never drawn", row.id) + }); + + assert!( + *parent_row > child, + "{parent} is a parent of {} but was drawn above it", + row.id + ); + } + } +} + +#[test] +fn every_commit_is_drawn_exactly_once() { + let fixture = Fixture::with_one_commit(); + fixture.branch_off("side"); + fixture.commit("side one"); + fixture.checkout("main"); + fixture.commit("main one"); + fixture.merge(&["side"]); + + let rows = rows(&fixture); + let unique: HashSet<&str> = rows.iter().map(|row| row.id.as_str()).collect(); + + assert_eq!(unique.len(), rows.len()); +} + +#[test] +fn unmerged_branches_are_walked_too() { + // A branch nothing points at from main still belongs in the graph. + let fixture = Fixture::with_one_commit(); + fixture.branch_off("abandoned"); + fixture.commit("never merged"); + fixture.checkout("main"); + fixture.commit("carried on"); + + let summaries: Vec = rows(&fixture).into_iter().map(|row| row.summary).collect(); + + assert!(summaries.iter().any(|summary| summary == "never merged")); + assert!(summaries.iter().any(|summary| summary == "carried on")); +} + +#[test] +fn refs_land_on_the_commits_they_point_at() { + let fixture = Fixture::with_one_commit(); + fixture.commit("second"); + fixture.tag("v1.0.0"); + fixture.branch("release"); + + let rows = rows(&fixture); + let tip = &rows[0]; + + let names: Vec<&str> = tip + .refs + .iter() + .map(|badge| badge.shorthand.as_str()) + .collect(); + + assert!(names.contains(&"main")); + assert!(names.contains(&"v1.0.0")); + assert!(names.contains(&"release")); + assert!( + rows[1].refs.is_empty(), + "the older commit has nothing pointing at it" + ); +} + +#[test] +fn head_is_marked_on_the_branch_it_is_on() { + let fixture = Fixture::with_one_commit(); + fixture.branch("other"); + + let rows = rows(&fixture); + let head: Vec<&str> = rows[0] + .refs + .iter() + .filter(|badge| badge.is_head) + .map(|badge| badge.shorthand.as_str()) + .collect(); + + assert_eq!(head, vec!["main"], "only the checked out branch is HEAD"); +} + +#[test] +fn an_annotated_tag_badges_the_commit_it_wraps() { + // An annotated tag points at a tag object, not a commit. Without peeling + // the badge would be attached to an object that is never drawn. + let fixture = Fixture::with_one_commit(); + fixture.git(&["tag", "--annotate", "v2.0.0", "--message", "release"]); + + let rows = rows(&fixture); + + assert!( + rows[0].refs.iter().any(|badge| badge.shorthand == "v2.0.0"), + "an annotated tag should badge its commit, got {:?}", + rows[0].refs + ); +} + +#[test] +fn a_detached_head_is_still_walked() { + let fixture = Fixture::with_one_commit(); + fixture.commit("second"); + fixture.detach_head(); + + assert_eq!(rows(&fixture).len(), 2); +} + +#[test] +fn an_empty_repository_produces_no_rows() { + let fixture = Fixture::empty(); + let repository = Repository::discover(fixture.path()).expect("should open"); + let mut walk = repository.graph().expect("should start a walk"); + + assert!(walk.next_chunk(10).expect("should not fail").is_empty()); + assert!(walk.is_exhausted()); +} + +#[test] +fn rows_come_out_in_chunks_of_the_requested_size() { + let fixture = Fixture::with_one_commit(); + for step in 2..=10 { + fixture.commit(&format!("commit {step}")); + } + + let repository = Repository::discover(fixture.path()).expect("should open"); + let mut walk = repository.graph().expect("should start a walk"); + + assert_eq!(walk.next_chunk(4).expect("should walk").len(), 4); + assert!(!walk.is_exhausted(), "there is more to come"); + + assert_eq!(walk.next_chunk(4).expect("should walk").len(), 4); + assert_eq!( + walk.next_chunk(4).expect("should walk").len(), + 2, + "the last chunk is short rather than padded" + ); + assert!(walk.next_chunk(4).expect("should walk").is_empty()); +} + +#[test] +fn commits_backdated_before_their_parents_still_come_out_in_order() { + // A rebase leaves commits whose committer dates run backwards. Date order + // alone would draw a parent above its child. + let fixture = Fixture::with_one_commit(); + fixture.commit_dated("backdated", "2001-01-01T00:00:00+00:00"); + fixture.commit_dated("older still", "2000-01-01T00:00:00+00:00"); + + let rows = rows(&fixture); + let positions = positions(&rows); + + for row in &rows { + for parent in fixture.parents_of(&row.id) { + assert!( + positions[parent.as_str()] > positions[row.id.as_str()], + "{parent} was drawn above its child {}", + row.id + ); + } + } +} + +#[test] +fn the_same_repository_always_produces_the_same_graph() { + let fixture = Fixture::with_one_commit(); + fixture.branch_off("side"); + fixture.commit("side one"); + fixture.checkout("main"); + fixture.commit("main one"); + fixture.merge(&["side"]); + + let first = rows(&fixture); + let second = rows(&fixture); + + assert_eq!( + first, second, + "the layout must not depend on iteration order" + ); +} -- 2.51.2