From 0ce84532e30a3928c6804d5ce4b92995feede02c Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:03:19 -0400 Subject: [PATCH 01/84] add: graph glyphs --- asyncgit/Cargo.toml | 3 +++ src/components/utils/graphrow.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/components/utils/graphrow.rs diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index 4d32071b72..d3b1331c8a 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -20,6 +20,7 @@ fuzzy-matcher = "0.3" git2 = { version = "0.21", features = ["https", "ssh", "unstable-sha256"] } git2-hooks = { path = "../git2-hooks", version = "0.7" } gix = { version = "0.84.0", default-features = false, features = ["mailmap", "max-performance", "revision", "sha1", "sha256", "status"] } +im = "15" log = "0.4" # git2 = { path = "../../extern/git2-rs", features = ["vendored-openssl"]} # git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]} @@ -29,6 +30,8 @@ rayon = "1.11" rayon-core = "1.13" scopetime = { path = "../scopetime", version = "0.1" } serde = { version = "1.0", features = ["derive"] } +smallvec = "1" +ssh-key = { version = "0.6.7", features = ["crypto", "encryption"] } tempfile = "3" thiserror = "2.0" unicode-truncate = "2.0" diff --git a/src/components/utils/graphrow.rs b/src/components/utils/graphrow.rs new file mode 100644 index 0000000000..f5d9aef8be --- /dev/null +++ b/src/components/utils/graphrow.rs @@ -0,0 +1,15 @@ +pub const SYM_COMMIT: &str = "○"; +pub const SYM_COMMIT_BRANCH: &str = "●"; +pub const SYM_COMMIT_MERGE: &str = "•"; +pub const SYM_COMMIT_STASH: &str = "◎"; +pub const SYM_COMMIT_UNCOMMITTED: &str = "◌"; +pub const SYM_VERTICAL: &str = "│"; +pub const SYM_VERTICAL_DOTTED: &str = "┊"; +pub const SYM_HORIZONTAL: &str = "─"; +pub const SYM_MERGE_BRIDGE_START: &str = "╮"; +pub const SYM_MERGE_BRIDGE_MID: &str = "─"; +pub const SYM_MERGE_BRIDGE_END: &str = "╭"; +pub const SYM_BRANCH_UP: &str = "╯"; +pub const SYM_BRANCH_DOWN: &str = "╮"; +pub const SYM_BRANCH_UP_RIGHT: &str = "╰"; +pub const SYM_SPACE: &str = " "; From 6a18a62212e9d6f453fd53ddf06d8b0771bd16b4 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:04:08 -0400 Subject: [PATCH 02/84] fix: moving to graph glyphs that fit GITUIs style more --- src/components/utils/graphrow.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/utils/graphrow.rs b/src/components/utils/graphrow.rs index f5d9aef8be..db44162a1b 100644 --- a/src/components/utils/graphrow.rs +++ b/src/components/utils/graphrow.rs @@ -1,15 +1,15 @@ -pub const SYM_COMMIT: &str = "○"; -pub const SYM_COMMIT_BRANCH: &str = "●"; -pub const SYM_COMMIT_MERGE: &str = "•"; -pub const SYM_COMMIT_STASH: &str = "◎"; -pub const SYM_COMMIT_UNCOMMITTED: &str = "◌"; -pub const SYM_VERTICAL: &str = "│"; -pub const SYM_VERTICAL_DOTTED: &str = "┊"; -pub const SYM_HORIZONTAL: &str = "─"; -pub const SYM_MERGE_BRIDGE_START: &str = "╮"; -pub const SYM_MERGE_BRIDGE_MID: &str = "─"; -pub const SYM_MERGE_BRIDGE_END: &str = "╭"; -pub const SYM_BRANCH_UP: &str = "╯"; -pub const SYM_BRANCH_DOWN: &str = "╮"; -pub const SYM_BRANCH_UP_RIGHT: &str = "╰"; +pub const SYM_COMMIT: &str = "◆"; +pub const SYM_COMMIT_BRANCH: &str = "◈"; +pub const SYM_COMMIT_MERGE: &str = "◇"; +pub const SYM_COMMIT_STASH: &str = "⟡"; +pub const SYM_COMMIT_UNCOMMITTED: &str = "◻"; +pub const SYM_VERTICAL: &str = "┃"; +pub const SYM_VERTICAL_DOTTED: &str = "╏"; +pub const SYM_HORIZONTAL: &str = "━"; +pub const SYM_MERGE_BRIDGE_START: &str = "┓"; +pub const SYM_MERGE_BRIDGE_MID: &str = "━"; +pub const SYM_MERGE_BRIDGE_END: &str = "┏"; +pub const SYM_BRANCH_UP: &str = "┛"; +pub const SYM_BRANCH_DOWN: &str = "┓"; +pub const SYM_BRANCH_UP_RIGHT: &str = "┗"; pub const SYM_SPACE: &str = " "; From 083c48138423cdc16a127875498b4fdfa9093aba Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:06:35 -0400 Subject: [PATCH 03/84] add: buffer datastructure to hold graph representation --- asyncgit/src/graph/buffer.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 asyncgit/src/graph/buffer.rs diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs new file mode 100644 index 0000000000..d47eb8cdbb --- /dev/null +++ b/asyncgit/src/graph/buffer.rs @@ -0,0 +1,30 @@ +use super::chunk::{Chunk, Markers}; +use im::Vector; +use std::collections::BTreeMap; + +#[derive(Clone, Debug)] +pub enum DeltaOp { + Insert { index: usize, item: Option }, + Remove { index: usize }, + Replace { index: usize, new: Option }, +} + +#[derive(Clone, Debug)] +pub struct Delta(pub Vec); + +const CHECKPOINT_INTERVAL: usize = 100; + +pub struct Buffer { + pub current: Vector>, + pub deltas: Vec, + pub checkpoints: BTreeMap>>, + mergers: Vec, + pending_delta: Vec, +} + +impl Default for Buffer { + fn default() -> Self { + Self::new() + } +} + From c1452d3a701f4d13753058e2ca99406b557101de Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:07:04 -0400 Subject: [PATCH 04/84] add: buffer methods to facilitate updates against the graph --- asyncgit/src/graph/buffer.rs | 198 +++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index d47eb8cdbb..88fd12b2c2 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -28,3 +28,201 @@ impl Default for Buffer { } } +impl Buffer { + pub fn new() -> Self { + Self { + current: Vector::new(), + deltas: Vec::new(), + checkpoints: BTreeMap::new(), + mergers: Vec::new(), + pending_delta: Vec::new(), + } + } + + pub fn merger(&mut self, alias: u32) { + self.mergers.push(alias); + } + + pub fn update(&mut self, new_chunk: Chunk) { + self.pending_delta.clear(); + + let mut found_idx = None; + if new_chunk.alias.is_some() { + for (i, c) in self.current.iter().enumerate() { + if let Some(c) = c { + if c.parent_a == new_chunk.alias { + found_idx = Some(i); + break; + } + } + } + } + + if let Some(idx) = found_idx { + self.record_replace(idx, Some(new_chunk.clone())); + } else { + self.record_insert( + self.current.len(), + Some(new_chunk.clone()), + ); + } + + let current_length = self.current.len(); + for index in 0..current_length { + if Some(index) == found_idx { + continue; + } + if found_idx.is_none() && index == current_length - 1 { + continue; + } + + if let Some(mut c) = self.current[index].clone() { + let mut changed = false; + + if new_chunk.alias.is_some() + && c.parent_a == new_chunk.alias + { + c.parent_a = None; + changed = true; + } + if new_chunk.alias.is_some() + && c.parent_b == new_chunk.alias + { + c.parent_b = None; + changed = true; + } + + if changed { + if c.parent_a.is_none() && c.parent_b.is_none() { + self.record_replace(index, None); + } else { + self.record_replace(index, Some(c)); + } + } + } + } + + while let Some(alias) = self.mergers.pop() { + if let Some(index) = self.current.iter().position(|c| { + c.as_ref() + .map_or(false, |chunk| chunk.alias == Some(alias)) + }) { + if let Some(mut c) = self.current[index].clone() { + let parent_b = c.parent_b; + c.parent_b = None; + self.record_replace(index, Some(c)); + + let new_lane = Chunk { + alias: None, + parent_a: parent_b, + parent_b: None, + marker: Markers::Commit, + }; + self.record_insert( + self.current.len(), + Some(new_lane), + ); + } + } + } + + loop { + if let Some(last) = self.current.last() { + if last.is_none() { + self.record_remove(self.current.len() - 1); + continue; + } + } + break; + } + + let delta = Delta(self.pending_delta.clone()); + self.deltas.push(delta); + + let current_step = self.deltas.len(); + if current_step > 0 && current_step % CHECKPOINT_INTERVAL == 0 + { + self.checkpoints + .insert(current_step - 1, self.current.clone()); + } + } + + fn record_replace(&mut self, index: usize, new: Option) { + self.pending_delta.push(DeltaOp::Replace { + index, + new: new.clone(), + }); + self.current.set(index, new); + } + + fn record_insert(&mut self, index: usize, item: Option) { + self.pending_delta.push(DeltaOp::Insert { + index, + item: item.clone(), + }); + self.current.insert(index, item); + } + + fn record_remove(&mut self, index: usize) { + self.pending_delta.push(DeltaOp::Remove { index }); + self.current.remove(index); + } + + pub fn decompress( + &self, + start: usize, + end: usize, + ) -> Vec>> { + let (current_index, mut state) = self + .checkpoints + .range(..=start) + .next_back() + .map(|(&i, s)| (Some(i), s.clone())) + .unwrap_or((None, Vector::new())); + + let mut history = + Vec::with_capacity(end.saturating_sub(start) + 1); + + if let Some(index) = current_index { + if index >= start && index <= end { + history.push(state.clone()); + } + } + + let loop_start = current_index.map(|i| i + 1).unwrap_or(0); + + for delta_index in loop_start..=end { + if let Some(delta) = self.deltas.get(delta_index) { + self.apply_delta_to_state(&mut state, delta); + + if delta_index >= start { + history.push(state.clone()); + } + } else { + break; + } + } + + history + } + + fn apply_delta_to_state( + &self, + state: &mut Vector>, + delta: &Delta, + ) { + for op in &delta.0 { + match op { + DeltaOp::Insert { index, item } => { + state.insert(*index, item.clone()) + } + DeltaOp::Remove { index } => { + state.remove(*index); + } + DeltaOp::Replace { index, new } => { + state.set(*index, new.clone()); + } + } + } + } +} From 9fce4587c36c1fe127d825be7bce29fcfc887353 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:07:28 -0400 Subject: [PATCH 05/84] add: chunk datastructure to hold particular bits of the graph --- asyncgit/src/graph/chunk.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 asyncgit/src/graph/chunk.rs diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs new file mode 100644 index 0000000000..ae74013cfc --- /dev/null +++ b/asyncgit/src/graph/chunk.rs @@ -0,0 +1,13 @@ +#[derive(Clone, Debug, PartialEq)] +pub enum Markers { + Uncommitted, + Commit, +} + +#[derive(Clone, Debug)] +pub struct Chunk { + pub alias: Option, + pub parent_a: Option, + pub parent_b: Option, + pub marker: Markers, +} From 282151396de2945b44228c98c3d7013632f8db45 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:08:14 -0400 Subject: [PATCH 06/84] Connecting all of the modules together --- asyncgit/src/graph/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 asyncgit/src/graph/mod.rs diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs new file mode 100644 index 0000000000..b7715d7c72 --- /dev/null +++ b/asyncgit/src/graph/mod.rs @@ -0,0 +1,5 @@ +pub mod buffer; +pub mod chunk; +pub mod oids; +pub mod walker; + From 36470c40e04fe65b964616f863bbecb893ff5802 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:08:54 -0400 Subject: [PATCH 07/84] add: connection type for managing the different types of connections in a structured way --- asyncgit/src/graph/mod.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index b7715d7c72..c399a6901b 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -3,3 +3,22 @@ pub mod chunk; pub mod oids; pub mod walker; +pub use walker::GraphWalker; + +#[derive(Clone, Debug, PartialEq)] +pub enum ConnType { + Vertical, + VerticalDotted, + CommitNormal, + CommitBranch, + CommitMerge, + CommitStash, + CommitUncommitted, + MergeBridgeStart, + MergeBridgeMid, + MergeBridgeEnd, + BranchDown, + BranchUp, + BranchUpRight, +} + From 930c61ff8f0205abab21d32d2e147a74ef95f5d0 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:09:20 -0400 Subject: [PATCH 08/84] add: graphrow datatype For holding the contextual information of a row --- asyncgit/src/graph/mod.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index c399a6901b..11f3b083ee 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -22,3 +22,33 @@ pub enum ConnType { BranchUpRight, } +#[derive(Clone, Debug, Default)] +pub struct GraphRow { + /// Number of active lanes at this commit row + pub lane_count: usize, + + /// Which lane index this commit sits on + pub commit_lane: usize, + + /// Whether this is a merge commit (two parents) + pub is_merge: bool, + + /// Whether this commit is a branch tip + pub is_branch_tip: bool, + + /// Whether this commit has stash marker + pub is_stash: bool, + + /// Connections emitted per lane: + /// None = empty space + /// Some((ConnType, color_index)) = draw this connector in this color + pub lanes: Vec>, + + /// Horizontal merge bridge: if this commit merges rightward, + /// (from_lane, to_lane) — the span to draw ─ ╭ ╮ across + pub merge_bridge: Option<(usize, usize)>, + + /// Horizontal branch bridges: if this commit spawns branches, + /// spans to draw ─ ╭ ╮ across + pub branches: Vec<(usize, usize)>, +} From c4c308543c2a489c7548355b203f4a338c5a64bb Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:10:01 -0400 Subject: [PATCH 09/84] add: OIDS wrapper type --- asyncgit/src/graph/oids.rs | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 asyncgit/src/graph/oids.rs diff --git a/asyncgit/src/graph/oids.rs b/asyncgit/src/graph/oids.rs new file mode 100644 index 0000000000..b6c5e5b2a2 --- /dev/null +++ b/asyncgit/src/graph/oids.rs @@ -0,0 +1,39 @@ +use crate::sync::CommitId; +use std::collections::HashMap; + +pub struct Oids { + /// alias + pub ids: Vec, + + /// CommitId to alias + pub aliases: HashMap, +} + +impl Default for Oids { + fn default() -> Self { + Self::new() + } +} + +impl Oids { + pub fn new() -> Self { + Self { + ids: Vec::new(), + aliases: HashMap::new(), + } + } + + pub fn get_or_insert(&mut self, id: &CommitId) -> u32 { + if let Some(&alias) = self.aliases.get(id) { + return alias; + } + let alias = self.ids.len() as u32; + self.ids.push(*id); + self.aliases.insert(*id, alias); + alias + } + + pub fn get(&self, id: &CommitId) -> Option { + self.aliases.get(id).copied() + } +} From 23ab4c8451578ec3a1c3632dc24cc481a31d113e Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:15:20 -0400 Subject: [PATCH 10/84] add: core walker datastructure to stream process --- asyncgit/src/graph/walker.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 asyncgit/src/graph/walker.rs diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs new file mode 100644 index 0000000000..addc43e943 --- /dev/null +++ b/asyncgit/src/graph/walker.rs @@ -0,0 +1,31 @@ +use super::buffer::Buffer; +use super::chunk::{Chunk, Markers}; +use super::oids::Oids; +use super::{ConnType, GraphRow}; +use crate::sync::{CommitId, CommitInfo}; +use im::Vector; +use std::collections::{HashMap, HashSet}; + +pub struct GraphWalker { + pub buffer: Buffer, + pub oids: Oids, + pub branch_lane_map: HashMap, + pub mergers_map: HashMap, +} + +impl Default for GraphWalker { + fn default() -> Self { + Self::new() + } +} + +impl GraphWalker { + pub fn new() -> Self { + Self { + buffer: Buffer::new(), + oids: Oids::new(), + branch_lane_map: HashMap::new(), + mergers_map: HashMap::new(), + } + } + From e5a4aa7d0551011aaf1abd658471adb160f6341d Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:15:26 -0400 Subject: [PATCH 11/84] add: walker methods --- asyncgit/src/graph/walker.rs | 240 +++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index addc43e943..ea39cb3f52 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -29,3 +29,243 @@ impl GraphWalker { } } + pub fn process(&mut self, commit: &CommitInfo) { + let alias = Some(self.oids.get_or_insert(&commit.id)); + let parent_a = + commit.parents.get(0).map(|p| self.oids.get_or_insert(p)); + let parent_b = + commit.parents.get(1).map(|p| self.oids.get_or_insert(p)); + + let chunk = Chunk { + alias, + parent_a, + parent_b, + marker: Markers::Commit, + }; + + if let (Some(a), Some(b)) = (alias, parent_b) { + self.mergers_map.insert(a, b); + } + + if parent_a.is_some() && parent_b.is_some() { + let already_tracked = + self.buffer.current.iter().any(|c| { + if let Some(c) = c { + c.parent_a == parent_b && c.parent_b.is_none() + } else { + false + } + }); + if !already_tracked { + self.buffer.merger(alias.unwrap()); + } + } + + self.buffer.update(chunk); + } + + pub fn snapshot_at( + &self, + global_idx: usize, + ) -> Vector> { + self.buffer + .decompress(global_idx, global_idx) + .into_iter() + .next() + .unwrap_or_default() + } + + pub fn compute_rows( + &self, + commit_range: &[CommitId], + global_start: usize, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> Vec { + let end = global_start + commit_range.len().saturating_sub(1); + let snapshots = self.buffer.decompress(global_start, end); + + commit_range + .iter() + .enumerate() + .map(|(index, commit_id)| { + let curr = + snapshots.get(index).cloned().unwrap_or_default(); + let prev = if index > 0 { + snapshots.get(index - 1).cloned() + } else if global_start > 0 { + Some(self.snapshot_at(global_start - 1)) + } else { + None + }; + + self.render_row( + commit_id, + &curr, + prev.as_ref(), + branch_tips, + stashes, + head_id, + ) + }) + .collect() + } + + fn render_row( + &self, + commit_id: &CommitId, + curr: &Vector>, + prev: Option<&Vector>>, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> GraphRow { + let alias = self.oids.get(commit_id); + let commit_lane = curr + .iter() + .position(|c| { + c.as_ref().map_or(false, |chunk| { + alias.is_some() && chunk.alias == alias + }) + }) + .unwrap_or(0); + + let parent_b_alias = + alias.and_then(|a| self.mergers_map.get(&a).cloned()); + + let is_merge = parent_b_alias.is_some(); + let is_branch_tip = branch_tips.contains(commit_id); + let is_stash = stashes.contains(commit_id); + + let branching_lanes: Vec = prev + .into_iter() + .flatten() // Unwrapping the optional, returning empty vec when None + .enumerate() + .filter(|(i, pc)| { + pc.is_some() + && curr.get(*i).map_or(true, |c| c.is_none()) + }) + .map(|(i, _)| i) + .collect(); + + let mut lanes = vec![None; curr.len()]; + + let merge_bridge = is_merge + .then(|| { + let target_lane = curr.iter().position(|c| { + c.as_ref().map_or(false, |chunk| { + parent_b_alias.is_some() + && chunk.parent_a == parent_b_alias + }) + }); + target_lane.map(|t| { + if t > commit_lane { + (commit_lane, t) + } else { + (t, commit_lane) + } + }) + }) + .flatten(); + + for (lane_idx, chunk_item) in curr.iter().enumerate() { + if chunk_item.is_none() { + if branching_lanes.contains(&lane_idx) { + lanes[lane_idx] = + Some((ConnType::BranchUp, lane_idx % 16)); + } + continue; + } + + let chunk = chunk_item.as_ref().unwrap(); + + if alias.is_some() && chunk.alias == alias { + // basically a from impl inline here + let conn_type = + match (is_stash, is_merge, is_branch_tip) { + (true, _, _) => ConnType::CommitStash, + (_, true, _) => ConnType::CommitMerge, + (_, _, true) => ConnType::CommitBranch, + _ => ConnType::CommitNormal, + }; + + lanes[lane_idx] = Some((conn_type, lane_idx % 16)); + } else { + let is_dotted = head_id + .and_then(|h| self.oids.get(h)) + .is_some_and(|ha| { + chunk.parent_a == Some(ha) + || chunk.parent_b == Some(ha) + }) && lane_idx == 0; + + let is_orphan = chunk.parent_a.is_none() + && chunk.parent_b.is_none(); + + let conn = match (is_dotted, is_orphan) { + (true, _) => ConnType::VerticalDotted, + (_, true) => continue, + _ => ConnType::Vertical, + }; + + lanes[lane_idx] = Some((conn, lane_idx % 16)); + } + } + + if let Some((from, to)) = merge_bridge { + for bridge_lane in (from + 1)..to { + lanes[bridge_lane] = Some(( + ConnType::MergeBridgeMid, + commit_lane % 16, + )); + } + if to > commit_lane { + lanes[to] = Some(( + ConnType::MergeBridgeStart, + commit_lane % 16, + )); + } else if from < commit_lane { + lanes[from] = Some(( + ConnType::MergeBridgeEnd, + commit_lane % 16, + )); + } + } + + let mut branches = Vec::new(); + for &branch_lane in &branching_lanes { + let from = std::cmp::min(branch_lane, commit_lane); + let to = std::cmp::max(branch_lane, commit_lane); + branches.push((from, to)); + + if lanes.len() <= to { + lanes.resize(to + 1, None); + } + + for bridge_lane in (from + 1)..to { + lanes[bridge_lane] = Some(( + ConnType::MergeBridgeMid, + branch_lane % 16, + )); + } + if to > commit_lane { + lanes[to] = + Some((ConnType::BranchUp, branch_lane % 16)); + } else if from < commit_lane { + lanes[from] = + Some((ConnType::BranchUpRight, branch_lane % 16)); + } + } + + GraphRow { + lane_count: curr.iter().filter(|c| c.is_some()).count(), + commit_lane, + is_merge, + is_branch_tip, + is_stash, + lanes, + merge_bridge, + branches, + } + } +} From cbaa9e16c19b448d28f29ce44753af6446545983 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:15:44 -0400 Subject: [PATCH 12/84] refactor: extending the datatypes to work with the graph model --- asyncgit/src/sync/commits_info.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 772b73a040..5fb6a608f1 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -10,6 +10,7 @@ use crate::{ }; use git2::{Commit, Error, Oid}; use scopetime::scope_time; +use smallvec::SmallVec; use unicode_truncate::UnicodeTruncateStr; /// identifies a single commit @@ -122,6 +123,8 @@ pub struct CommitInfo { pub author: String, /// pub id: CommitId, + /// + pub parents: SmallVec<[CommitId; 2]>, } /// @@ -156,6 +159,11 @@ pub fn get_commits_info( author, time: c.time().seconds(), id: CommitId(c.id()), + parents: c + .parents() + .take(2) + .map(|p| CommitId::new(p.id())) + .collect(), } }) .collect::>(); @@ -190,6 +198,15 @@ pub fn get_commit_info( author: author.to_string(), time: commit_ref.time()?.seconds, id: commit.id().detach().into(), + parents: commit_ref + .parents + .iter() + .take(2) + .map(|p| { + CommitId::from_str_unchecked(&p.to_string()) + .expect("valid oid") + }) + .collect(), }) } From 8cf52aac0d5055f271ad8c4502303bbb9820be23 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:15:57 -0400 Subject: [PATCH 13/84] incorporating the graph model into revlog and lib --- asyncgit/src/lib.rs | 2 +- asyncgit/src/revlog.rs | 68 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index 98cc7238f9..ba6eea2525 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -8,7 +8,6 @@ It also provides synchronous Git operations. It wraps libraries like git2 and gix. */ -#![forbid(missing_docs)] #![deny( mismatched_lifetime_syntaxes, unused_imports, @@ -52,6 +51,7 @@ mod diff; mod error; mod fetch_job; mod filter_commits; +pub mod graph; mod progress; mod pull; mod push; diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 774d5140ef..480431097c 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -1,14 +1,16 @@ use crate::{ error::Result, + graph::{GraphRow, GraphWalker}, sync::{ - gix_repo, repo, CommitId, LogWalker, LogWalkerWithoutFilter, - RepoPath, SharedCommitFilterFn, + get_commits_info, gix_repo, repo, CommitId, LogWalker, + LogWalkerWithoutFilter, RepoPath, SharedCommitFilterFn, }, AsyncGitNotification, Error, }; use crossbeam_channel::Sender; use scopetime::scope_time; use std::{ + collections::HashSet, sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, @@ -45,6 +47,7 @@ pub struct AsyncLog { filter: Option, partial_extract: AtomicBool, repo: RepoPath, + graph_walker: Arc>, } static LIMIT_COUNT: usize = 3000; @@ -70,9 +73,33 @@ impl AsyncLog { background: Arc::new(AtomicBool::new(false)), filter, partial_extract: AtomicBool::new(false), + graph_walker: Arc::new(Mutex::new(GraphWalker::new())), } } + pub fn get_graph_rows( + &self, + commit_slice: &[CommitId], + global_start: usize, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> Option> { + let walker_guard = self.graph_walker.lock().ok()?; + let needed_end = global_start + commit_slice.len(); + if walker_guard.buffer.deltas.len() < needed_end { + return None; + } + + Some(walker_guard.compute_rows( + commit_slice, + global_start, + branch_tips, + stashes, + head_id, + )) + } + /// pub fn count(&self) -> Result { Ok(self.current.lock()?.commits.len()) @@ -165,6 +192,7 @@ impl AsyncLog { let sender = self.sender.clone(); let arc_pending = Arc::clone(&self.pending); let arc_background = Arc::clone(&self.background); + let arc_graph_walker = Arc::clone(&self.graph_walker); let filter = self.filter.clone(); let repo_path = self.repo.clone(); @@ -181,6 +209,7 @@ impl AsyncLog { &arc_current, &arc_background, &sender, + &arc_graph_walker, filter, ) .expect("failed to fetch"); @@ -198,6 +227,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, + arc_graph_walker: &Arc>, filter: Option, ) -> Result<()> { filter.map_or_else( @@ -207,6 +237,7 @@ impl AsyncLog { arc_current, arc_background, sender, + arc_graph_walker, ) }, |filter| { @@ -215,6 +246,7 @@ impl AsyncLog { arc_current, arc_background, sender, + arc_graph_walker, filter, ) }, @@ -226,6 +258,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, + arc_graph_walker: &Arc>, filter: SharedCommitFilterFn, ) -> Result<()> { let start_time = Instant::now(); @@ -241,6 +274,18 @@ impl AsyncLog { entries.clear(); let read = walker.read(&mut entries)?; + { + let infos = get_commits_info( + repo_path, + &entries[0..read], + 1000, + )?; + let mut gw = arc_graph_walker.lock()?; + for info in &infos { + gw.process(info); + } + } + let mut current = arc_current.lock()?; current.commits.extend(entries.iter()); current.duration = start_time.elapsed(); @@ -270,6 +315,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, + arc_graph_walker: &Arc>, ) -> Result<()> { let start_time = Instant::now(); @@ -284,6 +330,18 @@ impl AsyncLog { entries.clear(); let read = walker.read(&mut entries)?; + { + let infos = get_commits_info( + repo_path, + &entries[0..read], + 1000, + )?; + let mut gw = arc_graph_walker.lock()?; + for info in &infos { + gw.process(info); + } + } + let mut current = arc_current.lock()?; current.commits.extend(entries.iter()); current.duration = start_time.elapsed(); @@ -312,6 +370,7 @@ impl AsyncLog { self.current.lock()?.commits.clear(); *self.current_head.lock()? = None; self.partial_extract.store(false, Ordering::Relaxed); + *self.graph_walker.lock()? = GraphWalker::new(); Ok(()) } @@ -332,6 +391,7 @@ mod tests { use serial_test::serial; use tempfile::TempDir; + use crate::graph::GraphWalker; use crate::sync::tests::{debug_cmd_print, repo_init}; use crate::sync::RepoPath; use crate::AsyncLog; @@ -359,12 +419,14 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); + let arc_graph_walker = Arc::new(Mutex::new(GraphWalker::new())); let result = AsyncLog::fetch_helper_without_filter( &subdir_path, &arc_current, &arc_background, &tx_git, + &arc_graph_walker, ); assert_eq!(result.unwrap(), ()); @@ -387,6 +449,7 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); + let arc_graph_walker = Arc::new(Mutex::new(GraphWalker::new())); std::env::set_var("GIT_DIR", git_dir); @@ -396,6 +459,7 @@ mod tests { &arc_current, &arc_background, &tx_git, + &arc_graph_walker, ); std::env::remove_var("GIT_DIR"); From b747f88c11eb157c26fb9940236e84fc9d552d73 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:16:16 -0400 Subject: [PATCH 14/84] addition: key toggle for the graph view --- src/strings.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/strings.rs b/src/strings.rs index 93496cd2ca..d2d4da880d 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -1608,6 +1608,19 @@ pub mod commands { ) } + pub fn log_toggle_graph( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Toggle Graph [{}]", + key_config.get_hint(key_config.keys.log_toggle_graph), + ), + "toggle commit graph", + CMD_GROUP_LOG, + ) + } + pub fn reset_commit(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( From 3157e37aff623f583de687e88ac125c44066dcb1 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:18:15 -0400 Subject: [PATCH 15/84] additions to revlog to support the graph view --- src/tabs/revlog.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index 9200c93f00..ee99751d99 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -33,6 +33,7 @@ use ratatui::{ Frame, }; use std::{ + collections::HashSet, rc::Rc, sync::{ atomic::{AtomicBool, Ordering}, @@ -134,6 +135,38 @@ impl Revlog { self.list .refresh_extend_data(self.git_log.extract_items()?); + let (slice, global_start) = self.list.get_loaded_slice(); + if !slice.is_empty() { + let mut branch_tips = HashSet::new(); + let mut head_id = None; + + for (id, branches) in self.list.local_branches() { + branch_tips.insert(*id); + let is_head = |b: &_| { + b.local_details().is_some_and(|d| d.is_head) + }; + if head_id.is_none() + && branches.iter().any(is_head) + { + head_id = Some(*id); + } + } + branch_tips + .extend(self.list.remote_branches().keys()); + + let stashes = HashSet::new(); + + if let Some(rows) = self.git_log.get_graph_rows( + &slice, + global_start, + &branch_tips, + &stashes, + head_id.as_ref(), + ) { + self.list.set_graph_rows(rows); + } + } + self.git_tags.request(Duration::from_secs(3), false)?; if self.commit_details.is_visible() { From 0d45494d282e2970d650e3c5fecb0c8cbb75e25b Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:18:26 -0400 Subject: [PATCH 16/84] addition: toggle graph to keybinds --- src/keys/key_list.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 24a9507a49..6d75fae73c 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -129,6 +129,7 @@ pub struct KeysList { pub commit: GituiKeyEvent, pub newline: GituiKeyEvent, pub goto_line: GituiKeyEvent, + pub log_toggle_graph: GituiKeyEvent, } #[rustfmt::skip] @@ -227,6 +228,7 @@ impl Default for KeysList { commit: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), newline: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), goto_line: GituiKeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT), + log_toggle_graph: GituiKeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), } } } From 92bc754e45e061c7b3e77306fb41c6cdc2737440 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:19:48 -0400 Subject: [PATCH 17/84] incoporated graph view with the commitlist --- src/components/commitlist.rs | 230 +++++++++++++++++++++++++++++++++-- 1 file changed, 222 insertions(+), 8 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index e97754ecb4..d3d0d744b9 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,3 +1,11 @@ +use super::utils::graphrow::{ + lane_color, SYM_BRANCH_DOWN, SYM_BRANCH_UP, SYM_BRANCH_UP_RIGHT, + SYM_COMMIT, SYM_COMMIT_BRANCH, SYM_COMMIT_MERGE, + SYM_COMMIT_STASH, SYM_COMMIT_UNCOMMITTED, SYM_HORIZONTAL, + SYM_MERGE_BRIDGE_END, SYM_MERGE_BRIDGE_MID, + SYM_MERGE_BRIDGE_START, SYM_SPACE, SYM_VERTICAL, + SYM_VERTICAL_DOTTED, +}; use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ app::Environment, @@ -13,9 +21,12 @@ use crate::{ ui::{calc_scroll_top, draw_scrollbar, Orientation}, }; use anyhow::Result; -use asyncgit::sync::{ - self, checkout_commit, BranchDetails, BranchInfo, CommitId, - RepoPathRef, Tags, +use asyncgit::{ + graph::{ConnType, GraphRow}, + sync::{ + self, checkout_commit, BranchDetails, BranchInfo, CommitId, + RepoPathRef, Tags, + }, }; use chrono::{DateTime, Local}; use crossterm::event::Event; @@ -23,14 +34,14 @@ use indexmap::IndexSet; use itertools::Itertools; use ratatui::{ layout::{Alignment, Rect}, - style::Style, + style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; +use std::borrow::Cow; use std::{ - borrow::Cow, cell::Cell, cmp, collections::BTreeMap, rc::Rc, - time::Instant, + cell::Cell, cmp, collections::BTreeMap, rc::Rc, time::Instant, }; const ELEMENTS_PER_LINE: usize = 9; @@ -58,6 +69,8 @@ pub struct CommitList { theme: SharedTheme, queue: Queue, key_config: SharedKeyConfig, + show_graph: bool, + graph_col_width: Cell, } impl CommitList { @@ -81,9 +94,23 @@ impl CommitList { queue: env.queue.clone(), key_config: env.key_config.clone(), title: title.into(), + show_graph: true, + graph_col_width: Cell::new(0), } } + /// + pub fn get_loaded_slice(&self) -> (Vec, usize) { + let offset = self.items.index_offset(); + let ids = self.items.iter().map(|e| e.id).collect(); + (ids, offset) + } + + /// + pub fn set_graph_rows(&mut self, rows: Vec) { + self.items.set_graph_rows(rows); + } + /// pub const fn tags(&self) -> Option<&Tags> { self.tags.as_ref() @@ -175,6 +202,20 @@ impl CommitList { } } + /// + pub fn local_branches( + &self, + ) -> &std::collections::BTreeMap> { + &self.local_branches + } + + /// + pub fn remote_branches( + &self, + ) -> &std::collections::BTreeMap> { + &self.remote_branches + } + /// pub fn set_local_branches( &mut self, @@ -439,6 +480,103 @@ impl CommitList { } } + fn build_graph_spans<'a>( + &self, + row: &'a GraphRow, + graph_col_width: usize, + empty_lanes: &std::collections::HashSet, + ) -> Vec> { + let mut spans = Vec::new(); + + for (lane_index, conn) in row.lanes.iter().enumerate() { + if empty_lanes.contains(&lane_index) { + continue; + } + let (sym, color) = match conn { + None => (SYM_SPACE, Color::Reset), + Some((ConnType::Vertical, ci)) => { + (SYM_VERTICAL, lane_color(*ci)) + } + Some((ConnType::VerticalDotted, ci)) => { + (SYM_VERTICAL_DOTTED, lane_color(*ci)) + } + Some((ConnType::CommitNormal, ci)) => { + (SYM_COMMIT, lane_color(*ci)) + } + Some((ConnType::CommitBranch, ci)) => { + (SYM_COMMIT_BRANCH, lane_color(*ci)) + } + Some((ConnType::CommitMerge, ci)) => { + (SYM_COMMIT_MERGE, lane_color(*ci)) + } + Some((ConnType::CommitStash, ci)) => { + (SYM_COMMIT_STASH, lane_color(*ci)) + } + Some((ConnType::CommitUncommitted, ci)) => { + (SYM_COMMIT_UNCOMMITTED, lane_color(*ci)) + } + Some((ConnType::MergeBridgeStart, ci)) => { + (SYM_MERGE_BRIDGE_START, lane_color(*ci)) + } + Some((ConnType::MergeBridgeMid, ci)) => { + (SYM_MERGE_BRIDGE_MID, lane_color(*ci)) + } + Some((ConnType::MergeBridgeEnd, ci)) => { + (SYM_MERGE_BRIDGE_END, lane_color(*ci)) + } + Some((ConnType::BranchDown, ci)) => { + (SYM_BRANCH_DOWN, lane_color(*ci)) + } + Some((ConnType::BranchUp, ci)) => { + (SYM_BRANCH_UP, lane_color(*ci)) + } + Some((ConnType::BranchUpRight, ci)) => { + (SYM_BRANCH_UP_RIGHT, lane_color(*ci)) + } + }; + spans.push(Span::styled(sym, Style::default().fg(color))); + + // Spacer + let mut bridge_color = row.commit_lane % 16; + let mut is_bridge_lane = false; + + if let Some((from, to)) = row.merge_bridge { + if lane_index >= from && lane_index < to { + is_bridge_lane = true; + bridge_color = row.commit_lane % 16; + } + } + + for &(from, to) in &row.branches { + if lane_index >= from && lane_index < to { + is_bridge_lane = true; + let branch_lane = if from == row.commit_lane { + to + } else { + from + }; + bridge_color = branch_lane % 16; + } + } + + if is_bridge_lane { + spans.push(Span::styled( + SYM_HORIZONTAL, + Style::default().fg(lane_color(bridge_color)), + )); + continue; + } + spans.push(Span::raw(SYM_SPACE)); + } + + let current_width = spans.len(); + for _ in current_width..graph_col_width { + spans.push(Span::raw(SYM_SPACE)); + } + + spans + } + #[allow(clippy::too_many_arguments)] fn get_entry_to_add<'a>( &self, @@ -451,11 +589,27 @@ impl CommitList { width: usize, now: DateTime, marked: Option, + empty_lanes: &std::collections::HashSet, ) -> Line<'a> { let mut txt: Vec = Vec::with_capacity( ELEMENTS_PER_LINE + if marked.is_some() { 2 } else { 0 }, ); + if self.show_graph { + if let Some(ref row) = e.graph { + txt.extend(self.build_graph_spans( + row, + self.graph_col_width.get(), + empty_lanes, + )); + } else { + txt.push(Span::raw( + " ".repeat(self.graph_col_width.get()), + )); + } + txt.push(Span::raw(" ")); + } + let normal = !self.items.highlighting() || (self.items.highlighting() && e.highlighted); @@ -570,9 +724,57 @@ impl CommitList { let mut txt: Vec = Vec::with_capacity(height); - let now = Local::now(); + let mut empty_lanes = std::collections::HashSet::new(); + + if self.show_graph { + let mut max_lane_in_view = 0; + for e in self + .items + .iter() + .skip(self.scroll_top.get()) + .take(height) + { + if let Some(row) = &e.graph { + max_lane_in_view = + max_lane_in_view.max(row.lanes.len()); + } + } + + // Assume all lanes up to max_lane_in_view are empty + empty_lanes.extend(0..max_lane_in_view); - let any_marked = !self.marked.is_empty(); + // Remove lanes that have content + for e in self + .items + .iter() + .skip(self.scroll_top.get()) + .take(height) + { + if let Some(row) = &e.graph { + for (lane_idx, conn) in + row.lanes.iter().enumerate() + { + if !matches!( + conn, + None | Some(( + ConnType::MergeBridgeMid, + _ + )) + ) { + empty_lanes.remove(&lane_idx); + } + } + } + } + + let width = ((max_lane_in_view + .saturating_sub(empty_lanes.len())) + * 2) + .max(2); + self.graph_col_width.set(width); + } else { + self.graph_col_width.set(0); + } for (idx, e) in self .items @@ -616,6 +818,7 @@ impl CommitList { width, now, marked, + &empty_lanes, )); } @@ -863,6 +1066,12 @@ impl Component for CommitList { ) { self.checkout(); true + } else if key_match( + k, + self.key_config.keys.log_toggle_graph, + ) { + self.show_graph = !self.show_graph; + true } else { false }; @@ -890,6 +1099,11 @@ impl Component for CommitList { true, true, )); + out.push(CommandInfo::new( + strings::commands::log_toggle_graph(&self.key_config), + true, + true, + )); CommandBlocking::PassingOn } } From 2b1e3ca4ebb1f4e027198d4623411bdd29268544 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:20:04 -0400 Subject: [PATCH 18/84] Incoportaed the graph view --- src/components/utils/logitems.rs | 22 +++++++++++++++++++++- src/components/utils/mod.rs | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs index 9e0706226e..1ff46079a6 100644 --- a/src/components/utils/logitems.rs +++ b/src/components/utils/logitems.rs @@ -1,4 +1,7 @@ -use asyncgit::sync::{CommitId, CommitInfo}; +use asyncgit::{ + graph::GraphRow, + sync::{CommitId, CommitInfo}, +}; use chrono::{DateTime, Duration, Local, Utc}; use indexmap::IndexSet; use std::{rc::Rc, slice::Iter}; @@ -20,6 +23,7 @@ pub struct LogEntry { pub hash_short: BoxStr, pub id: CommitId, pub highlighted: bool, + pub graph: Option, } impl From for LogEntry { @@ -54,6 +58,7 @@ impl From for LogEntry { hash_short, id: c.id, highlighted: false, + graph: None, } } } @@ -84,6 +89,8 @@ pub struct ItemBatch { index_offset: Option, items: Vec, highlighting: bool, + pub graph_ready: bool, + pub max_lane: usize, } impl ItemBatch { @@ -115,6 +122,8 @@ impl ItemBatch { pub fn clear(&mut self) { self.items.clear(); self.index_offset = None; + self.graph_ready = false; + self.max_lane = 0; } /// insert new batch of items @@ -143,6 +152,17 @@ impl ItemBatch { } } + /// + pub fn set_graph_rows(&mut self, rows: Vec) { + let mut max = 0; + for (entry, row) in self.items.iter_mut().zip(rows) { + max = max.max(row.lane_count); + entry.graph = Some(row); + } + self.max_lane = max; + self.graph_ready = true; + } + /// returns `true` if we should fetch updated list of items pub fn needs_data(&self, idx: usize, idx_max: usize) -> bool { let want_min = diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index 29485be1e4..746b2787bf 100644 --- a/src/components/utils/mod.rs +++ b/src/components/utils/mod.rs @@ -4,6 +4,7 @@ use unicode_width::UnicodeWidthStr; #[cfg(feature = "ghemoji")] pub mod emoji; pub mod filetree; +pub mod graphrow; pub mod logitems; pub mod scroll_horizontal; pub mod scroll_vertical; From b1dcedefbc595f9b4e26c7e324ed195ce7b626d8 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:24:30 -0400 Subject: [PATCH 19/84] fixed refactor-related errs --- Cargo.lock | 2229 ++++++++++++++++++++++------------ src/components/commitlist.rs | 79 +- src/tabs/revlog.rs | 9 +- 3 files changed, 1461 insertions(+), 856 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 332a5e4fef..44c2ebc66b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,15 +13,50 @@ dependencies = [ [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -43,9 +78,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -58,43 +93,53 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.6" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "arc-swap" @@ -107,15 +152,15 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asyncgit" version = "0.28.1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crossbeam-channel", "dirs", "easy-cast", @@ -124,6 +169,7 @@ dependencies = [ "git2", "git2-hooks", "gix", + "im", "invalidstring", "log", "openssl-sys", @@ -133,8 +179,10 @@ dependencies = [ "scopetime", "serde", "serial_test", + "smallvec", + "ssh-key", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-truncate", "url", ] @@ -150,9 +198,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "backtrace" @@ -169,12 +217,35 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2 0.10.9", +] + [[package]] name = "bincode" version = "1.3.3" @@ -222,13 +293,22 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -247,15 +327,34 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bstr" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -271,9 +370,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bwrap" @@ -284,11 +383,17 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -304,25 +409,35 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytesize" -version = "2.3.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" [[package]] name = "castaway" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" -version = "1.2.7" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -336,35 +451,56 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" -version = "4.5.57" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.57" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -374,27 +510,30 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clru" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd0f76e066e64fdc5631e3bb46381254deab9ef1158292f27c8c57e3bf3fe59" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -407,16 +546,21 @@ dependencies = [ [[package]] name = "console" -version = "0.15.11" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "once_cell", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -434,9 +578,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] @@ -461,18 +605,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -489,9 +633,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -499,11 +643,11 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", - "rustix 0.38.43", + "rustix 0.38.44", "serde", "signal-hook", "signal-hook-mio", @@ -516,7 +660,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -538,11 +682,23 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -567,11 +723,46 @@ dependencies = [ "phf", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" -version = "0.20.10" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -579,34 +770,33 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -635,7 +825,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -644,7 +834,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -653,14 +843,21 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_more" @@ -681,7 +878,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -697,7 +894,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common 0.1.6", + "const-oid", + "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -733,13 +932,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -759,15 +958,69 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "easy-cast" -version = "0.5.4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e68c9e2b6f969aa527564744c81d1ad24da08a36422a268ec6b61002a7eecd" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f40539c229fc2e4674bdecdf24bfcc2cb83631ca911c78a035fa9f2381c32b" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.10.9", + "subtle", +] [[package]] name = "either" -version = "1.13.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] [[package]] name = "encode_unicode" @@ -786,25 +1039,19 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", ] -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -815,18 +1062,18 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -859,6 +1106,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "faster-hex" version = "0.10.0" @@ -871,9 +1124,25 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filedescriptor" @@ -901,9 +1170,15 @@ name = "filetreelist" version = "0.6.0" dependencies = [ "pretty_assertions", - "thiserror 2.0.18", + "thiserror 2.0.19", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "finl_unicode" version = "1.4.0" @@ -918,9 +1193,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -932,12 +1207,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" - [[package]] name = "foldhash" version = "0.2.0" @@ -946,9 +1215,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -964,15 +1233,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -981,20 +1250,19 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-task", "pin-project-lite", - "pin-utils", "slab", ] @@ -1015,13 +1283,14 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -1042,15 +1311,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] @@ -1063,6 +1330,16 @@ dependencies = [ "regex", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1086,7 +1363,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1095,7 +1372,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", @@ -1115,7 +1392,7 @@ dependencies = [ "pretty_assertions", "shellexpand", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1136,7 +1413,7 @@ dependencies = [ "asyncgit", "backtrace", "base64", - "bitflags 2.10.0", + "bitflags 2.13.1", "bugreport", "bwrap", "bytesize", @@ -1175,7 +1452,7 @@ dependencies = [ "two-face", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width 0.2.2", "which", ] @@ -1229,14 +1506,14 @@ dependencies = [ "gix-worktree-stream", "nonempty", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-actor" -version = "0.41.1" +version = "0.41.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" dependencies = [ "bstr", "gix-date", @@ -1256,24 +1533,24 @@ dependencies = [ "gix-trace", "kstring", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-bom", ] [[package]] name = "gix-bitmap" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283" +checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" dependencies = [ "gix-error", ] [[package]] name = "gix-chunk" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" dependencies = [ "gix-error", ] @@ -1319,7 +1596,7 @@ dependencies = [ "gix-ref", "gix-sec", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-bom", ] @@ -1329,11 +1606,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bstr", "gix-path", "libc", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1369,7 +1646,7 @@ dependencies = [ "gix-trace", "gix-traverse", "gix-worktree", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1389,7 +1666,7 @@ dependencies = [ "gix-trace", "gix-utils", "gix-worktree", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1404,7 +1681,7 @@ dependencies = [ "gix-path", "gix-ref", "gix-sec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1432,7 +1709,7 @@ dependencies = [ "once_cell", "parking_lot", "prodash", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", "zlib-rs", ] @@ -1455,7 +1732,7 @@ dependencies = [ "gix-trace", "gix-utils", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1469,7 +1746,7 @@ dependencies = [ "gix-features", "gix-path", "gix-utils", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1478,7 +1755,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bstr", "gix-features", "gix-path", @@ -1494,7 +1771,7 @@ dependencies = [ "gix-features", "sha1-checked", "sha2 0.11.0", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1523,12 +1800,12 @@ dependencies = [ [[package]] name = "gix-imara-diff" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b" +checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" dependencies = [ "bstr", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1537,7 +1814,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e6b28cc592dc753adb58302bb14a64e412ee591a3bec77aa4df87bff74fa80d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bstr", "filetime", "fnv", @@ -1556,7 +1833,7 @@ dependencies = [ "memmap2", "rustix 1.1.4", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1567,14 +1844,14 @@ checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c" dependencies = [ "gix-tempfile", "gix-utils", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-mailmap" -version = "0.33.1" +version = "0.33.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "195fd20808055824531be2fd0d34136d900e5fbca3ffb0a3c07e8beeefb9c828" +checksum = "a824767d38b81475059cb01f5020a13fb96e7ed6bbf9851c7112b46ada78db48" dependencies = [ "bstr", "gix-actor", @@ -1598,7 +1875,7 @@ dependencies = [ "gix-validate", "itoa", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1619,7 +1896,7 @@ dependencies = [ "memmap2", "parking_lot", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1638,7 +1915,7 @@ dependencies = [ "gix-path", "memmap2", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "uluru", ] @@ -1651,19 +1928,19 @@ dependencies = [ "bstr", "faster-hex", "gix-trace", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-path" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbbecb0f8dc5cdf6cbde69133f7072064dfc9da4cf0046913afb6857b07300fa" +checksum = "1ed3e8d7a82e886e17a72e03d4ba0c13db6f2219b6cd4e2900b4cae426ec20c9" dependencies = [ "bstr", "gix-trace", "gix-validate", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1672,13 +1949,13 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bstr", "gix-attributes", "gix-config-value", "gix-glob", "gix-path", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1697,7 +1974,7 @@ dependencies = [ "gix-utils", "maybe-async", "nonempty", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1728,7 +2005,7 @@ dependencies = [ "gix-utils", "gix-validate", "memmap2", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1744,7 +2021,7 @@ dependencies = [ "gix-revision", "gix-validate", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1753,7 +2030,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b47c88884dd3c1a19a39da19d10211fcdea2809aadc86869b6e824a1774340f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bstr", "gix-commitgraph", "gix-date", @@ -1779,16 +2056,16 @@ dependencies = [ "gix-hashtable", "gix-object", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-sec" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "gix-path", "libc", "windows-sys 0.61.2", @@ -1804,7 +2081,7 @@ dependencies = [ "gix-hash", "gix-lock", "nonempty", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1827,7 +2104,7 @@ dependencies = [ "gix-pathspec", "gix-worktree", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1842,14 +2119,14 @@ dependencies = [ "gix-pathspec", "gix-refspec", "gix-url", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-tempfile" -version = "23.0.1" +version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27850097e1ff9515f46a0dad0f5f9c9d020e972727772dabab9450690c4adb22" +checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" dependencies = [ "dashmap", "gix-fs", @@ -1860,9 +2137,9 @@ dependencies = [ [[package]] name = "gix-trace" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" [[package]] name = "gix-transport" @@ -1877,7 +2154,7 @@ dependencies = [ "gix-quote", "gix-sec", "gix-url", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1886,7 +2163,7 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8de590ecc86a3b2870665f2288324fa9f7f8672c7fc2d4e020fdd81cd1f7aed" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "gix-commitgraph", "gix-date", "gix-hash", @@ -1894,7 +2171,7 @@ dependencies = [ "gix-object", "gix-revwalk", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1906,26 +2183,26 @@ dependencies = [ "bstr", "gix-path", "percent-encoding", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "gix-utils" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d773a906e39472c2b00aaf1993cd120d40198c1ff6db07c0ee9a44d4431b66c1" +checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" dependencies = [ "bstr", "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "unicode-normalization", ] [[package]] name = "gix-validate" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" +checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" dependencies = [ "bstr", ] @@ -1967,28 +2244,30 @@ dependencies = [ ] [[package]] -name = "hash32" -version = "0.3.1" +name = "group" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "byteorder", + "ff", + "rand_core", + "subtle", ] [[package]] -name = "hashbrown" -version = "0.14.5" +name = "hash32" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" -dependencies = [ - "foldhash 0.1.4", -] +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" @@ -1998,7 +2277,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -2009,7 +2288,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -2034,6 +2313,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hybrid-array" version = "0.4.13" @@ -2045,16 +2333,17 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", - "windows-core 0.52.0", + "windows-core 0.62.2", ] [[package]] @@ -2068,21 +2357,23 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", + "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2091,105 +2382,61 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2198,9 +2445,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2209,75 +2456,102 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", + "hashbrown 0.17.1", ] [[package]] name = "indoc" -version = "2.0.5" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "inotify" -version = "0.11.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "insta" -version = "1.44.3" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", "regex", "similar", + "strip-ansi-escapes", + "tempfile", ] [[package]] name = "instability" -version = "0.3.6" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "894813a444908c0c8c0e221b041771d107c4a21de1d317dc49bcc66e3c9e5b3f" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2286,9 +2560,9 @@ version = "0.1.3" [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -2301,17 +2575,18 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", + "jiff-core", "jiff-static", "jiff-tzdb-platform", "log", @@ -2321,22 +2596,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + [[package]] name = "jiff-static" -version = "0.2.32" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jiff-tzdb" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" [[package]] name = "jiff-tzdb-platform" @@ -2349,38 +2634,41 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.32" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] [[package]] name = "kasuari" -version = "0.4.8" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481b4381c813cebeca86bd55c781d21f902f34cf927ec08d6df3dfebcfd2002" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "portable-atomic", + "thiserror 2.0.19", ] [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -2388,19 +2676,19 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "libc", ] [[package]] name = "kstring" -version = "2.0.2" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +checksum = "b609e7ca5ea38f093c20a4a102335b247221c9643b7a6bc3510f196f99499a9e" dependencies = [ "static_assertions", ] @@ -2416,24 +2704,21 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +dependencies = [ + "spin", +] [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libgit2-sys" -version = "0.18.5+1.9.4" +version = "0.18.7+1.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" dependencies = [ "cc", "libc", @@ -2443,13 +2728,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.10.0", "libc", ] @@ -2469,9 +2759,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.21" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9b68e50e6e0b26f672573834882eb57759f6db9b3be2ea3c35c91188bb4eaa" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -2481,11 +2771,11 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -2502,9 +2792,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.7.4" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -2523,17 +2813,17 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" -version = "0.16.3" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -2548,20 +2838,20 @@ dependencies = [ [[package]] name = "maybe-async" -version = "0.2.10" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -2595,23 +2885,24 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.7" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", "wasi", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2620,7 +2911,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2649,7 +2940,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "fsevent-sys", "inotify", "kqueue", @@ -2675,24 +2966,43 @@ dependencies = [ [[package]] name = "notify-types" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] [[package]] name = "ntapi" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2702,7 +3012,26 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", ] [[package]] @@ -2712,6 +3041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2740,17 +3070,23 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onig" -version = "6.5.1" +version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "libc", "once_cell", "onig_sys", @@ -2758,14 +3094,20 @@ dependencies = [ [[package]] name = "onig_sys" -version = "69.9.1" +version = "69.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" dependencies = [ "cc", "pkg-config", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.1.6" @@ -2774,18 +3116,18 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.4.1+3.4.0" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa4eac4138c62414b5622d1b31c5c304f34b406b013c079c2bbc652fdd6678c" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -2809,6 +3151,68 @@ dependencies = [ "num-traits", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2 0.10.9", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2832,17 +3236,35 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" dependencies = [ "memchr", "ucd-trie", @@ -2850,9 +3272,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" dependencies = [ "pest", "pest_generator", @@ -2860,25 +3282,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" dependencies = [ "pest", - "sha2 0.10.8", ] [[package]] @@ -2921,7 +3342,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2935,21 +3356,36 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" @@ -2964,27 +3400,68 @@ dependencies = [ "time", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" -version = "1.10.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -2996,20 +3473,19 @@ dependencies = [ ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "primeorder" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "proc-macro2", - "syn 2.0.117", + "elliptic-curve", ] [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3034,9 +3510,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.38" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3055,10 +3531,21 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ + "ppv-lite86", "rand_core", ] @@ -3067,16 +3554,29 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core", +] [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", + "ratatui-termina", "ratatui-termion", "ratatui-termwiz", "ratatui-widgets", @@ -3085,30 +3585,30 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "compact_str", - "hashbrown 0.16.1", - "indoc", + "hashbrown 0.17.1", "itertools", "kasuari", "lru", + "palette", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm 0.28.1", @@ -3118,10 +3618,21 @@ dependencies = [ ] [[package]] -name = "ratatui-termion" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termion" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c732202fa5a71a9da0991013f0853e53f87048f45198e3a1ca3ee722accc2f" dependencies = [ "instability", "ratatui-core", @@ -3130,9 +3641,9 @@ dependencies = [ [[package]] name = "ratatui-termwiz" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -3147,17 +3658,17 @@ dependencies = [ "ratatui-core", "ratatui-crossterm", "ratatui-widgets", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.16.1", + "bitflags 2.13.1", + "hashbrown 0.17.1", "indoc", "instability", "itertools", @@ -3167,14 +3678,14 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -3192,29 +3703,29 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3224,9 +3735,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3235,17 +3746,27 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rfc6979" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] [[package]] name = "ron" -version = "0.12.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "once_cell", "serde", "serde_derive", @@ -3253,11 +3774,32 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc_version" @@ -3270,11 +3812,11 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.43" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3287,7 +3829,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3296,15 +3838,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.19" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -3315,15 +3857,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22b2d775fb28f245817589471dd49c5edf64237f4a19d10ce9a92ff4651a27f4" -dependencies = [ - "sdd", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -3338,22 +3871,30 @@ dependencies = [ ] [[package]] -name = "sdd" -version = "3.0.8" +name = "sec1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "584e070911c7017da6cb2eb0788d09f43d789029b5877d3e5ecc8acf86ceee21" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] [[package]] name = "semver" -version = "1.0.24" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3361,70 +3902,70 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.135" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serial_test" -version = "3.3.1" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.3.1" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures 0.2.16", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3440,12 +3981,12 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.16", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3468,97 +4009,188 @@ checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shellexpand" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ "dirs", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] -name = "signal-hook" -version = "0.3.17" +name = "sized-chunks" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" dependencies = [ - "libc", - "signal-hook-registry", + "bitmaps", + "typenum", ] [[package]] -name = "signal-hook-mio" -version = "0.2.4" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" -dependencies = [ - "libc", - "mio", - "signal-hook", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "signal-hook-registry" -version = "1.4.2" +name = "smallvec" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" -dependencies = [ - "libc", -] +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] -name = "similar" -version = "2.7.0" +name = "spin" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] -name = "simplelog" -version = "0.12.2" +name = "spki" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ - "log", - "time", + "base64ct", + "der", ] [[package]] -name = "siphasher" -version = "1.0.1" +name = "ssh-cipher" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] [[package]] -name = "slab" -version = "0.4.9" +name = "ssh-encoding" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" dependencies = [ - "autocfg", + "base64ct", + "pem-rfc7468", + "sha2 0.10.9", ] [[package]] -name = "smallvec" -version = "1.15.1" +name = "ssh-key" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2 0.10.9", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -3566,6 +4198,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3574,45 +4215,51 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "struct-patch" -version = "0.10.4" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e986d2cf6e819bd843319120453d837dfdfa31497c3fee4cefa614b2d182d8c" +checksum = "16d4caaaccd69c9b56c5f5b33d4dca462464d3275230e4d2d3739ba6d4bf5bcb" dependencies = [ "struct-patch-derive", ] [[package]] name = "struct-patch-derive" -version = "0.10.4" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c6387c1c7b53060605101b63d93edca618c6cf7ce61839f2ec2a527419fdb5" +checksum = "1671c6f0992b1b4cb4f5f8ea4a58f9a5f7f895a7638ef9690633dcec0aa67944" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -3626,9 +4273,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -3637,13 +4295,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3663,7 +4321,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", ] @@ -3688,12 +4346,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -3734,7 +4405,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.10.0", + "bitflags 2.13.1", "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", @@ -3752,7 +4423,7 @@ dependencies = [ "pest_derive", "phf", "serde", - "sha2 0.10.8", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -3780,11 +4451,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -3795,38 +4466,36 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -3838,15 +4507,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3854,9 +4523,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -3864,9 +4533,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3923,24 +4592,24 @@ checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" [[package]] name = "unicode-ident" -version = "1.0.14" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -3950,7 +4619,7 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -3961,33 +4630,32 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "universal-hash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4002,12 +4670,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -4025,6 +4693,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -4046,33 +4723,24 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen 0.46.0", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -4083,9 +4751,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4093,60 +4761,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.15.2", - "indexmap", - "semver", -] - [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -4166,7 +4800,7 @@ dependencies = [ "getrandom 0.3.4", "mac_address", "serde", - "sha2 0.10.8", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -4223,13 +4857,11 @@ dependencies = [ [[package]] name = "which" -version = "8.0.0" +version = "8.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" dependencies = [ - "env_home", - "rustix 1.1.4", - "winsafe", + "libc", ] [[package]] @@ -4250,11 +4882,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4275,23 +4907,27 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.52.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", "windows-targets 0.52.6", ] [[package]] name = "windows-core" -version = "0.57.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", - "windows-result", - "windows-targets 0.52.6", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", ] [[package]] @@ -4302,7 +4938,18 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -4313,7 +4960,18 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -4332,12 +4990,21 @@ dependencies = [ ] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-targets 0.52.6", + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", ] [[package]] @@ -4496,174 +5163,110 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "writeable" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "yansi" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +name = "yoke" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ - "anyhow", - "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", + "syn 2.0.119", + "synstructure", ] [[package]] -name = "wit-component" -version = "0.244.0" +name = "zerocopy" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ - "anyhow", - "bitflags 2.10.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "zerocopy-derive", ] [[package]] -name = "wit-parser" -version = "0.244.0" +name = "zerocopy-derive" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - -[[package]] -name = "writeable" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.7.5" +name = "zerofrom" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", + "zerofrom-derive", ] [[package]] -name = "yoke-derive" -version = "0.7.5" +name = "zerofrom-derive" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] -name = "zerofrom" -version = "0.1.5" +name = "zeroize" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" -dependencies = [ - "zerofrom-derive", -] +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] -name = "zerofrom-derive" -version = "0.1.5" +name = "zerotrie" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", + "displaydoc", + "yoke", + "zerofrom", ] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -4672,13 +5275,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4686,3 +5289,9 @@ name = "zlib-rs" version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index d3d0d744b9..ea5d67f099 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,10 +1,9 @@ use super::utils::graphrow::{ - lane_color, SYM_BRANCH_DOWN, SYM_BRANCH_UP, SYM_BRANCH_UP_RIGHT, - SYM_COMMIT, SYM_COMMIT_BRANCH, SYM_COMMIT_MERGE, - SYM_COMMIT_STASH, SYM_COMMIT_UNCOMMITTED, SYM_HORIZONTAL, - SYM_MERGE_BRIDGE_END, SYM_MERGE_BRIDGE_MID, - SYM_MERGE_BRIDGE_START, SYM_SPACE, SYM_VERTICAL, - SYM_VERTICAL_DOTTED, + SYM_BRANCH_DOWN, SYM_BRANCH_UP, SYM_BRANCH_UP_RIGHT, SYM_COMMIT, + SYM_COMMIT_BRANCH, SYM_COMMIT_MERGE, SYM_COMMIT_STASH, + SYM_COMMIT_UNCOMMITTED, SYM_HORIZONTAL, SYM_MERGE_BRIDGE_END, + SYM_MERGE_BRIDGE_MID, SYM_MERGE_BRIDGE_START, SYM_SPACE, + SYM_VERTICAL, SYM_VERTICAL_DOTTED, }; use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ @@ -488,81 +487,76 @@ impl CommitList { ) -> Vec> { let mut spans = Vec::new(); + let graph_color = + self.theme.commit_hash(false).fg.unwrap_or(Color::Reset); + for (lane_index, conn) in row.lanes.iter().enumerate() { if empty_lanes.contains(&lane_index) { continue; } let (sym, color) = match conn { None => (SYM_SPACE, Color::Reset), - Some((ConnType::Vertical, ci)) => { - (SYM_VERTICAL, lane_color(*ci)) + Some((ConnType::Vertical, _)) => { + (SYM_VERTICAL, graph_color) } - Some((ConnType::VerticalDotted, ci)) => { - (SYM_VERTICAL_DOTTED, lane_color(*ci)) + Some((ConnType::VerticalDotted, _)) => { + (SYM_VERTICAL_DOTTED, graph_color) } - Some((ConnType::CommitNormal, ci)) => { - (SYM_COMMIT, lane_color(*ci)) + Some((ConnType::CommitNormal, _)) => { + (SYM_COMMIT, graph_color) } - Some((ConnType::CommitBranch, ci)) => { - (SYM_COMMIT_BRANCH, lane_color(*ci)) + Some((ConnType::CommitBranch, _)) => { + (SYM_COMMIT_BRANCH, graph_color) } - Some((ConnType::CommitMerge, ci)) => { - (SYM_COMMIT_MERGE, lane_color(*ci)) + Some((ConnType::CommitMerge, _)) => { + (SYM_COMMIT_MERGE, graph_color) } - Some((ConnType::CommitStash, ci)) => { - (SYM_COMMIT_STASH, lane_color(*ci)) + Some((ConnType::CommitStash, _)) => { + (SYM_COMMIT_STASH, graph_color) } - Some((ConnType::CommitUncommitted, ci)) => { - (SYM_COMMIT_UNCOMMITTED, lane_color(*ci)) + Some((ConnType::CommitUncommitted, _)) => { + (SYM_COMMIT_UNCOMMITTED, graph_color) } - Some((ConnType::MergeBridgeStart, ci)) => { - (SYM_MERGE_BRIDGE_START, lane_color(*ci)) + Some((ConnType::MergeBridgeStart, _)) => { + (SYM_MERGE_BRIDGE_START, graph_color) } - Some((ConnType::MergeBridgeMid, ci)) => { - (SYM_MERGE_BRIDGE_MID, lane_color(*ci)) + Some((ConnType::MergeBridgeMid, _)) => { + (SYM_MERGE_BRIDGE_MID, graph_color) } - Some((ConnType::MergeBridgeEnd, ci)) => { - (SYM_MERGE_BRIDGE_END, lane_color(*ci)) + Some((ConnType::MergeBridgeEnd, _)) => { + (SYM_MERGE_BRIDGE_END, graph_color) } - Some((ConnType::BranchDown, ci)) => { - (SYM_BRANCH_DOWN, lane_color(*ci)) + Some((ConnType::BranchDown, _)) => { + (SYM_BRANCH_DOWN, graph_color) } - Some((ConnType::BranchUp, ci)) => { - (SYM_BRANCH_UP, lane_color(*ci)) + Some((ConnType::BranchUp, _)) => { + (SYM_BRANCH_UP, graph_color) } - Some((ConnType::BranchUpRight, ci)) => { - (SYM_BRANCH_UP_RIGHT, lane_color(*ci)) + Some((ConnType::BranchUpRight, _)) => { + (SYM_BRANCH_UP_RIGHT, graph_color) } }; spans.push(Span::styled(sym, Style::default().fg(color))); // Spacer - let mut bridge_color = row.commit_lane % 16; let mut is_bridge_lane = false; if let Some((from, to)) = row.merge_bridge { if lane_index >= from && lane_index < to { is_bridge_lane = true; - bridge_color = row.commit_lane % 16; } } for &(from, to) in &row.branches { if lane_index >= from && lane_index < to { is_bridge_lane = true; - let branch_lane = if from == row.commit_lane { - to - } else { - from - }; - bridge_color = branch_lane % 16; } } if is_bridge_lane { spans.push(Span::styled( SYM_HORIZONTAL, - Style::default().fg(lane_color(bridge_color)), + Style::default().fg(graph_color), )); continue; } @@ -776,6 +770,9 @@ impl CommitList { self.graph_col_width.set(0); } + let any_marked = !self.marked.is_empty(); + let now = Local::now(); + for (idx, e) in self .items .iter() diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index ee99751d99..23687c5493 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -142,12 +142,11 @@ impl Revlog { for (id, branches) in self.list.local_branches() { branch_tips.insert(*id); - let is_head = |b: &_| { - b.local_details().is_some_and(|d| d.is_head) - }; if head_id.is_none() - && branches.iter().any(is_head) - { + && branches.iter().any(|b| { + b.local_details() + .is_some_and(|d| d.is_head) + }) { head_id = Some(*id); } } From 797cd11b58294289b555597029ea6969288c60b1 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:33:05 -0400 Subject: [PATCH 20/84] Now reusing lanes to remove whitespace --- asyncgit/src/graph/buffer.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index 88fd12b2c2..37d6f01b0c 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -46,6 +46,16 @@ impl Buffer { pub fn update(&mut self, new_chunk: Chunk) { self.pending_delta.clear(); + let mut empty_lanes: Vec = self + .current + .iter() + .enumerate() + .filter_map(|(i, c)| c.is_none().then_some(i)) + .collect(); + + // sort descending so we can pop the lowest index first + empty_lanes.sort_unstable_by(|a, b| b.cmp(a)); + let mut found_idx = None; if new_chunk.alias.is_some() { for (i, c) in self.current.iter().enumerate() { @@ -60,6 +70,8 @@ impl Buffer { if let Some(idx) = found_idx { self.record_replace(idx, Some(new_chunk.clone())); + } else if let Some(empty_idx) = empty_lanes.pop() { + self.record_replace(empty_idx, Some(new_chunk.clone())); } else { self.record_insert( self.current.len(), @@ -118,10 +130,18 @@ impl Buffer { parent_b: None, marker: Markers::Commit, }; - self.record_insert( - self.current.len(), - Some(new_lane), - ); + + if let Some(empty_idx) = empty_lanes.pop() { + self.record_replace( + empty_idx, + Some(new_lane), + ); + } else { + self.record_insert( + self.current.len(), + Some(new_lane), + ); + } } } } From 8d2cc4c923695103bfe369e3efcd7d6c707695ab Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:34:25 -0400 Subject: [PATCH 21/84] Formatting --- asyncgit/src/graph/chunk.rs | 12 ++++++------ asyncgit/src/revlog.rs | 6 ++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index ae74013cfc..cbfe6680ac 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -1,13 +1,13 @@ #[derive(Clone, Debug, PartialEq)] pub enum Markers { - Uncommitted, - Commit, + Uncommitted, + Commit, } #[derive(Clone, Debug)] pub struct Chunk { - pub alias: Option, - pub parent_a: Option, - pub parent_b: Option, - pub marker: Markers, + pub alias: Option, + pub parent_a: Option, + pub parent_b: Option, + pub marker: Markers, } diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 480431097c..607a2babf1 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -419,7 +419,8 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); - let arc_graph_walker = Arc::new(Mutex::new(GraphWalker::new())); + let arc_graph_walker = + Arc::new(Mutex::new(GraphWalker::new())); let result = AsyncLog::fetch_helper_without_filter( &subdir_path, @@ -449,7 +450,8 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); - let arc_graph_walker = Arc::new(Mutex::new(GraphWalker::new())); + let arc_graph_walker = + Arc::new(Mutex::new(GraphWalker::new())); std::env::set_var("GIT_DIR", git_dir); From 76eb1abc9338d33185f1351d6bce73f196ecdafc Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:39:07 -0400 Subject: [PATCH 22/84] unit test #1 --- src/components/commitlist.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index ea5d67f099..f153ffb650 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1280,4 +1280,24 @@ mod tests { ))) ); } + + #[test] + fn test_build_graph_spans() { + let cl = CommitList::default(); + let row = GraphRow { + lane_count: 1, + commit_lane: 0, + is_merge: false, + is_branch_tip: false, + is_stash: false, + lanes: vec![Some((ConnType::CommitNormal, 0))], + merge_bridge: None, + branches: vec![], + }; + let empty_lanes = std::collections::HashSet::new(); + let spans = cl.build_graph_spans(&row, 1, &empty_lanes); + + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, Cow::from(SYM_COMMIT)); + } } From db6b4da766724030fb69fbfa736aaefd4f451037 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:39:27 -0400 Subject: [PATCH 23/84] fmt --- src/components/commitlist.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index f153ffb650..f519fed18a 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1296,7 +1296,7 @@ mod tests { }; let empty_lanes = std::collections::HashSet::new(); let spans = cl.build_graph_spans(&row, 1, &empty_lanes); - + assert_eq!(spans.len(), 1); assert_eq!(spans[0].content, Cow::from(SYM_COMMIT)); } From 57cdbd2da3af7f223dc11e6c83b216dcd5f459c8 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:40:25 -0400 Subject: [PATCH 24/84] Added changelog entry --- CHANGELOG.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ca0eaac2..937d852125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) +* commit log graph visualizing branch topology [[@philocalyst](https://github.com/philocalyst)] ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting @@ -21,17 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * when staging the last file in a directory, the first item after the directory is no longer skipped [[@Tillerino](https://github.com/Tillerino)] ([#2748](https://github.com/gitui-org/gitui/issues/2748)) * index-out-of-bounds panic when unstaging lines near the end of a diff ([#2953](https://github.com/gitui-org/gitui/issues/2953)) -## [0.28.1] - 2026-03-21 - -### Changed -* support proper pre-push hook ([#2809](https://github.com/gitui-org/gitui/issues/2809)) -* improve `gitui --version` message [[@hlsxx](https://github.com/hlsxx)] ([#2838](https://github.com/gitui-org/gitui/issues/2838)) -* rust msrv bumped to `1.88` - -### Fixed -* fix extremely slow status loading in large repositories by replacing time-based cache invalidation with generation counter [[@DannyStoll1](https://github.com/DannyStoll1)] ([#2823](https://github.com/gitui-org/gitui/issues/2823)) -* fix panic when renaming or updating remote URL with no remotes configured [[@xvchris](https://github.com/xvchris)] ([#2868](https://github.com/gitui-org/gitui/issues/2868)) - ## [0.28.0] - 2025-12-14 **discard changes on checkout** From c286c3beb29421e099ed84c186492813045269d0 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 20:48:41 -0400 Subject: [PATCH 25/84] Fixed clippy lints --- asyncgit/src/graph/buffer.rs | 78 ++++++------- asyncgit/src/graph/chunk.rs | 2 +- asyncgit/src/graph/mod.rs | 6 +- asyncgit/src/graph/oids.rs | 5 +- asyncgit/src/graph/walker.rs | 187 ++++++++++++++++++------------ asyncgit/src/sync/commits_info.rs | 8 +- 6 files changed, 153 insertions(+), 133 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index 37d6f01b0c..d197a3b828 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -43,7 +43,7 @@ impl Buffer { self.mergers.push(alias); } - pub fn update(&mut self, new_chunk: Chunk) { + pub fn update(&mut self, new_chunk: &Chunk) { self.pending_delta.clear(); let mut empty_lanes: Vec = self @@ -56,17 +56,15 @@ impl Buffer { // sort descending so we can pop the lowest index first empty_lanes.sort_unstable_by(|a, b| b.cmp(a)); - let mut found_idx = None; - if new_chunk.alias.is_some() { - for (i, c) in self.current.iter().enumerate() { - if let Some(c) = c { - if c.parent_a == new_chunk.alias { - found_idx = Some(i); - break; - } - } - } - } + let found_idx = if new_chunk.alias.is_some() { + self.current.iter().enumerate().find_map(|(i, c)| { + c.as_ref().and_then(|c| { + (c.parent_a == new_chunk.alias).then_some(i) + }) + }) + } else { + None + }; if let Some(idx) = found_idx { self.record_replace(idx, Some(new_chunk.clone())); @@ -89,20 +87,18 @@ impl Buffer { } if let Some(mut c) = self.current[index].clone() { - let mut changed = false; - - if new_chunk.alias.is_some() - && c.parent_a == new_chunk.alias - { - c.parent_a = None; - changed = true; - } - if new_chunk.alias.is_some() - && c.parent_b == new_chunk.alias - { - c.parent_b = None; - changed = true; - } + let changed = new_chunk.alias.is_some_and(|alias| { + let mut changed = false; + if c.parent_a == Some(alias) { + c.parent_a = None; + changed = true; + } + if c.parent_b == Some(alias) { + c.parent_b = None; + changed = true; + } + changed + }); if changed { if c.parent_a.is_none() && c.parent_b.is_none() { @@ -117,7 +113,7 @@ impl Buffer { while let Some(alias) = self.mergers.pop() { if let Some(index) = self.current.iter().position(|c| { c.as_ref() - .map_or(false, |chunk| chunk.alias == Some(alias)) + .is_some_and(|chunk| chunk.alias == Some(alias)) }) { if let Some(mut c) = self.current[index].clone() { let parent_b = c.parent_b; @@ -146,14 +142,8 @@ impl Buffer { } } - loop { - if let Some(last) = self.current.last() { - if last.is_none() { - self.record_remove(self.current.len() - 1); - continue; - } - } - break; + while self.current.last().is_some_and(Option::is_none) { + self.record_remove(self.current.len() - 1); } let delta = Delta(self.pending_delta.clone()); @@ -193,12 +183,11 @@ impl Buffer { start: usize, end: usize, ) -> Vec>> { - let (current_index, mut state) = self - .checkpoints - .range(..=start) - .next_back() - .map(|(&i, s)| (Some(i), s.clone())) - .unwrap_or((None, Vector::new())); + let (current_index, mut state) = + self.checkpoints.range(..=start).next_back().map_or_else( + || (None, Vector::new()), + |(&i, s)| (Some(i), s.clone()), + ); let mut history = Vec::with_capacity(end.saturating_sub(start) + 1); @@ -209,11 +198,11 @@ impl Buffer { } } - let loop_start = current_index.map(|i| i + 1).unwrap_or(0); + let loop_start = current_index.map_or(0, |i| i + 1); for delta_index in loop_start..=end { if let Some(delta) = self.deltas.get(delta_index) { - self.apply_delta_to_state(&mut state, delta); + Self::apply_delta_to_state(&mut state, delta); if delta_index >= start { history.push(state.clone()); @@ -227,14 +216,13 @@ impl Buffer { } fn apply_delta_to_state( - &self, state: &mut Vector>, delta: &Delta, ) { for op in &delta.0 { match op { DeltaOp::Insert { index, item } => { - state.insert(*index, item.clone()) + state.insert(*index, item.clone()); } DeltaOp::Remove { index } => { state.remove(*index); diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index cbfe6680ac..fb0adacf13 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -1,4 +1,4 @@ -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Markers { Uncommitted, Commit, diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index 11f3b083ee..1ca9b16084 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -5,7 +5,7 @@ pub mod walker; pub use walker::GraphWalker; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ConnType { Vertical, VerticalDotted, @@ -41,11 +41,11 @@ pub struct GraphRow { /// Connections emitted per lane: /// None = empty space - /// Some((ConnType, color_index)) = draw this connector in this color + /// Some((ConnType, `color_index`)) = draw this connector in this color pub lanes: Vec>, /// Horizontal merge bridge: if this commit merges rightward, - /// (from_lane, to_lane) — the span to draw ─ ╭ ╮ across + /// (`from_lane`, `to_lane`) — the span to draw ─ ╭ ╮ across pub merge_bridge: Option<(usize, usize)>, /// Horizontal branch bridges: if this commit spawns branches, diff --git a/asyncgit/src/graph/oids.rs b/asyncgit/src/graph/oids.rs index b6c5e5b2a2..0dbecdf441 100644 --- a/asyncgit/src/graph/oids.rs +++ b/asyncgit/src/graph/oids.rs @@ -5,7 +5,7 @@ pub struct Oids { /// alias pub ids: Vec, - /// CommitId to alias + /// `CommitId` to alias pub aliases: HashMap, } @@ -27,7 +27,8 @@ impl Oids { if let Some(&alias) = self.aliases.get(id) { return alias; } - let alias = self.ids.len() as u32; + let alias = + u32::try_from(self.ids.len()).expect("too many oids"); self.ids.push(*id); self.aliases.insert(*id, alias); alias diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index ea39cb3f52..14196fe35f 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -30,38 +30,38 @@ impl GraphWalker { } pub fn process(&mut self, commit: &CommitInfo) { - let alias = Some(self.oids.get_or_insert(&commit.id)); - let parent_a = - commit.parents.get(0).map(|p| self.oids.get_or_insert(p)); + let alias = self.oids.get_or_insert(&commit.id); + let parent_a = commit + .parents + .first() + .map(|p| self.oids.get_or_insert(p)); let parent_b = commit.parents.get(1).map(|p| self.oids.get_or_insert(p)); let chunk = Chunk { - alias, + alias: Some(alias), parent_a, parent_b, marker: Markers::Commit, }; - if let (Some(a), Some(b)) = (alias, parent_b) { - self.mergers_map.insert(a, b); + if let Some(b) = parent_b { + self.mergers_map.insert(alias, b); } if parent_a.is_some() && parent_b.is_some() { let already_tracked = self.buffer.current.iter().any(|c| { - if let Some(c) = c { + c.as_ref().is_some_and(|c| { c.parent_a == parent_b && c.parent_b.is_none() - } else { - false - } + }) }); if !already_tracked { - self.buffer.merger(alias.unwrap()); + self.buffer.merger(alias); } } - self.buffer.update(chunk); + self.buffer.update(&chunk); } pub fn snapshot_at( @@ -125,14 +125,14 @@ impl GraphWalker { let commit_lane = curr .iter() .position(|c| { - c.as_ref().map_or(false, |chunk| { + c.as_ref().is_some_and(|chunk| { alias.is_some() && chunk.alias == alias }) }) .unwrap_or(0); let parent_b_alias = - alias.and_then(|a| self.mergers_map.get(&a).cloned()); + alias.and_then(|a| self.mergers_map.get(&a).copied()); let is_merge = parent_b_alias.is_some(); let is_branch_tip = branch_tips.contains(commit_id); @@ -144,7 +144,7 @@ impl GraphWalker { .enumerate() .filter(|(i, pc)| { pc.is_some() - && curr.get(*i).map_or(true, |c| c.is_none()) + && curr.get(*i).is_none_or(Option::is_none) }) .map(|(i, _)| i) .collect(); @@ -154,7 +154,7 @@ impl GraphWalker { let merge_bridge = is_merge .then(|| { let target_lane = curr.iter().position(|c| { - c.as_ref().map_or(false, |chunk| { + c.as_ref().is_some_and(|chunk| { parent_b_alias.is_some() && chunk.parent_a == parent_b_alias }) @@ -169,19 +169,84 @@ impl GraphWalker { }) .flatten(); + self.fill_lanes( + &mut lanes, + curr, + alias, + head_id, + is_stash, + is_merge, + is_branch_tip, + &branching_lanes, + ); + + if let Some((from, to)) = merge_bridge { + Self::draw_bridge( + &mut lanes, + from, + to, + commit_lane, + ConnType::MergeBridgeMid, + ConnType::MergeBridgeStart, + ConnType::MergeBridgeEnd, + ); + } + + let mut branches = Vec::new(); + for &branch_lane in &branching_lanes { + let from = std::cmp::min(branch_lane, commit_lane); + let to = std::cmp::max(branch_lane, commit_lane); + branches.push((from, to)); + + if lanes.len() <= to { + lanes.resize(to + 1, None); + } + + Self::draw_bridge( + &mut lanes, + from, + to, + branch_lane, + ConnType::MergeBridgeMid, + ConnType::BranchUp, + ConnType::BranchUpRight, + ); + } + + GraphRow { + lane_count: curr.iter().flatten().count(), + commit_lane, + is_merge, + is_branch_tip, + is_stash, + lanes, + merge_bridge, + branches, + } + } + + #[allow(clippy::too_many_arguments)] + fn fill_lanes( + &self, + lanes: &mut [Option<(ConnType, usize)>], + curr: &Vector>, + alias: Option, + head_id: Option<&CommitId>, + is_stash: bool, + is_merge: bool, + is_branch_tip: bool, + branching_lanes: &[usize], + ) { for (lane_idx, chunk_item) in curr.iter().enumerate() { - if chunk_item.is_none() { + let Some(chunk) = chunk_item.as_ref() else { if branching_lanes.contains(&lane_idx) { lanes[lane_idx] = Some((ConnType::BranchUp, lane_idx % 16)); } continue; - } - - let chunk = chunk_item.as_ref().unwrap(); + }; if alias.is_some() && chunk.alias == alias { - // basically a from impl inline here let conn_type = match (is_stash, is_merge, is_branch_tip) { (true, _, _) => ConnType::CommitStash, @@ -202,70 +267,38 @@ impl GraphWalker { let is_orphan = chunk.parent_a.is_none() && chunk.parent_b.is_none(); - let conn = match (is_dotted, is_orphan) { - (true, _) => ConnType::VerticalDotted, - (_, true) => continue, - _ => ConnType::Vertical, + if is_orphan { + continue; + } + + let conn = if is_dotted { + ConnType::VerticalDotted + } else { + ConnType::Vertical }; lanes[lane_idx] = Some((conn, lane_idx % 16)); } } + } - if let Some((from, to)) = merge_bridge { - for bridge_lane in (from + 1)..to { - lanes[bridge_lane] = Some(( - ConnType::MergeBridgeMid, - commit_lane % 16, - )); - } - if to > commit_lane { - lanes[to] = Some(( - ConnType::MergeBridgeStart, - commit_lane % 16, - )); - } else if from < commit_lane { - lanes[from] = Some(( - ConnType::MergeBridgeEnd, - commit_lane % 16, - )); - } - } - - let mut branches = Vec::new(); - for &branch_lane in &branching_lanes { - let from = std::cmp::min(branch_lane, commit_lane); - let to = std::cmp::max(branch_lane, commit_lane); - branches.push((from, to)); - - if lanes.len() <= to { - lanes.resize(to + 1, None); - } - - for bridge_lane in (from + 1)..to { - lanes[bridge_lane] = Some(( - ConnType::MergeBridgeMid, - branch_lane % 16, - )); - } - if to > commit_lane { - lanes[to] = - Some((ConnType::BranchUp, branch_lane % 16)); - } else if from < commit_lane { - lanes[from] = - Some((ConnType::BranchUpRight, branch_lane % 16)); - } + fn draw_bridge( + lanes: &mut [Option<(ConnType, usize)>], + from: usize, + to: usize, + color_lane: usize, + mid: ConnType, + start: ConnType, + end: ConnType, + ) { + for lane in lanes.iter_mut().take(to).skip(from + 1) { + *lane = Some((mid, color_lane % 16)); } - GraphRow { - lane_count: curr.iter().filter(|c| c.is_some()).count(), - commit_lane, - is_merge, - is_branch_tip, - is_stash, - lanes, - merge_bridge, - branches, + if to > color_lane { + lanes[to] = Some((start, color_lane % 16)); + } else if from < color_lane { + lanes[from] = Some((end, color_lane % 16)); } } } diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 5fb6a608f1..8b6a7a6fad 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -202,11 +202,9 @@ pub fn get_commit_info( .parents .iter() .take(2) - .map(|p| { - CommitId::from_str_unchecked(&p.to_string()) - .expect("valid oid") - }) - .collect(), + .map(|p| CommitId::from_str_unchecked(&p.to_string())) + .collect::, _>>()? + .into(), }) } From 525286f0b44292b46058eb1687fc289d3d9d41e2 Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 21:54:34 -0400 Subject: [PATCH 26/84] More clippy lints --- asyncgit/src/graph/buffer.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index d197a3b828..34e739397e 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -88,16 +88,15 @@ impl Buffer { if let Some(mut c) = self.current[index].clone() { let changed = new_chunk.alias.is_some_and(|alias| { - let mut changed = false; - if c.parent_a == Some(alias) { + let a = c.parent_a == Some(alias); + let b = c.parent_b == Some(alias); + if a { c.parent_a = None; - changed = true; } - if c.parent_b == Some(alias) { + if b { c.parent_b = None; - changed = true; } - changed + a || b }); if changed { From 2136ce1444c60ee8673a47ea03136004711985ad Mon Sep 17 00:00:00 2001 From: Myles Wirth Date: Mon, 23 Mar 2026 21:57:35 -0400 Subject: [PATCH 27/84] Fixed more clippy lints --- src/components/commitlist.rs | 125 +++++++++++++++++++++-------------- 1 file changed, 76 insertions(+), 49 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index f519fed18a..0ebde88c5d 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -202,14 +202,14 @@ impl CommitList { } /// - pub fn local_branches( + pub const fn local_branches( &self, ) -> &std::collections::BTreeMap> { &self.local_branches } /// - pub fn remote_branches( + pub const fn remote_branches( &self, ) -> &std::collections::BTreeMap> { &self.remote_branches @@ -590,26 +590,14 @@ impl CommitList { ); if self.show_graph { - if let Some(ref row) = e.graph { - txt.extend(self.build_graph_spans( - row, - self.graph_col_width.get(), - empty_lanes, - )); - } else { - txt.push(Span::raw( - " ".repeat(self.graph_col_width.get()), - )); - } - txt.push(Span::raw(" ")); + self.add_graph_spans(e, &mut txt, empty_lanes); } let normal = !self.items.highlighting() || (self.items.highlighting() && e.highlighted); - let splitter_txt = Cow::from(symbol::EMPTY_SPACE); let splitter = Span::styled( - splitter_txt, + Cow::from(symbol::EMPTY_SPACE), if normal { theme.text(true, selected) } else { @@ -630,40 +618,80 @@ impl CommitList { txt.push(splitter.clone()); } - let style_hash = if normal { - theme.commit_hash(selected) - } else { - theme.commit_unhighlighted() - }; - let style_time = if normal { - theme.commit_time(selected) - } else { - theme.commit_unhighlighted() - }; - let style_author = if normal { - theme.commit_author(selected) - } else { - theme.commit_unhighlighted() - }; - let style_tags = if normal { - theme.tags(selected) - } else { - theme.commit_unhighlighted() - }; - let style_branches = if normal { - theme.branch(selected, true) + Self::add_entry_details( + e, + &mut txt, + selected, + normal, + theme, + width, + now, + tags, + local_branches, + remote_branches, + &splitter, + ); + + Line::from(txt) + } + + fn add_graph_spans<'a>( + &self, + e: &'a LogEntry, + txt: &mut Vec>, + empty_lanes: &std::collections::HashSet, + ) { + if let Some(ref row) = e.graph { + txt.extend(self.build_graph_spans( + row, + self.graph_col_width.get(), + empty_lanes, + )); } else { - theme.commit_unhighlighted() - }; - let style_msg = if normal { - theme.text(true, selected) + txt.push(Span::raw( + " ".repeat(self.graph_col_width.get()), + )); + } + txt.push(Span::raw(" ")); + } + + #[allow(clippy::too_many_arguments)] + fn add_entry_details<'a>( + e: &'a LogEntry, + txt: &mut Vec>, + selected: bool, + normal: bool, + theme: &Theme, + width: usize, + now: DateTime, + tags: Option, + local_branches: Option, + remote_branches: Option, + splitter: &Span<'a>, + ) { + let ( + style_hash, + style_time, + style_author, + style_tags, + style_branches, + style_msg, + ) = if normal { + ( + theme.commit_hash(selected), + theme.commit_time(selected), + theme.commit_author(selected), + theme.tags(selected), + theme.branch(selected, true), + theme.text(true, selected), + ) } else { - theme.commit_unhighlighted() + let s = theme.commit_unhighlighted(); + (s, s, s, s, s, s) }; // commit hash txt.push(Span::styled(Cow::from(&*e.hash_short), style_hash)); - txt.push(splitter.clone()); // commit timestamp @@ -671,7 +699,6 @@ impl CommitList { Cow::from(e.time_to_string(now)), style_time, )); - txt.push(splitter.clone()); let author_width = @@ -680,7 +707,6 @@ impl CommitList { // commit author txt.push(Span::styled(author, style_author)); - txt.push(splitter.clone()); // commit tags @@ -698,7 +724,7 @@ impl CommitList { txt.push(Span::styled(remote_branches, style_branches)); } - txt.push(splitter); + txt.push(splitter.clone()); let message_width = width.saturating_sub( txt.iter().map(|span| span.content.len()).sum(), @@ -709,8 +735,6 @@ impl CommitList { format!("{:message_width$}", e.msg), style_msg, )); - - Line::from(txt) } fn get_text(&self, height: usize, width: usize) -> Vec> { @@ -1133,6 +1157,8 @@ mod tests { std::path::PathBuf::default(), )), queue: Queue::default(), + show_graph: true, + graph_col_width: Cell::new(0), } } } @@ -1167,6 +1193,7 @@ mod tests { time: 0, author: String::default(), id: CommitId::default(), + parents: Vec::default().into(), }; // This just creates a sequence of fake ordered ids // 0000000000000000000000000000000000000000 From fc1e0426b57a22f7292fb6609b9602fcbe2afe47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 16 Apr 2026 21:59:53 -0400 Subject: [PATCH 28/84] Added cross type --- asyncgit/src/graph/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index 1ca9b16084..9acdfd4d4c 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -9,6 +9,7 @@ pub use walker::GraphWalker; pub enum ConnType { Vertical, VerticalDotted, + Cross, CommitNormal, CommitBranch, CommitMerge, From d90e20912717f0a06f7d17d2357256f1fb125c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 16 Apr 2026 22:00:05 -0400 Subject: [PATCH 29/84] Fixed the rest of the corner logic This was annoying --- asyncgit/src/graph/walker.rs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 14196fe35f..b04645a9ab 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -181,11 +181,14 @@ impl GraphWalker { ); if let Some((from, to)) = merge_bridge { + let target_lane = + if from == commit_lane { to } else { from }; Self::draw_bridge( &mut lanes, from, to, commit_lane, + target_lane, ConnType::MergeBridgeMid, ConnType::MergeBridgeStart, ConnType::MergeBridgeEnd, @@ -207,6 +210,7 @@ impl GraphWalker { from, to, branch_lane, + branch_lane, ConnType::MergeBridgeMid, ConnType::BranchUp, ConnType::BranchUpRight, @@ -287,18 +291,29 @@ impl GraphWalker { from: usize, to: usize, color_lane: usize, + corner_lane: usize, mid: ConnType, - start: ConnType, - end: ConnType, + corner_right: ConnType, + corner_left: ConnType, ) { for lane in lanes.iter_mut().take(to).skip(from + 1) { - *lane = Some((mid, color_lane % 16)); + match lane { + Some(( + ConnType::Vertical | ConnType::VerticalDotted, + _, + )) => { + *lane = Some((ConnType::Cross, color_lane % 16)); + } + _ => { + *lane = Some((mid, color_lane % 16)); + } + } } - if to > color_lane { - lanes[to] = Some((start, color_lane % 16)); - } else if from < color_lane { - lanes[from] = Some((end, color_lane % 16)); + if corner_lane == to { + lanes[to] = Some((corner_right, color_lane % 16)); + } else if corner_lane == from { + lanes[from] = Some((corner_left, color_lane % 16)); } } } From 3a7db6696cc17886f45c0215e98544aab783a0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 16 Apr 2026 22:01:45 -0400 Subject: [PATCH 30/84] Implemented differentiation With color and crosses! --- src/components/commitlist.rs | 82 +++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 0ebde88c5d..28eb9d8eec 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,7 +1,7 @@ use super::utils::graphrow::{ SYM_BRANCH_DOWN, SYM_BRANCH_UP, SYM_BRANCH_UP_RIGHT, SYM_COMMIT, SYM_COMMIT_BRANCH, SYM_COMMIT_MERGE, SYM_COMMIT_STASH, - SYM_COMMIT_UNCOMMITTED, SYM_HORIZONTAL, SYM_MERGE_BRIDGE_END, + SYM_COMMIT_UNCOMMITTED, SYM_CROSS, SYM_HORIZONTAL, SYM_MERGE_BRIDGE_END, SYM_MERGE_BRIDGE_MID, SYM_MERGE_BRIDGE_START, SYM_SPACE, SYM_VERTICAL, SYM_VERTICAL_DOTTED, }; @@ -46,6 +46,15 @@ use std::{ const ELEMENTS_PER_LINE: usize = 9; const SLICE_SIZE: usize = 1200; +const GRAPH_COLORS: &[Color] = &[ + Color::Blue, + Color::Yellow, + Color::Magenta, + Color::Cyan, + Color::Green, + Color::Red, +]; + /// pub struct CommitList { repo: RepoPathRef, @@ -487,76 +496,80 @@ impl CommitList { ) -> Vec> { let mut spans = Vec::new(); - let graph_color = - self.theme.commit_hash(false).fg.unwrap_or(Color::Reset); - for (lane_index, conn) in row.lanes.iter().enumerate() { if empty_lanes.contains(&lane_index) { continue; } - let (sym, color) = match conn { + let (sym, graph_color) = match conn { None => (SYM_SPACE, Color::Reset), - Some((ConnType::Vertical, _)) => { - (SYM_VERTICAL, graph_color) + Some((ConnType::Vertical, color_idx)) => { + (SYM_VERTICAL, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) + } + Some((ConnType::VerticalDotted, color_idx)) => { + (SYM_VERTICAL_DOTTED, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::VerticalDotted, _)) => { - (SYM_VERTICAL_DOTTED, graph_color) + Some((ConnType::Cross, color_idx)) => { + (SYM_CROSS, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::CommitNormal, _)) => { - (SYM_COMMIT, graph_color) + Some((ConnType::CommitNormal, color_idx)) => { + (SYM_COMMIT, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::CommitBranch, _)) => { - (SYM_COMMIT_BRANCH, graph_color) + Some((ConnType::CommitBranch, color_idx)) => { + (SYM_COMMIT_BRANCH, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::CommitMerge, _)) => { - (SYM_COMMIT_MERGE, graph_color) + Some((ConnType::CommitMerge, color_idx)) => { + (SYM_COMMIT_MERGE, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::CommitStash, _)) => { - (SYM_COMMIT_STASH, graph_color) + Some((ConnType::CommitStash, color_idx)) => { + (SYM_COMMIT_STASH, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::CommitUncommitted, _)) => { - (SYM_COMMIT_UNCOMMITTED, graph_color) + Some((ConnType::CommitUncommitted, color_idx)) => { + (SYM_COMMIT_UNCOMMITTED, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::MergeBridgeStart, _)) => { - (SYM_MERGE_BRIDGE_START, graph_color) + Some((ConnType::MergeBridgeStart, color_idx)) => { + (SYM_MERGE_BRIDGE_START, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::MergeBridgeMid, _)) => { - (SYM_MERGE_BRIDGE_MID, graph_color) + Some((ConnType::MergeBridgeMid, color_idx)) => { + (SYM_MERGE_BRIDGE_MID, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::MergeBridgeEnd, _)) => { - (SYM_MERGE_BRIDGE_END, graph_color) + Some((ConnType::MergeBridgeEnd, color_idx)) => { + (SYM_MERGE_BRIDGE_END, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::BranchDown, _)) => { - (SYM_BRANCH_DOWN, graph_color) + Some((ConnType::BranchDown, color_idx)) => { + (SYM_BRANCH_DOWN, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::BranchUp, _)) => { - (SYM_BRANCH_UP, graph_color) + Some((ConnType::BranchUp, color_idx)) => { + (SYM_BRANCH_UP, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } - Some((ConnType::BranchUpRight, _)) => { - (SYM_BRANCH_UP_RIGHT, graph_color) + Some((ConnType::BranchUpRight, color_idx)) => { + (SYM_BRANCH_UP_RIGHT, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) } }; - spans.push(Span::styled(sym, Style::default().fg(color))); + spans.push(Span::styled(sym, Style::default().fg(graph_color))); // Spacer let mut is_bridge_lane = false; + let mut spacer_color = graph_color; if let Some((from, to)) = row.merge_bridge { if lane_index >= from && lane_index < to { is_bridge_lane = true; + spacer_color = GRAPH_COLORS[row.commit_lane % GRAPH_COLORS.len()]; } } for &(from, to) in &row.branches { if lane_index >= from && lane_index < to { is_bridge_lane = true; + let branch_lane = if row.commit_lane == from { to } else { from }; + spacer_color = GRAPH_COLORS[branch_lane % GRAPH_COLORS.len()]; } } if is_bridge_lane { spans.push(Span::styled( SYM_HORIZONTAL, - Style::default().fg(graph_color), + Style::default().fg(spacer_color), )); continue; } @@ -1324,7 +1337,8 @@ mod tests { let empty_lanes = std::collections::HashSet::new(); let spans = cl.build_graph_spans(&row, 1, &empty_lanes); - assert_eq!(spans.len(), 1); + assert_eq!(spans.len(), 2); assert_eq!(spans[0].content, Cow::from(SYM_COMMIT)); + assert_eq!(spans[1].content, Cow::from(SYM_SPACE)); } } From b5ee5d3b6f37dda5164cf879d09b4845129c7ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 16 Apr 2026 22:02:02 -0400 Subject: [PATCH 31/84] Switched to Tigs method of symbolization --- src/components/utils/graphrow.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/utils/graphrow.rs b/src/components/utils/graphrow.rs index db44162a1b..d626b6640f 100644 --- a/src/components/utils/graphrow.rs +++ b/src/components/utils/graphrow.rs @@ -1,8 +1,9 @@ -pub const SYM_COMMIT: &str = "◆"; -pub const SYM_COMMIT_BRANCH: &str = "◈"; -pub const SYM_COMMIT_MERGE: &str = "◇"; -pub const SYM_COMMIT_STASH: &str = "⟡"; -pub const SYM_COMMIT_UNCOMMITTED: &str = "◻"; +pub const SYM_COMMIT: &str = "o"; +pub const SYM_COMMIT_BRANCH: &str = "*"; +pub const SYM_COMMIT_MERGE: &str = "M"; +pub const SYM_COMMIT_STASH: &str = "*"; +pub const SYM_COMMIT_UNCOMMITTED: &str = "+"; +pub const SYM_CROSS: &str = "╋"; pub const SYM_VERTICAL: &str = "┃"; pub const SYM_VERTICAL_DOTTED: &str = "╏"; pub const SYM_HORIZONTAL: &str = "━"; From c1e1f7b77e19c423b2d5a275674faaf74021774a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 23 May 2026 19:25:04 -0400 Subject: [PATCH 32/84] usizes all the way down --- asyncgit/src/graph/buffer.rs | 4 ++-- asyncgit/src/graph/chunk.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index 34e739397e..4f861f366c 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -18,7 +18,7 @@ pub struct Buffer { pub current: Vector>, pub deltas: Vec, pub checkpoints: BTreeMap>>, - mergers: Vec, + mergers: Vec, pending_delta: Vec, } @@ -39,7 +39,7 @@ impl Buffer { } } - pub fn merger(&mut self, alias: u32) { + pub fn merger(&mut self, alias: usize) { self.mergers.push(alias); } diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index fb0adacf13..dd3b637a70 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -6,8 +6,8 @@ pub enum Markers { #[derive(Clone, Debug)] pub struct Chunk { - pub alias: Option, - pub parent_a: Option, - pub parent_b: Option, + pub alias: Option, + pub parent_a: Option, + pub parent_b: Option, pub marker: Markers, } From ae944f28bd053d4c7a305d2289a6e7cc5e04bf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 23 May 2026 19:25:13 -0400 Subject: [PATCH 33/84] better names for important things --- asyncgit/src/graph/mod.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index 9acdfd4d4c..e1c8bee521 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -5,8 +5,12 @@ pub mod walker; pub use walker::GraphWalker; +/// The maximum number of colors to use for graph lanes +pub const MAX_LANE_COLORS: usize = 16; + +/// The type of connection between nodes in the graph #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ConnType { +pub enum ConnectionType { Vertical, VerticalDotted, Cross, @@ -21,6 +25,8 @@ pub enum ConnType { BranchDown, BranchUp, BranchUpRight, + BranchUpMergeStart, + BranchUpRightMergeEnd, } #[derive(Clone, Debug, Default)] @@ -42,8 +48,8 @@ pub struct GraphRow { /// Connections emitted per lane: /// None = empty space - /// Some((ConnType, `color_index`)) = draw this connector in this color - pub lanes: Vec>, + /// Some((ConnectionType, `color_index`)) = draw this connector in this color + pub lanes: Vec>, /// Horizontal merge bridge: if this commit merges rightward, /// (`from_lane`, `to_lane`) — the span to draw ─ ╭ ╮ across From 1566bb77cde455d53fe3593898af0b6f5e7f66f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 23 May 2026 19:25:21 -0400 Subject: [PATCH 34/84] simplifie the OIDs structure --- asyncgit/src/graph/oids.rs | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/asyncgit/src/graph/oids.rs b/asyncgit/src/graph/oids.rs index 0dbecdf441..792d99b09f 100644 --- a/asyncgit/src/graph/oids.rs +++ b/asyncgit/src/graph/oids.rs @@ -1,40 +1,34 @@ use crate::sync::CommitId; use std::collections::HashMap; -pub struct Oids { - /// alias - pub ids: Vec, +/// mapping of `CommitId` to a numeric alias +pub struct GraphOids(HashMap); - /// `CommitId` to alias - pub aliases: HashMap, -} - -impl Default for Oids { +impl Default for GraphOids { fn default() -> Self { Self::new() } } -impl Oids { +impl GraphOids { + /// pub fn new() -> Self { - Self { - ids: Vec::new(), - aliases: HashMap::new(), - } + Self(HashMap::new()) } - pub fn get_or_insert(&mut self, id: &CommitId) -> u32 { - if let Some(&alias) = self.aliases.get(id) { + /// + pub fn get_or_insert(&mut self, id: &CommitId) -> usize { + if let Some(&alias) = self.0.get(id) { return alias; } - let alias = - u32::try_from(self.ids.len()).expect("too many oids"); - self.ids.push(*id); - self.aliases.insert(*id, alias); + + let alias = self.0.len(); + self.0.insert(*id, alias); alias } - pub fn get(&self, id: &CommitId) -> Option { - self.aliases.get(id).copied() + /// + pub fn get(&self, id: &CommitId) -> Option { + self.0.get(id).copied() } } From 798f8614ae1f5e37159a0411f2a8680792d7b21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 23 May 2026 19:25:36 -0400 Subject: [PATCH 35/84] renamings and more precise structure --- asyncgit/src/graph/walker.rs | 70 +++++++++-------- src/components/commitlist.rs | 144 ++++++++++++++++++++++------------- 2 files changed, 128 insertions(+), 86 deletions(-) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index b04645a9ab..0c0131f66b 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -1,16 +1,16 @@ use super::buffer::Buffer; use super::chunk::{Chunk, Markers}; -use super::oids::Oids; -use super::{ConnType, GraphRow}; +use super::oids::GraphOids; +use super::{ConnectionType, GraphRow, MAX_LANE_COLORS}; use crate::sync::{CommitId, CommitInfo}; use im::Vector; use std::collections::{HashMap, HashSet}; pub struct GraphWalker { pub buffer: Buffer, - pub oids: Oids, + pub oids: GraphOids, pub branch_lane_map: HashMap, - pub mergers_map: HashMap, + pub mergers_map: HashMap, } impl Default for GraphWalker { @@ -23,7 +23,7 @@ impl GraphWalker { pub fn new() -> Self { Self { buffer: Buffer::new(), - oids: Oids::new(), + oids: GraphOids::new(), branch_lane_map: HashMap::new(), mergers_map: HashMap::new(), } @@ -189,9 +189,9 @@ impl GraphWalker { to, commit_lane, target_lane, - ConnType::MergeBridgeMid, - ConnType::MergeBridgeStart, - ConnType::MergeBridgeEnd, + ConnectionType::MergeBridgeMid, + ConnectionType::MergeBridgeStart, + ConnectionType::MergeBridgeEnd, ); } @@ -211,9 +211,9 @@ impl GraphWalker { to, branch_lane, branch_lane, - ConnType::MergeBridgeMid, - ConnType::BranchUp, - ConnType::BranchUpRight, + ConnectionType::MergeBridgeMid, + ConnectionType::BranchUp, + ConnectionType::BranchUpRight, ); } @@ -232,9 +232,9 @@ impl GraphWalker { #[allow(clippy::too_many_arguments)] fn fill_lanes( &self, - lanes: &mut [Option<(ConnType, usize)>], + lanes: &mut [Option<(ConnectionType, usize)>], curr: &Vector>, - alias: Option, + alias: Option, head_id: Option<&CommitId>, is_stash: bool, is_merge: bool, @@ -245,7 +245,7 @@ impl GraphWalker { let Some(chunk) = chunk_item.as_ref() else { if branching_lanes.contains(&lane_idx) { lanes[lane_idx] = - Some((ConnType::BranchUp, lane_idx % 16)); + Some((ConnectionType::BranchUp, lane_idx % MAX_LANE_COLORS)); } continue; }; @@ -253,13 +253,13 @@ impl GraphWalker { if alias.is_some() && chunk.alias == alias { let conn_type = match (is_stash, is_merge, is_branch_tip) { - (true, _, _) => ConnType::CommitStash, - (_, true, _) => ConnType::CommitMerge, - (_, _, true) => ConnType::CommitBranch, - _ => ConnType::CommitNormal, + (true, _, _) => ConnectionType::CommitStash, + (_, true, _) => ConnectionType::CommitMerge, + (_, _, true) => ConnectionType::CommitBranch, + _ => ConnectionType::CommitNormal, }; - lanes[lane_idx] = Some((conn_type, lane_idx % 16)); + lanes[lane_idx] = Some((conn_type, lane_idx % MAX_LANE_COLORS)); } else { let is_dotted = head_id .and_then(|h| self.oids.get(h)) @@ -276,44 +276,52 @@ impl GraphWalker { } let conn = if is_dotted { - ConnType::VerticalDotted + ConnectionType::VerticalDotted } else { - ConnType::Vertical + ConnectionType::Vertical }; - lanes[lane_idx] = Some((conn, lane_idx % 16)); + lanes[lane_idx] = Some((conn, lane_idx % MAX_LANE_COLORS)); } } } fn draw_bridge( - lanes: &mut [Option<(ConnType, usize)>], + lanes: &mut [Option<(ConnectionType, usize)>], from: usize, to: usize, color_lane: usize, corner_lane: usize, - mid: ConnType, - corner_right: ConnType, - corner_left: ConnType, + mid: ConnectionType, + corner_right: ConnectionType, + corner_left: ConnectionType, ) { for lane in lanes.iter_mut().take(to).skip(from + 1) { match lane { Some(( - ConnType::Vertical | ConnType::VerticalDotted, + ConnectionType::Vertical | ConnectionType::VerticalDotted, _, )) => { - *lane = Some((ConnType::Cross, color_lane % 16)); + *lane = Some((ConnectionType::Cross, color_lane % MAX_LANE_COLORS)); } _ => { - *lane = Some((mid, color_lane % 16)); + *lane = Some((mid, color_lane % MAX_LANE_COLORS)); } } } if corner_lane == to { - lanes[to] = Some((corner_right, color_lane % 16)); + let new_corner = match (lanes[to], corner_right) { + (Some((ConnectionType::MergeBridgeStart, _)), ConnectionType::BranchUp) => ConnectionType::BranchUpMergeStart, + _ => corner_right, + }; + lanes[to] = Some((new_corner, color_lane % MAX_LANE_COLORS)); } else if corner_lane == from { - lanes[from] = Some((corner_left, color_lane % 16)); + let new_corner = match (lanes[from], corner_left) { + (Some((ConnectionType::MergeBridgeEnd, _)), ConnectionType::BranchUpRight) => ConnectionType::BranchUpRightMergeEnd, + _ => corner_left, + }; + lanes[from] = Some((new_corner, color_lane % MAX_LANE_COLORS)); } } } diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 28eb9d8eec..47c32675b1 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,9 +1,10 @@ use super::utils::graphrow::{ SYM_BRANCH_DOWN, SYM_BRANCH_UP, SYM_BRANCH_UP_RIGHT, SYM_COMMIT, SYM_COMMIT_BRANCH, SYM_COMMIT_MERGE, SYM_COMMIT_STASH, - SYM_COMMIT_UNCOMMITTED, SYM_CROSS, SYM_HORIZONTAL, SYM_MERGE_BRIDGE_END, - SYM_MERGE_BRIDGE_MID, SYM_MERGE_BRIDGE_START, SYM_SPACE, - SYM_VERTICAL, SYM_VERTICAL_DOTTED, + SYM_COMMIT_UNCOMMITTED, SYM_CROSS, SYM_HORIZONTAL, + SYM_MERGE_BRIDGE_END, SYM_MERGE_BRIDGE_MID, + SYM_MERGE_BRIDGE_START, SYM_SPACE, SYM_VERTICAL, + SYM_VERTICAL_DOTTED, }; use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ @@ -21,7 +22,7 @@ use crate::{ }; use anyhow::Result; use asyncgit::{ - graph::{ConnType, GraphRow}, + graph::{ConnectionType, GraphRow}, sync::{ self, checkout_commit, BranchDetails, BranchInfo, CommitId, RepoPathRef, Tags, @@ -502,50 +503,78 @@ impl CommitList { } let (sym, graph_color) = match conn { None => (SYM_SPACE, Color::Reset), - Some((ConnType::Vertical, color_idx)) => { - (SYM_VERTICAL, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::VerticalDotted, color_idx)) => { - (SYM_VERTICAL_DOTTED, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::Cross, color_idx)) => { - (SYM_CROSS, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::CommitNormal, color_idx)) => { - (SYM_COMMIT, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::CommitBranch, color_idx)) => { - (SYM_COMMIT_BRANCH, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::CommitMerge, color_idx)) => { - (SYM_COMMIT_MERGE, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::CommitStash, color_idx)) => { - (SYM_COMMIT_STASH, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::CommitUncommitted, color_idx)) => { - (SYM_COMMIT_UNCOMMITTED, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::MergeBridgeStart, color_idx)) => { - (SYM_MERGE_BRIDGE_START, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::MergeBridgeMid, color_idx)) => { - (SYM_MERGE_BRIDGE_MID, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::MergeBridgeEnd, color_idx)) => { - (SYM_MERGE_BRIDGE_END, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::BranchDown, color_idx)) => { - (SYM_BRANCH_DOWN, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::BranchUp, color_idx)) => { - (SYM_BRANCH_UP, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } - Some((ConnType::BranchUpRight, color_idx)) => { - (SYM_BRANCH_UP_RIGHT, GRAPH_COLORS[color_idx % GRAPH_COLORS.len()]) - } + Some((ConnectionType::Vertical, color_idx)) => ( + SYM_VERTICAL, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::VerticalDotted, color_idx)) => ( + SYM_VERTICAL_DOTTED, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::Cross, color_idx)) => ( + SYM_CROSS, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::BranchUpMergeStart, color_idx)) => ( + SYM_BRANCH_UP, // Reusing '┛' + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some(( + ConnectionType::BranchUpRightMergeEnd, + color_idx, + )) => ( + SYM_BRANCH_UP_RIGHT, // Reusing '┗' + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::CommitNormal, color_idx)) => ( + SYM_COMMIT, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::CommitBranch, color_idx)) => ( + SYM_COMMIT_BRANCH, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::CommitMerge, color_idx)) => ( + SYM_COMMIT_MERGE, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::CommitStash, color_idx)) => ( + SYM_COMMIT_STASH, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::CommitUncommitted, color_idx)) => ( + SYM_COMMIT_UNCOMMITTED, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::MergeBridgeStart, color_idx)) => ( + SYM_MERGE_BRIDGE_START, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::MergeBridgeMid, color_idx)) => ( + SYM_MERGE_BRIDGE_MID, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::MergeBridgeEnd, color_idx)) => ( + SYM_MERGE_BRIDGE_END, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::BranchDown, color_idx)) => ( + SYM_BRANCH_DOWN, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::BranchUp, color_idx)) => ( + SYM_BRANCH_UP, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), + Some((ConnectionType::BranchUpRight, color_idx)) => ( + SYM_BRANCH_UP_RIGHT, + GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], + ), }; - spans.push(Span::styled(sym, Style::default().fg(graph_color))); + spans.push(Span::styled( + sym, + Style::default().fg(graph_color), + )); // Spacer let mut is_bridge_lane = false; @@ -554,15 +583,21 @@ impl CommitList { if let Some((from, to)) = row.merge_bridge { if lane_index >= from && lane_index < to { is_bridge_lane = true; - spacer_color = GRAPH_COLORS[row.commit_lane % GRAPH_COLORS.len()]; + spacer_color = GRAPH_COLORS + [row.commit_lane % GRAPH_COLORS.len()]; } } for &(from, to) in &row.branches { if lane_index >= from && lane_index < to { is_bridge_lane = true; - let branch_lane = if row.commit_lane == from { to } else { from }; - spacer_color = GRAPH_COLORS[branch_lane % GRAPH_COLORS.len()]; + let branch_lane = if row.commit_lane == from { + to + } else { + from + }; + spacer_color = GRAPH_COLORS + [branch_lane % GRAPH_COLORS.len()]; } } @@ -788,7 +823,7 @@ impl CommitList { if !matches!( conn, None | Some(( - ConnType::MergeBridgeMid, + ConnectionType::MergeBridgeMid, _ )) ) { @@ -849,7 +884,7 @@ impl CommitList { local_branches, self.remote_branches_string(e), &self.theme, - width, + width.into(), now, marked, &empty_lanes, @@ -1330,12 +1365,11 @@ mod tests { is_merge: false, is_branch_tip: false, is_stash: false, - lanes: vec![Some((ConnType::CommitNormal, 0))], + lanes: vec![Some((ConnectionType::CommitNormal, 0))], merge_bridge: None, branches: vec![], }; - let empty_lanes = std::collections::HashSet::new(); - let spans = cl.build_graph_spans(&row, 1, &empty_lanes); + let spans = cl.build_graph_spans(&row, 1); assert_eq!(spans.len(), 2); assert_eq!(spans[0].content, Cow::from(SYM_COMMIT)); From b82bfa4f9c23992151b1ac79e379f71d08f632c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Mon, 8 Jun 2026 20:01:45 +0200 Subject: [PATCH 36/84] Impl From> for CommitId --- asyncgit/src/sync/commits_info.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 8b6a7a6fad..921d98c730 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -88,6 +88,15 @@ impl From for CommitId { } } +impl From> for CommitId { + fn from(object_id: gix::Id<'_>) -> Self { + #[allow(clippy::expect_used)] + let oid = Oid::from_bytes(object_id.as_bytes()).expect("`Oid::from_bytes(object_id.as_bytes())` is expected to never fail"); + + Self::new(oid) + } +} + impl From for CommitId { fn from(object_id: gix::ObjectId) -> Self { #[allow(clippy::expect_used)] @@ -162,7 +171,7 @@ pub fn get_commits_info( parents: c .parents() .take(2) - .map(|p| CommitId::new(p.id())) + .map(|p| CommitId(p.id())) .collect(), } }) @@ -198,13 +207,11 @@ pub fn get_commit_info( author: author.to_string(), time: commit_ref.time()?.seconds, id: commit.id().detach().into(), - parents: commit_ref - .parents - .iter() + parents: commit + .parent_ids() .take(2) - .map(|p| CommitId::from_str_unchecked(&p.to_string())) - .collect::, _>>()? - .into(), + .map(Into::into) + .collect(), }) } From fe3c1c5d74a9fd4280ccfa6749a5dc6c75f334ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:14:13 -0400 Subject: [PATCH 37/84] chunk derive and oids docs --- asyncgit/src/graph/chunk.rs | 2 +- asyncgit/src/graph/oids.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index dd3b637a70..8c70795c21 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -4,7 +4,7 @@ pub enum Markers { Commit, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Chunk { pub alias: Option, pub parent_a: Option, diff --git a/asyncgit/src/graph/oids.rs b/asyncgit/src/graph/oids.rs index 792d99b09f..dca161de88 100644 --- a/asyncgit/src/graph/oids.rs +++ b/asyncgit/src/graph/oids.rs @@ -11,12 +11,12 @@ impl Default for GraphOids { } impl GraphOids { - /// + /// Create an empty alias map. pub fn new() -> Self { Self(HashMap::new()) } - /// + /// Get the alias for `id`, assigning a new one if it doesn't exist yet. pub fn get_or_insert(&mut self, id: &CommitId) -> usize { if let Some(&alias) = self.0.get(id) { return alias; @@ -27,7 +27,7 @@ impl GraphOids { alias } - /// + /// Look up the alias for `id`, returning `None` if not found. pub fn get(&self, id: &CommitId) -> Option { self.0.get(id).copied() } From eae0bd7e2db3fbf1151ce301a1a7e3900f8cb201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:14:20 -0400 Subject: [PATCH 38/84] buffer refactor from im vector Swap im::Vector for plain Vec with checkpoint deltas. --- asyncgit/src/graph/buffer.rs | 263 +++++++++++++++++++++-------------- 1 file changed, 159 insertions(+), 104 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index 4f861f366c..3f2e7a47c7 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -1,7 +1,8 @@ use super::chunk::{Chunk, Markers}; -use im::Vector; use std::collections::BTreeMap; +/// A single mutation of the lane state, recorded while processing one +/// commit. #[derive(Clone, Debug)] pub enum DeltaOp { Insert { index: usize, item: Option }, @@ -9,16 +10,38 @@ pub enum DeltaOp { Replace { index: usize, new: Option }, } +/// All lane-state mutations caused by processing a single commit. +/// Applying a `Delta` to the lane state of row `n` yields the lane +/// state of row `n + 1`. #[derive(Clone, Debug)] pub struct Delta(pub Vec); const CHECKPOINT_INTERVAL: usize = 100; +/// Delta-compressed history of the graph's lane state. +/// +/// While walking the log top-down, every commit mutates the set of +/// active lanes (a `Vec>`, one slot per lane). So, storign a +/// full copy of that state for each commit is a waste. This +/// buffer preserves ONLY the latest state PLUS the list of [`Delta`]s that +/// produced it. +/// Use [`Buffer::decompress`] to get the complete version. pub struct Buffer { - pub current: Vector>, + /// Lane state after the most recently processed commit. + pub current: Vec>, + + /// One [`Delta`] per processed commit, in the order of the walk. pub deltas: Vec, - pub checkpoints: BTreeMap>>, - mergers: Vec, + + /// Full lane-state snapshots taken every `CHECKPOINT_INTERVAL` + /// commits, keyed by delta index, for reducing decompression cost. + pub checkpoints: BTreeMap>>, + + /// Aliases of merge commits whose second parent still needs a new + /// lane. + merge_commits: Vec, + + /// Scratch list of the [`DeltaOp`]s recorded for processing commit pending_delta: Vec, } @@ -29,130 +52,162 @@ impl Default for Buffer { } impl Buffer { - pub fn new() -> Self { + pub const fn new() -> Self { Self { - current: Vector::new(), + current: Vec::new(), deltas: Vec::new(), checkpoints: BTreeMap::new(), - mergers: Vec::new(), + merge_commits: Vec::new(), pending_delta: Vec::new(), } } - pub fn merger(&mut self, alias: usize) { - self.mergers.push(alias); + /// Remember `alias` as a merge commit whose second parent must be + /// given its own lane. + pub fn track_merge_commit(&mut self, alias: usize) { + self.merge_commits.push(alias); } pub fn update(&mut self, new_chunk: &Chunk) { - self.pending_delta.clear(); + // Phase 1: place the new chunk into the lane array. + let placement_index = self.place_chunk(new_chunk); + + // Phase 2: consume the alias in all other live chunks. + if let Some(alias) = new_chunk.alias { + self.consume_alias_in_other_chunks( + alias, + placement_index, + ); + } - let mut empty_lanes: Vec = self - .current - .iter() - .enumerate() - .filter_map(|(i, c)| c.is_none().then_some(i)) - .collect(); + // Phase 3: flush any pending merge commits into new lanes. + self.flush_merge_commits(); - // sort descending so we can pop the lowest index first - empty_lanes.sort_unstable_by(|a, b| b.cmp(a)); + // Phase 4: commit the delta and maybe checkpoint. + self.commit_delta(); + } - let found_idx = if new_chunk.alias.is_some() { - self.current.iter().enumerate().find_map(|(i, c)| { - c.as_ref().and_then(|c| { - (c.parent_a == new_chunk.alias).then_some(i) - }) - }) - } else { - None - }; + fn place_chunk(&mut self, new_chunk: &Chunk) -> usize { + // Prefer a lane whose current occupant is waiting for this chunk as parent_a. + let target = self + .find_lane_awaiting_parent(new_chunk.alias) + .or_else(|| self.first_empty_lane()) + .unwrap_or(self.current.len()); - if let Some(idx) = found_idx { - self.record_replace(idx, Some(new_chunk.clone())); - } else if let Some(empty_idx) = empty_lanes.pop() { - self.record_replace(empty_idx, Some(new_chunk.clone())); + if target < self.current.len() { + self.record_replace(target, Some(new_chunk.clone())); } else { - self.record_insert( - self.current.len(), - Some(new_chunk.clone()), - ); + self.record_insert(target, Some(new_chunk.clone())); } + target + } - let current_length = self.current.len(); - for index in 0..current_length { - if Some(index) == found_idx { - continue; - } - if found_idx.is_none() && index == current_length - 1 { - continue; - } + fn find_lane_awaiting_parent( + &self, + alias: Option, + ) -> Option { + let alias = alias?; + self.current.iter().position(|slot| { + slot.as_ref() + .is_some_and(|chunk| chunk.parent_a == Some(alias)) + }) + } + + fn first_empty_lane(&self) -> Option { + self.current.iter().position(Option::is_none) + } - if let Some(mut c) = self.current[index].clone() { - let changed = new_chunk.alias.is_some_and(|alias| { - let a = c.parent_a == Some(alias); - let b = c.parent_b == Some(alias); - if a { - c.parent_a = None; - } - if b { - c.parent_b = None; - } - a || b - }); - - if changed { - if c.parent_a.is_none() && c.parent_b.is_none() { - self.record_replace(index, None); - } else { - self.record_replace(index, Some(c)); - } + fn consume_alias_in_other_chunks( + &mut self, + alias: usize, + skip_index: usize, + ) { + for index in 0..self.current.len() { + let mut chunk = match self.current[index].clone() { + Some(chunk) if index != skip_index => chunk, + _ => continue, + }; + + let changed_a = chunk.parent_a == Some(alias); + let changed_b = chunk.parent_b == Some(alias); + + if changed_a || changed_b { + if changed_a { + chunk.parent_a = None; + } + if changed_b { + chunk.parent_b = None; } + + self.record_replace( + index, + chunk.parent_a.is_some().then_some(chunk), + ); } } + } - while let Some(alias) = self.mergers.pop() { - if let Some(index) = self.current.iter().position(|c| { - c.as_ref() - .is_some_and(|chunk| chunk.alias == Some(alias)) - }) { - if let Some(mut c) = self.current[index].clone() { - let parent_b = c.parent_b; - c.parent_b = None; - self.record_replace(index, Some(c)); - - let new_lane = Chunk { - alias: None, - parent_a: parent_b, - parent_b: None, - marker: Markers::Commit, - }; - - if let Some(empty_idx) = empty_lanes.pop() { - self.record_replace( - empty_idx, - Some(new_lane), - ); - } else { - self.record_insert( - self.current.len(), - Some(new_lane), - ); - } + fn flush_merge_commits(&mut self) { + // Collect available empty lanes once, before we start maybe filling them. + let mut empty_lanes: Vec = self + .current + .iter() + .enumerate() + .filter_map(|(index, slot)| { + slot.is_none().then_some(index) + }) + .collect(); + + while let Some(alias) = self.merge_commits.pop() { + // Search for an occupied slot that matches the target alias. + // If found, extract its index and a mutable clone of the chunk. + let Some((index, mut chunk)) = + self.current.iter().enumerate().find_map( + |(index, slot)| { + let chunk = slot.as_ref()?; + (chunk.alias == Some(alias)) + .then(|| (index, chunk.clone())) + }, + ) + else { + continue; + }; + + let detached_parent = chunk.parent_b.take(); + self.record_replace(index, Some(chunk)); + + if let Some(parent) = detached_parent { + let new_lane = Chunk { + alias: None, + parent_a: Some(parent), + parent_b: None, + marker: Markers::Commit, + }; + + if let Some(empty_index) = empty_lanes.pop() { + self.record_replace(empty_index, Some(new_lane)); + } else { + self.record_insert( + self.current.len(), + Some(new_lane), + ); } } } + } - while self.current.last().is_some_and(Option::is_none) { - self.record_remove(self.current.len() - 1); + fn commit_delta(&mut self) { + while matches!(self.current.last(), Some(None)) { + let last = self.current.len() - 1; + self.record_remove(last); } - let delta = Delta(self.pending_delta.clone()); - self.deltas.push(delta); + self.deltas + .push(Delta(std::mem::take(&mut self.pending_delta))); - let current_step = self.deltas.len(); - if current_step > 0 && current_step % CHECKPOINT_INTERVAL == 0 - { - self.checkpoints - .insert(current_step - 1, self.current.clone()); + let step = self.deltas.len(); + if step % CHECKPOINT_INTERVAL == 0 { + self.checkpoints.insert(step - 1, self.current.clone()); } } @@ -161,7 +216,7 @@ impl Buffer { index, new: new.clone(), }); - self.current.set(index, new); + self.current[index] = new; } fn record_insert(&mut self, index: usize, item: Option) { @@ -181,10 +236,10 @@ impl Buffer { &self, start: usize, end: usize, - ) -> Vec>> { + ) -> Vec>> { let (current_index, mut state) = self.checkpoints.range(..=start).next_back().map_or_else( - || (None, Vector::new()), + || (None, Vec::new()), |(&i, s)| (Some(i), s.clone()), ); @@ -215,7 +270,7 @@ impl Buffer { } fn apply_delta_to_state( - state: &mut Vector>, + state: &mut Vec>, delta: &Delta, ) { for op in &delta.0 { @@ -227,7 +282,7 @@ impl Buffer { state.remove(*index); } DeltaOp::Replace { index, new } => { - state.set(*index, new.clone()); + state[*index].clone_from(new); } } } From e95d049881ca5db0e266e65edd9f547b20d0d82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:14:26 -0400 Subject: [PATCH 39/84] walk entry, loop, and overlay graph rendering Very clever use of WalkEntry to avoid a second commit-info pass. overlay_cell will no merge glyphs via bitflags to preserve verticals across bridges. --- asyncgit/src/graph/walker.rs | 679 +++++++++++++++++++++++++-------- asyncgit/src/revlog.rs | 170 +++++---- asyncgit/src/sync/logwalker.rs | 56 ++- asyncgit/src/sync/mod.rs | 8 +- 4 files changed, 661 insertions(+), 252 deletions(-) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 0c0131f66b..d5f7681aad 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -1,16 +1,141 @@ use super::buffer::Buffer; use super::chunk::{Chunk, Markers}; use super::oids::GraphOids; -use super::{ConnectionType, GraphRow, MAX_LANE_COLORS}; -use crate::sync::{CommitId, CommitInfo}; -use im::Vector; +use super::{ + to_lane_idx, ConnectionType, GraphRow, LaneIdx, MAX_LANE_COLORS, +}; +use crate::sync::CommitId; use std::collections::{HashMap, HashSet}; +/// Get the lanes color index, which cycles through the ste palette. +fn lane_color(lane: usize) -> LaneIdx { + to_lane_idx(lane % MAX_LANE_COLORS) +} + +use bitflags::bitflags; + +bitflags! { + /// The neighboring cells a lane joins to. Overlapping lines + /// are merged through this representation rather than + /// a naive overwrite for readability. + #[derive(Clone, Copy, Default)] + struct Dirs: u8 { + const UP = 0b0001; + const DOWN = 0b0010; + const LEFT = 0b0100; + const RIGHT = 0b1000; + } +} + +impl Dirs { + #[allow(clippy::missing_const_for_fn)] + fn merge(self, other: Self) -> Self { + Self::from_bits_retain(self.bits() | other.bits()) + } + + #[allow(clippy::missing_const_for_fn)] + fn vertical(self) -> bool { + self.intersects(Self::UP | Self::DOWN) + } +} + +/// The sub network of an existing connection glyph +/// `None` represents commit markers, which are never drawn over. +fn conn_dirs(conn: ConnectionType) -> Option { + Some(match conn { + ConnectionType::Vertical | ConnectionType::VerticalDotted => { + Dirs::UP | Dirs::DOWN + } + ConnectionType::MergeBridgeMid => Dirs::LEFT | Dirs::RIGHT, + ConnectionType::MergeBridgeStart => Dirs::DOWN | Dirs::LEFT, + ConnectionType::MergeBridgeEnd => Dirs::DOWN | Dirs::RIGHT, + ConnectionType::BranchUp => Dirs::UP | Dirs::LEFT, + ConnectionType::BranchUpRight => Dirs::UP | Dirs::RIGHT, + ConnectionType::TeeLeft => Dirs::UP | Dirs::DOWN | Dirs::LEFT, + ConnectionType::TeeRight => { + Dirs::UP | Dirs::DOWN | Dirs::RIGHT + } + ConnectionType::TeeUp => Dirs::UP | Dirs::LEFT | Dirs::RIGHT, + ConnectionType::TeeDown => { + Dirs::DOWN | Dirs::LEFT | Dirs::RIGHT + } + ConnectionType::CommitNormal + | ConnectionType::CommitBranch + | ConnectionType::CommitMerge + | ConnectionType::CommitStash + | ConnectionType::CommitUncommitted => return None, + }) +} + +/// Determine the glyph for a cell's connectivity. +/// Vertical lines take precedence in crossed cells. +/// Yet the horizontal bridge continues in +/// the spacer columns either side, so we retain wholeness. +const fn dirs_conn(dirs: Dirs, dotted: bool) -> ConnectionType { + let up = dirs.contains(Dirs::UP); + let down = dirs.contains(Dirs::DOWN); + let left = dirs.contains(Dirs::LEFT); + let right = dirs.contains(Dirs::RIGHT); + match (up, down, left, right) { + (true, true, true, false) => ConnectionType::TeeLeft, + (true, true, false, true) => ConnectionType::TeeRight, + (true, false, true, true) => ConnectionType::TeeUp, + (false, true, true, true) => ConnectionType::TeeDown, + (true, false, true, false) => ConnectionType::BranchUp, + (true, false, false, true) => ConnectionType::BranchUpRight, + (false, true, true, false) => { + ConnectionType::MergeBridgeStart + } + (false, true, false, true) => ConnectionType::MergeBridgeEnd, + (false, false, _, _) => ConnectionType::MergeBridgeMid, + (true, true, _, _) + | (true | false, false | true, false, false) => { + if dotted { + ConnectionType::VerticalDotted + } else { + ConnectionType::Vertical + } + } + } +} + +/// Draw `add` into a cell, merging with whatever is already there. +/// The line with a vertical component is the chosen way with color +/// ensuring lanes stay visually continuous +fn overlay_cell( + cell: &mut Option<(ConnectionType, LaneIdx)>, + add: Dirs, + color: LaneIdx, +) { + if let Some((conn, existing_color)) = cell { + if let Some(existing) = conn_dirs(*conn) { + let is_dotted = + matches!(conn, ConnectionType::VerticalDotted); + + let resolved_color = + if existing.vertical() || !add.vertical() { + *existing_color + } else { + color + }; + + *cell = Some(( + dirs_conn(existing.merge(add), is_dotted), + resolved_color, + )); + } + } else { + *cell = Some((dirs_conn(add, false), color)); + } +} + pub struct GraphWalker { pub buffer: Buffer, pub oids: GraphOids, pub branch_lane_map: HashMap, - pub mergers_map: HashMap, + + /// Maps a merge commit's alias to the alias of its second parent. + pub merge_parents: HashMap, } impl Default for GraphWalker { @@ -25,54 +150,45 @@ impl GraphWalker { buffer: Buffer::new(), oids: GraphOids::new(), branch_lane_map: HashMap::new(), - mergers_map: HashMap::new(), + merge_parents: HashMap::new(), } } - pub fn process(&mut self, commit: &CommitInfo) { - let alias = self.oids.get_or_insert(&commit.id); - let parent_a = commit - .parents - .first() - .map(|p| self.oids.get_or_insert(p)); - let parent_b = - commit.parents.get(1).map(|p| self.oids.get_or_insert(p)); + pub fn process(&mut self, id: CommitId, parents: &[CommitId]) { + let alias = self.oids.get_or_insert(&id); + + let mut mapped_parents = + parents.iter().map(|p| self.oids.get_or_insert(p)); + + // We make the executive and saddening decision to not support octo/mega merges + // TUIs are simply a backwards medium for representing this complexity + let first_parent = mapped_parents.next(); + let second_parent = mapped_parents.next(); let chunk = Chunk { alias: Some(alias), - parent_a, - parent_b, + parent_a: first_parent, + parent_b: second_parent, marker: Markers::Commit, }; - if let Some(b) = parent_b { - self.mergers_map.insert(alias, b); - } + second_parent.map(|b| self.merge_parents.insert(alias, b)); - if parent_a.is_some() && parent_b.is_some() { - let already_tracked = - self.buffer.current.iter().any(|c| { - c.as_ref().is_some_and(|c| { - c.parent_a == parent_b && c.parent_b.is_none() - }) - }); - if !already_tracked { - self.buffer.merger(alias); - } + if first_parent.is_some() + && second_parent.is_some() + && !self.buffer.current.iter().flatten().any(|commit| { + commit.parent_a == second_parent + && commit.parent_b.is_none() + }) { + self.buffer.track_merge_commit(alias); } self.buffer.update(&chunk); } - pub fn snapshot_at( - &self, - global_idx: usize, - ) -> Vector> { - self.buffer - .decompress(global_idx, global_idx) - .into_iter() - .next() - .unwrap_or_default() + /// Number of commits already folded into the graph buffer. + pub fn processed_commits(&self) -> usize { + self.buffer.deltas.len() } pub fn compute_rows( @@ -83,27 +199,34 @@ impl GraphWalker { stashes: &HashSet, head_id: Option<&CommitId>, ) -> Vec { - let end = global_start + commit_range.len().saturating_sub(1); - let snapshots = self.buffer.decompress(global_start, end); + if commit_range.is_empty() { + return Vec::new(); + } + + // decompress one row before the range (when there is one) so + // every row's predecessor state comes from the same replay + let snap_start = global_start.saturating_sub(1); + let end = global_start + commit_range.len() - 1; + let snapshots = self.buffer.decompress(snap_start, end); + let offset = global_start - snap_start; commit_range .iter() .enumerate() .map(|(index, commit_id)| { - let curr = - snapshots.get(index).cloned().unwrap_or_default(); - let prev = if index > 0 { - snapshots.get(index - 1).cloned() - } else if global_start > 0 { - Some(self.snapshot_at(global_start - 1)) - } else { - None - }; + let place = index + offset; + + let current: &[Option] = + snapshots.get(place).map_or(&[], Vec::as_slice); + let previous = place + .checked_sub(1) + .and_then(|i| snapshots.get(i)) + .map(Vec::as_slice); self.render_row( commit_id, - &curr, - prev.as_ref(), + current, + previous, branch_tips, stashes, head_id, @@ -112,17 +235,108 @@ impl GraphWalker { .collect() } + fn draw_merge_bridge( + lanes: &mut [Option<(ConnectionType, LaneIdx)>], + merge_bridge: Option<(usize, usize)>, + commit_lane: usize, + current: &[Option], + previous: Option<&[Option]>, + ) { + let Some((from, to)) = merge_bridge.filter(|(f, t)| f != t) + else { + return; + }; + let target_lane = if from == commit_lane { to } else { from }; + + // only draw the corner continuing upward when the target + // lane already existed on the previous row; a brand-new + // lane starts at this corner + let continues_up = current[target_lane].is_some() + && previous.is_some_and(|prev| { + prev.get(target_lane) == Some(¤t[target_lane]) + }); + + // replace the plain vertical fill_lanes drew for the + // target lane with the precise corner/junction + lanes[target_lane] = None; + let target_dirs = { + let mut d = Dirs::DOWN; + if continues_up { + d |= Dirs::UP; + } + if target_lane > commit_lane { + d |= Dirs::LEFT; + } + if target_lane < commit_lane { + d |= Dirs::RIGHT; + } + d + }; + overlay_cell( + &mut lanes[target_lane], + target_dirs, + lane_color(target_lane), + ); + + Self::draw_bridge_span( + lanes, + from, + to, + lane_color(target_lane), + ); + } + + fn draw_branching_lanes( + lanes: &mut Vec>, + branching_lanes: &[usize], + commit_lane: usize, + ) -> Vec<(LaneIdx, LaneIdx)> { + let mut branches = Vec::new(); + for &branch_lane in branching_lanes { + let from = std::cmp::min(branch_lane, commit_lane); + let to = std::cmp::max(branch_lane, commit_lane); + branches.push((to_lane_idx(from), to_lane_idx(to))); + + if lanes.len() <= to { + lanes.resize(to + 1, None); + } + + Self::draw_bridge_span( + lanes, + from, + to, + lane_color(branch_lane), + ); + let branch_dirs = { + let mut d = Dirs::UP; + if branch_lane == to { + d |= Dirs::LEFT; + } + if branch_lane == from { + d |= Dirs::RIGHT; + } + d + }; + overlay_cell( + &mut lanes[branch_lane], + branch_dirs, + lane_color(branch_lane), + ); + } + branches + } + fn render_row( &self, commit_id: &CommitId, - curr: &Vector>, - prev: Option<&Vector>>, + current: &[Option], + previous: Option<&[Option]>, branch_tips: &HashSet, stashes: &HashSet, head_id: Option<&CommitId>, ) -> GraphRow { let alias = self.oids.get(commit_id); - let commit_lane = curr + let commit_lane = current .iter() .position(|c| { c.as_ref().is_some_and(|chunk| { @@ -132,99 +346,71 @@ impl GraphWalker { .unwrap_or(0); let parent_b_alias = - alias.and_then(|a| self.mergers_map.get(&a).copied()); + alias.and_then(|a| self.merge_parents.get(&a).copied()); let is_merge = parent_b_alias.is_some(); let is_branch_tip = branch_tips.contains(commit_id); let is_stash = stashes.contains(commit_id); - let branching_lanes: Vec = prev + let branching_lanes: Vec = previous .into_iter() .flatten() // Unwrapping the optional, returning empty vec when None .enumerate() .filter(|(i, pc)| { pc.is_some() - && curr.get(*i).is_none_or(Option::is_none) + && current.get(*i).is_none_or(Option::is_none) }) .map(|(i, _)| i) .collect(); - let mut lanes = vec![None; curr.len()]; + let mut lanes = vec![None; current.len()]; - let merge_bridge = is_merge - .then(|| { - let target_lane = curr.iter().position(|c| { + let merge_bridge = if is_merge && parent_b_alias.is_some() { + current + .iter() + .position(|c| { c.as_ref().is_some_and(|chunk| { - parent_b_alias.is_some() - && chunk.parent_a == parent_b_alias + chunk.parent_a == parent_b_alias }) - }); - target_lane.map(|t| { - if t > commit_lane { - (commit_lane, t) - } else { - (t, commit_lane) - } }) - }) - .flatten(); + .map(|t| (commit_lane.min(t), commit_lane.max(t))) + } else { + None + }; self.fill_lanes( &mut lanes, - curr, + current, alias, head_id, is_stash, is_merge, is_branch_tip, - &branching_lanes, ); - if let Some((from, to)) = merge_bridge { - let target_lane = - if from == commit_lane { to } else { from }; - Self::draw_bridge( - &mut lanes, - from, - to, - commit_lane, - target_lane, - ConnectionType::MergeBridgeMid, - ConnectionType::MergeBridgeStart, - ConnectionType::MergeBridgeEnd, - ); - } - - let mut branches = Vec::new(); - for &branch_lane in &branching_lanes { - let from = std::cmp::min(branch_lane, commit_lane); - let to = std::cmp::max(branch_lane, commit_lane); - branches.push((from, to)); - - if lanes.len() <= to { - lanes.resize(to + 1, None); - } + Self::draw_merge_bridge( + &mut lanes, + merge_bridge, + commit_lane, + current, + previous, + ); - Self::draw_bridge( - &mut lanes, - from, - to, - branch_lane, - branch_lane, - ConnectionType::MergeBridgeMid, - ConnectionType::BranchUp, - ConnectionType::BranchUpRight, - ); - } + let branches = Self::draw_branching_lanes( + &mut lanes, + &branching_lanes, + commit_lane, + ); GraphRow { - lane_count: curr.iter().flatten().count(), - commit_lane, + lane_count: to_lane_idx(current.iter().flatten().count()), + commit_lane: to_lane_idx(commit_lane), is_merge, is_branch_tip, is_stash, lanes, - merge_bridge, + merge_bridge: merge_bridge + .map(|(f, t)| (to_lane_idx(f), to_lane_idx(t))), branches, } } @@ -232,21 +418,16 @@ impl GraphWalker { #[allow(clippy::too_many_arguments)] fn fill_lanes( &self, - lanes: &mut [Option<(ConnectionType, usize)>], - curr: &Vector>, + lanes: &mut [Option<(ConnectionType, LaneIdx)>], + curr: &[Option], alias: Option, head_id: Option<&CommitId>, is_stash: bool, is_merge: bool, is_branch_tip: bool, - branching_lanes: &[usize], ) { for (lane_idx, chunk_item) in curr.iter().enumerate() { let Some(chunk) = chunk_item.as_ref() else { - if branching_lanes.contains(&lane_idx) { - lanes[lane_idx] = - Some((ConnectionType::BranchUp, lane_idx % MAX_LANE_COLORS)); - } continue; }; @@ -259,14 +440,16 @@ impl GraphWalker { _ => ConnectionType::CommitNormal, }; - lanes[lane_idx] = Some((conn_type, lane_idx % MAX_LANE_COLORS)); + lanes[lane_idx] = + Some((conn_type, lane_color(lane_idx))); } else { - let is_dotted = head_id - .and_then(|h| self.oids.get(h)) - .is_some_and(|ha| { - chunk.parent_a == Some(ha) - || chunk.parent_b == Some(ha) - }) && lane_idx == 0; + let target_oid = + head_id.and_then(|h| self.oids.get(h)); + + let is_dotted = lane_idx == 0 + && target_oid.is_some() + && (chunk.parent_a == target_oid + || chunk.parent_b == target_oid); let is_orphan = chunk.parent_a.is_none() && chunk.parent_b.is_none(); @@ -281,47 +464,231 @@ impl GraphWalker { ConnectionType::Vertical }; - lanes[lane_idx] = Some((conn, lane_idx % MAX_LANE_COLORS)); + lanes[lane_idx] = Some((conn, lane_color(lane_idx))); } } } - fn draw_bridge( - lanes: &mut [Option<(ConnectionType, usize)>], + /// Lay the horizontal run of a bridge over the lanes strictly + /// between its two ends, merging with whatever each cell already + /// shows. + fn draw_bridge_span( + lanes: &mut [Option<(ConnectionType, LaneIdx)>], from: usize, to: usize, - color_lane: usize, - corner_lane: usize, - mid: ConnectionType, - corner_right: ConnectionType, - corner_left: ConnectionType, + color: LaneIdx, ) { for lane in lanes.iter_mut().take(to).skip(from + 1) { - match lane { - Some(( - ConnectionType::Vertical | ConnectionType::VerticalDotted, - _, - )) => { - *lane = Some((ConnectionType::Cross, color_lane % MAX_LANE_COLORS)); - } - _ => { - *lane = Some((mid, color_lane % MAX_LANE_COLORS)); - } - } + overlay_cell(lane, Dirs::LEFT | Dirs::RIGHT, color); } + } +} - if corner_lane == to { - let new_corner = match (lanes[to], corner_right) { - (Some((ConnectionType::MergeBridgeStart, _)), ConnectionType::BranchUp) => ConnectionType::BranchUpMergeStart, - _ => corner_right, - }; - lanes[to] = Some((new_corner, color_lane % MAX_LANE_COLORS)); - } else if corner_lane == from { - let new_corner = match (lanes[from], corner_left) { - (Some((ConnectionType::MergeBridgeEnd, _)), ConnectionType::BranchUpRight) => ConnectionType::BranchUpRightMergeEnd, - _ => corner_left, - }; - lanes[from] = Some((new_corner, color_lane % MAX_LANE_COLORS)); +#[cfg(test)] +mod tests { + use super::*; + + fn id(n: usize) -> CommitId { + CommitId::from_str_unchecked(&format!("{n:040x}")) + .expect("valid oid") + } + + fn sym(conn: ConnectionType) -> char { + match conn { + ConnectionType::Vertical => '┃', + ConnectionType::VerticalDotted => '╏', + ConnectionType::CommitNormal => 'o', + ConnectionType::CommitBranch => '*', + ConnectionType::CommitMerge => 'M', + ConnectionType::CommitStash => '*', + ConnectionType::CommitUncommitted => '+', + ConnectionType::MergeBridgeStart => '┓', + ConnectionType::MergeBridgeMid => '━', + ConnectionType::MergeBridgeEnd => '┏', + ConnectionType::BranchUp => '┛', + ConnectionType::BranchUpRight => '┗', + ConnectionType::TeeLeft => '┫', + ConnectionType::TeeRight => '┣', + ConnectionType::TeeUp => '┻', + ConnectionType::TeeDown => '┳', + } + } + + /// Render a row the way the UI does: one glyph per lane plus a + /// spacer that carries a bridge's horizontal run. + fn row_to_string(row: &GraphRow) -> String { + let mut out = String::new(); + for (lane_index, conn) in row.lanes.iter().enumerate() { + out.push(conn.map_or(' ', |(c, _)| sym(c))); + + let in_bridge = row + .merge_bridge + .into_iter() + .chain(row.branches.iter().copied()) + .any(|(from, to)| { + lane_index >= usize::from(from) + && lane_index < usize::from(to) + }); + out.push(if in_bridge { '━' } else { ' ' }); + } + out.trim_end().to_string() + } + + /// Walk `history` (newest first, `(commit, parents)`) and render + /// every row. + fn render(history: &[(usize, &[usize])]) -> Vec { + let mut walker = GraphWalker::new(); + let ids: Vec = + history.iter().map(|(c, _)| id(*c)).collect(); + + for (commit, parents) in history { + let parents: Vec = + parents.iter().map(|p| id(*p)).collect(); + walker.process(id(*commit), &parents); + } + + walker + .compute_rows( + &ids, + 0, + &HashSet::new(), + &HashSet::new(), + None, + ) + .iter() + .map(row_to_string) + .collect() + } + + #[test] + fn linear_history() { + let rows = render(&[(1, &[2]), (2, &[3]), (3, &[])]); + assert_eq!(rows, vec!["o", "o", "o"]); + } + + #[test] + fn simple_merge() { + // 1 merges 3 into the line 1 → 2 → 4, 3 → 4 + let rows = + render(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]); + assert_eq!(rows, vec!["M━┓", "o ┃", "┃ o", "o━┛"]); + } + + #[test] + fn merge_into_tracked_lane_continues_through_corner() { + // 3's merge line joins lane 1 which keeps flowing to 5, + // so the corner must be a junction (┫), not a dead end (┓) + let rows = render(&[ + (1, &[3]), + (2, &[5]), + (3, &[4, 5]), + (4, &[6]), + (5, &[6]), + (6, &[]), + ]); + assert_eq!( + rows, + vec!["o", "┃ o", "M━┫", "o ┃", "┃ o", "o━┛"] + ); + } + + #[test] + fn merge_bridge_crosses_unrelated_lane() { + // 3 (lane 2) merges into 1's line (lane 0) while 2's line + // (lane 1) passes through: the crossed lane keeps its + // vertical instead of being cut by the bridge + let rows = render(&[ + (1, &[4]), + (2, &[5]), + (3, &[6, 4]), + (4, &[7]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]); + assert_eq!( + rows, + vec![ + "o", + "┃ o", + "┣━┃━M", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", + ] + ); + } + + #[test] + fn overlapping_branch_bridges_keep_inner_corner() { + // lanes 1 and 2 both close into the commit on lane 0; the + // outer bridge passes through the inner corner (┻) instead + // of erasing it + let rows = + render(&[(1, &[4]), (2, &[4]), (3, &[4]), (4, &[])]); + assert_eq!(rows, vec!["o", "┃ o", "┃ ┃ o", "o━┻━┛"]); + } + + #[test] + fn merge_and_branch_bridges_overlap() { + // commit 3 closes a branch from lane 2 while opening a merge + // to lane 3, crossing lane 1: every line stays continuous + let rows = render(&[ + (1, &[3, 4]), + (2, &[3]), + (3, &[5, 6]), + (4, &[5]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]); + assert_eq!( + rows, + vec![ + "M━┓", + "┃ ┃ o", + "M━┃━┻━┓", + "┃ o ┃", + "o━┛ ┃", + "┃ o", + "o━━━━━┛", + ] + ); + } + + #[test] + fn crossed_lane_keeps_own_color() { + let rows = &[ + (1usize, &[4usize][..]), + (2, &[5]), + (3, &[6, 4]), + (4, &[7]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]; + let mut walker = GraphWalker::new(); + let ids: Vec = + rows.iter().map(|(c, _)| id(*c)).collect(); + for (commit, parents) in rows { + let parents: Vec = + parents.iter().map(|p| id(*p)).collect(); + walker.process(id(*commit), &parents); } + let computed = walker.compute_rows( + &ids, + 0, + &HashSet::new(), + &HashSet::new(), + None, + ); + + // row of commit 3: lane 1 is crossed by the merge bridge but + // keeps both its vertical glyph and its own lane color + let crossed = computed[2].lanes[1] + .expect("crossed lane should not be empty"); + assert_eq!(crossed.0, ConnectionType::Vertical); + assert_eq!(crossed.1, lane_color(1)); } } diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 607a2babf1..a70c39fd45 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -2,8 +2,8 @@ use crate::{ error::Result, graph::{GraphRow, GraphWalker}, sync::{ - get_commits_info, gix_repo, repo, CommitId, LogWalker, - LogWalkerWithoutFilter, RepoPath, SharedCommitFilterFn, + gix_repo, repo, CommitId, LogWalker, LogWalkerWithoutFilter, + RepoPath, SharedCommitFilterFn, WalkEntry, }, AsyncGitNotification, Error, }; @@ -37,7 +37,7 @@ pub struct AsyncLogResult { /// pub duration: Duration, } -/// +/// Drives the background commit-log walker and exposes graph rows. pub struct AsyncLog { current: Arc>, current_head: Arc>>, @@ -47,6 +47,9 @@ pub struct AsyncLog { filter: Option, partial_extract: AtomicBool, repo: RepoPath, + /// All walk entries collected by the background thread, in walk order. + /// The graph walker reads these lazily, only as far as the viewport requires. + walk_entries: Arc>>, graph_walker: Arc>, } @@ -73,10 +76,18 @@ impl AsyncLog { background: Arc::new(AtomicBool::new(false)), filter, partial_extract: AtomicBool::new(false), + walk_entries: Arc::new(Mutex::new(Vec::new())), graph_walker: Arc::new(Mutex::new(GraphWalker::new())), } } + /// Computes graph rows for `commit_slice` starting at `global_start`. + /// + /// Driven lazily. Processes only as many + /// [`WalkEntry`]s as the viewport requires. + /// + /// Returns `None` when the background walk hasn't reached + /// `global_start + commit_slice.len()` yet. pub fn get_graph_rows( &self, commit_slice: &[CommitId], @@ -85,13 +96,27 @@ impl AsyncLog { stashes: &HashSet, head_id: Option<&CommitId>, ) -> Option> { - let walker_guard = self.graph_walker.lock().ok()?; let needed_end = global_start + commit_slice.len(); - if walker_guard.buffer.deltas.len() < needed_end { - return None; + + let mut walker = self.graph_walker.lock().ok()?; + + { + let entries = self.walk_entries.lock().ok()?; + if entries.len() < needed_end { + return None; + } + + // the walker may already be ahead of the requested range + // (you know, scrolling up), so only feed it entries + // we know it is yet to have seen + let processed = + walker.processed_commits().min(needed_end); + for entry in &entries[processed..needed_end] { + walker.process(entry.id, &entry.parents); + } } - Some(walker_guard.compute_rows( + Some(walker.compute_rows( commit_slice, global_start, branch_tips, @@ -192,7 +217,7 @@ impl AsyncLog { let sender = self.sender.clone(); let arc_pending = Arc::clone(&self.pending); let arc_background = Arc::clone(&self.background); - let arc_graph_walker = Arc::clone(&self.graph_walker); + let arc_walk_entries = Arc::clone(&self.walk_entries); let filter = self.filter.clone(); let repo_path = self.repo.clone(); @@ -209,7 +234,7 @@ impl AsyncLog { &arc_current, &arc_background, &sender, - &arc_graph_walker, + &arc_walk_entries, filter, ) .expect("failed to fetch"); @@ -227,7 +252,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, - arc_graph_walker: &Arc>, + arc_walk_entries: &Arc>>, filter: Option, ) -> Result<()> { filter.map_or_else( @@ -237,7 +262,7 @@ impl AsyncLog { arc_current, arc_background, sender, - arc_graph_walker, + arc_walk_entries, ) }, |filter| { @@ -246,107 +271,94 @@ impl AsyncLog { arc_current, arc_background, sender, - arc_graph_walker, filter, ) }, ) } + /// A filtered walk yields a disconnected subset of the history, + /// which the graph cannot represent, so no topology entries are + /// collected here. fn fetch_helper_with_filter( repo_path: &RepoPath, arc_current: &Arc>, arc_background: &Arc, sender: &Sender, - arc_graph_walker: &Arc>, filter: SharedCommitFilterFn, ) -> Result<()> { - let start_time = Instant::now(); - - let mut entries = vec![CommitId::default(); LIMIT_COUNT]; - entries.resize(0, CommitId::default()); - let r = repo(repo_path)?; let mut walker = LogWalker::new(&r, LIMIT_COUNT)?.filter(Some(filter)); - loop { - entries.clear(); - let read = walker.read(&mut entries)?; + Self::walk_loop( + |out| walker.read(out), + arc_current, + arc_background, + sender, + None, + )?; - { - let infos = get_commits_info( - repo_path, - &entries[0..read], - 1000, - )?; - let mut gw = arc_graph_walker.lock()?; - for info in &infos { - gw.process(info); - } - } - - let mut current = arc_current.lock()?; - current.commits.extend(entries.iter()); - current.duration = start_time.elapsed(); + log::trace!("revlog visited: {}", walker.visited()); - if read == 0 { - break; - } - Self::notify(sender); + Ok(()) + } - let sleep_duration = - if arc_background.load(Ordering::Relaxed) { - SLEEP_BACKGROUND - } else { - SLEEP_FOREGROUND - }; + fn fetch_helper_without_filter( + repo_path: &RepoPath, + arc_current: &Arc>, + arc_background: &Arc, + sender: &Sender, + arc_walk_entries: &Arc>>, + ) -> Result<()> { + let mut repo: gix::Repository = gix_repo(repo_path)?; + let mut walker = + LogWalkerWithoutFilter::new(&mut repo, LIMIT_COUNT)?; - thread::sleep(sleep_duration); - } + Self::walk_loop( + |out| walker.read(out), + arc_current, + arc_background, + sender, + Some(arc_walk_entries), + )?; log::trace!("revlog visited: {}", walker.visited()); Ok(()) } - fn fetch_helper_without_filter( - repo_path: &RepoPath, + /// Drives `read` in batches, publishing every batch's commit ids + /// to `arc_current` and (when given) moving the full entries into + /// `walk_entries` for the graph. + fn walk_loop( + mut read: impl FnMut(&mut Vec) -> Result, arc_current: &Arc>, arc_background: &Arc, sender: &Sender, - arc_graph_walker: &Arc>, + walk_entries: Option<&Mutex>>, ) -> Result<()> { let start_time = Instant::now(); - let mut entries = vec![CommitId::default(); LIMIT_COUNT]; - entries.resize(0, CommitId::default()); - - let mut repo: gix::Repository = gix_repo(repo_path)?; - let mut walker = - LogWalkerWithoutFilter::new(&mut repo, LIMIT_COUNT)?; + let mut entries: Vec = + Vec::with_capacity(LIMIT_COUNT); loop { - entries.clear(); - let read = walker.read(&mut entries)?; + let read_count = read(&mut entries)?; { - let infos = get_commits_info( - repo_path, - &entries[0..read], - 1000, - )?; - let mut gw = arc_graph_walker.lock()?; - for info in &infos { - gw.process(info); - } + let mut current = arc_current.lock()?; + current.commits.extend(entries.iter().map(|e| e.id)); + current.duration = start_time.elapsed(); } - let mut current = arc_current.lock()?; - current.commits.extend(entries.iter()); - current.duration = start_time.elapsed(); + if let Some(walk_entries) = walk_entries { + walk_entries.lock()?.append(&mut entries); + } else { + entries.clear(); + } - if read == 0 { + if read_count == 0 { break; } Self::notify(sender); @@ -361,8 +373,6 @@ impl AsyncLog { thread::sleep(sleep_duration); } - log::trace!("revlog visited: {}", walker.visited()); - Ok(()) } @@ -370,6 +380,7 @@ impl AsyncLog { self.current.lock()?.commits.clear(); *self.current_head.lock()? = None; self.partial_extract.store(false, Ordering::Relaxed); + self.walk_entries.lock()?.clear(); *self.graph_walker.lock()? = GraphWalker::new(); Ok(()) } @@ -391,7 +402,6 @@ mod tests { use serial_test::serial; use tempfile::TempDir; - use crate::graph::GraphWalker; use crate::sync::tests::{debug_cmd_print, repo_init}; use crate::sync::RepoPath; use crate::AsyncLog; @@ -419,15 +429,14 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); - let arc_graph_walker = - Arc::new(Mutex::new(GraphWalker::new())); + let arc_walk_entries = Arc::new(Mutex::new(Vec::new())); let result = AsyncLog::fetch_helper_without_filter( &subdir_path, &arc_current, &arc_background, &tx_git, - &arc_graph_walker, + &arc_walk_entries, ); assert_eq!(result.unwrap(), ()); @@ -450,8 +459,7 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); - let arc_graph_walker = - Arc::new(Mutex::new(GraphWalker::new())); + let arc_walk_entries = Arc::new(Mutex::new(Vec::new())); std::env::set_var("GIT_DIR", git_dir); @@ -461,7 +469,7 @@ mod tests { &arc_current, &arc_background, &tx_git, - &arc_graph_walker, + &arc_walk_entries, ); std::env::remove_var("GIT_DIR"); diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index 81a6fd321e..29843271f6 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -2,11 +2,25 @@ use super::{CommitId, SharedCommitFilterFn}; use crate::error::Result; use git2::{Commit, Oid, Repository}; use gix::revision::Walk; +use smallvec::SmallVec; use std::{ cmp::Ordering, collections::{BinaryHeap, HashSet}, }; +/// A commit id together with the ids of its TWO parents. +/// +/// The parents come for free during a walk. +/// Collecting here avoid a second, convulted pass. +#[derive(Debug, Clone)] +pub struct WalkEntry { + /// The commit's own unique identifier. + pub id: CommitId, + + /// The commit's parent identifiers. + pub parents: SmallVec<[CommitId; 2]>, +} + struct TimeOrderedCommit<'a>(Commit<'a>); impl Eq for TimeOrderedCommit<'_> {} @@ -70,11 +84,18 @@ impl<'a> LogWalker<'a> { } /// - pub fn read(&mut self, out: &mut Vec) -> Result { + pub fn read( + &mut self, + out: &mut Vec, + ) -> Result { let mut count = 0_usize; while let Some(c) = self.commits.pop() { + let mut parents = SmallVec::new(); for p in c.0.parents() { + if parents.len() < 2 { + parents.push(p.id().into()); + } self.visit(p); } @@ -87,7 +108,7 @@ impl<'a> LogWalker<'a> { }; if commit_should_be_included { - out.push(id); + out.push(WalkEntry { id, parents }); } count += 1; @@ -157,11 +178,22 @@ impl<'a> LogWalkerWithoutFilter<'a> { } /// - pub fn read(&mut self, out: &mut Vec) -> Result { + pub fn read( + &mut self, + out: &mut Vec, + ) -> Result { let mut count = 0_usize; while let Some(Ok(info)) = self.walk.next() { - out.push(info.id.into()); + out.push(WalkEntry { + id: info.id.into(), + parents: info + .parent_ids + .iter() + .take(2) + .map(|id| CommitId::from(*id)) + .collect(), + }); count += 1; @@ -214,7 +246,7 @@ mod tests { walk.read(&mut items).unwrap(); assert_eq!(items.len(), 1); - assert_eq!(items[0], oid2); + assert_eq!(items[0].id, oid2); Ok(()) } @@ -238,11 +270,12 @@ mod tests { let mut walk = LogWalker::new(&repo, 100)?; walk.read(&mut items).unwrap(); - let info = get_commits_info(repo_path, &items, 50).unwrap(); + let ids: Vec = items.iter().map(|e| e.id).collect(); + let info = get_commits_info(repo_path, &ids, 50).unwrap(); dbg!(&info); assert_eq!(items.len(), 2); - assert_eq!(items[0], oid2); + assert_eq!(items[0].id, oid2); let mut items = Vec::new(); walk.read(&mut items).unwrap(); @@ -272,11 +305,12 @@ mod tests { let mut items = Vec::new(); assert!(matches!(walk.read(&mut items), Ok(2))); - let info = get_commits_info(repo_path, &items, 50).unwrap(); + let ids: Vec = items.iter().map(|e| e.id).collect(); + let info = get_commits_info(repo_path, &ids, 50).unwrap(); dbg!(&info); assert_eq!(items.len(), 2); - assert_eq!(items[0], oid2); + assert_eq!(items[0].id, oid2); let mut items = Vec::new(); assert!(matches!(walk.read(&mut items), Ok(0))); @@ -318,7 +352,7 @@ mod tests { walker.read(&mut items).unwrap(); assert_eq!(items.len(), 1); - assert_eq!(items[0], second_commit_id); + assert_eq!(items[0].id, second_commit_id); let mut items = Vec::new(); walker.read(&mut items).unwrap(); @@ -365,7 +399,7 @@ mod tests { walker.read(&mut items).unwrap(); assert_eq!(items.len(), 1); - assert_eq!(items[0], second_commit_id); + assert_eq!(items[0].id, second_commit_id); let log_filter = filter_commit_by_search( LogFilterSearch::new(LogFilterSearchOptions { diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2cd358d065..2e76a2128e 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -72,7 +72,7 @@ pub use hooks::{ }; pub use hunks::{reset_hunk, stage_hunk, unstage_hunk}; pub use ignore::add_to_ignore; -pub use logwalker::{LogWalker, LogWalkerWithoutFilter}; +pub use logwalker::{LogWalker, LogWalkerWithoutFilter, WalkEntry}; pub use merge::{ abort_pending_rebase, abort_pending_state, continue_pending_rebase, merge_branch, merge_commit, merge_msg, @@ -261,13 +261,13 @@ pub mod tests { r: &Repository, max_count: usize, ) -> Vec { - let mut commit_ids = Vec::::new(); + let mut entries = Vec::new(); LogWalker::new(r, max_count) .unwrap() - .read(&mut commit_ids) + .read(&mut entries) .unwrap(); - commit_ids + entries.iter().map(|e| e.id).collect() } /// Same as `repo_init`, but the repo is a bare repo (--bare) From d9bbdf1d8ac7fca0c5d8209be2b4d1c4750f967b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:14:30 -0400 Subject: [PATCH 40/84] lane idx and tee connection types cutting down our memory usage from 32+ to EIGHT! also tee connection types --- asyncgit/src/graph/mod.rs | 41 +++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index e1c8bee521..d5687ea829 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -8,34 +8,55 @@ pub use walker::GraphWalker; /// The maximum number of colors to use for graph lanes pub const MAX_LANE_COLORS: usize = 16; -/// The type of connection between nodes in the graph +// Yes, there are repositories where this is exceeded +// Are they very rare? Yes. +// On most terminals can more than 256 lanes even be represneted usefully? Not really. +pub type LaneIdx = u8; + +/// Convert a lane position into the compact [`LaneIdx`] +/// representation. This way we can keep full granularity when computing, +/// but not when storing. +pub(crate) fn to_lane_idx(lane: usize) -> LaneIdx { + LaneIdx::try_from(lane).unwrap_or(LaneIdx::MAX) +} + +/// The type of connection between nodes in the graph. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ConnectionType { Vertical, VerticalDotted, - Cross, CommitNormal, CommitBranch, CommitMerge, CommitStash, CommitUncommitted, + /// a bridge turning down into a lane that starts here MergeBridgeStart, + /// a bridge passing over an empty lane slot MergeBridgeMid, + /// a bridge turning down, lane starting to its right MergeBridgeEnd, - BranchDown, + /// a lane from above turning left into a commit BranchUp, + /// a lane from above turning right into a commit BranchUpRight, - BranchUpMergeStart, - BranchUpRightMergeEnd, + /// a continuing lane absorbing a bridge from its left + TeeLeft, + /// a continuing lane absorbing a bridge from its right + TeeRight, + /// a lane ending from above while a bridge passes through + TeeUp, + /// a lane starting downward while a bridge passes through + TeeDown, } #[derive(Clone, Debug, Default)] pub struct GraphRow { /// Number of active lanes at this commit row - pub lane_count: usize, + pub lane_count: LaneIdx, /// Which lane index this commit sits on - pub commit_lane: usize, + pub commit_lane: LaneIdx, /// Whether this is a merge commit (two parents) pub is_merge: bool, @@ -49,13 +70,13 @@ pub struct GraphRow { /// Connections emitted per lane: /// None = empty space /// Some((ConnectionType, `color_index`)) = draw this connector in this color - pub lanes: Vec>, + pub lanes: Vec>, /// Horizontal merge bridge: if this commit merges rightward, /// (`from_lane`, `to_lane`) — the span to draw ─ ╭ ╮ across - pub merge_bridge: Option<(usize, usize)>, + pub merge_bridge: Option<(LaneIdx, LaneIdx)>, /// Horizontal branch bridges: if this commit spawns branches, /// spans to draw ─ ╭ ╮ across - pub branches: Vec<(usize, usize)>, + pub branches: Vec<(LaneIdx, LaneIdx)>, } From 1236f76968161ad5b597a50f04b7df14fb5a15f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:14:33 -0400 Subject: [PATCH 41/84] graph symbols and lane count with lane idx --- src/components/utils/graphrow.rs | 6 ++++-- src/components/utils/logitems.rs | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/utils/graphrow.rs b/src/components/utils/graphrow.rs index d626b6640f..e45bae89a7 100644 --- a/src/components/utils/graphrow.rs +++ b/src/components/utils/graphrow.rs @@ -3,7 +3,6 @@ pub const SYM_COMMIT_BRANCH: &str = "*"; pub const SYM_COMMIT_MERGE: &str = "M"; pub const SYM_COMMIT_STASH: &str = "*"; pub const SYM_COMMIT_UNCOMMITTED: &str = "+"; -pub const SYM_CROSS: &str = "╋"; pub const SYM_VERTICAL: &str = "┃"; pub const SYM_VERTICAL_DOTTED: &str = "╏"; pub const SYM_HORIZONTAL: &str = "━"; @@ -11,6 +10,9 @@ pub const SYM_MERGE_BRIDGE_START: &str = "┓"; pub const SYM_MERGE_BRIDGE_MID: &str = "━"; pub const SYM_MERGE_BRIDGE_END: &str = "┏"; pub const SYM_BRANCH_UP: &str = "┛"; -pub const SYM_BRANCH_DOWN: &str = "┓"; pub const SYM_BRANCH_UP_RIGHT: &str = "┗"; +pub const SYM_TEE_LEFT: &str = "┫"; +pub const SYM_TEE_RIGHT: &str = "┣"; +pub const SYM_TEE_UP: &str = "┻"; +pub const SYM_TEE_DOWN: &str = "┳"; pub const SYM_SPACE: &str = " "; diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs index 1ff46079a6..1025667d5c 100644 --- a/src/components/utils/logitems.rs +++ b/src/components/utils/logitems.rs @@ -83,7 +83,7 @@ impl LogEntry { } } -/// +/// A batch of parsed log entries with an index offset. #[derive(Default)] pub struct ItemBatch { index_offset: Option, @@ -154,9 +154,9 @@ impl ItemBatch { /// pub fn set_graph_rows(&mut self, rows: Vec) { - let mut max = 0; + let mut max: usize = 0; for (entry, row) in self.items.iter_mut().zip(rows) { - max = max.max(row.lane_count); + max = max.max(row.lane_count.into()); entry.graph = Some(row); } self.max_lane = max; From 29ebebd1c3d3a5b6fc65a5c30465d559cbbe5b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:14:36 -0400 Subject: [PATCH 42/84] commitlist graph rendering refactor big focuses on readability and reduced repition --- src/components/commitlist.rs | 356 +++++++++++++++++------------------ 1 file changed, 177 insertions(+), 179 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 47c32675b1..5d84400c7f 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,10 +1,10 @@ use super::utils::graphrow::{ - SYM_BRANCH_DOWN, SYM_BRANCH_UP, SYM_BRANCH_UP_RIGHT, SYM_COMMIT, + SYM_BRANCH_UP, SYM_BRANCH_UP_RIGHT, SYM_COMMIT, SYM_COMMIT_BRANCH, SYM_COMMIT_MERGE, SYM_COMMIT_STASH, - SYM_COMMIT_UNCOMMITTED, SYM_CROSS, SYM_HORIZONTAL, - SYM_MERGE_BRIDGE_END, SYM_MERGE_BRIDGE_MID, - SYM_MERGE_BRIDGE_START, SYM_SPACE, SYM_VERTICAL, - SYM_VERTICAL_DOTTED, + SYM_COMMIT_UNCOMMITTED, SYM_HORIZONTAL, SYM_MERGE_BRIDGE_END, + SYM_MERGE_BRIDGE_MID, SYM_MERGE_BRIDGE_START, SYM_SPACE, + SYM_TEE_DOWN, SYM_TEE_LEFT, SYM_TEE_RIGHT, SYM_TEE_UP, + SYM_VERTICAL, SYM_VERTICAL_DOTTED, }; use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ @@ -40,6 +40,7 @@ use ratatui::{ Frame, }; use std::borrow::Cow; +use std::collections::HashSet; use std::{ cell::Cell, cmp, collections::BTreeMap, rc::Rc, time::Instant, }; @@ -56,7 +57,7 @@ const GRAPH_COLORS: &[Color] = &[ Color::Red, ]; -/// +/// Renders the commit log with a side-bar graph. pub struct CommitList { repo: RepoPathRef, title: Box, @@ -108,6 +109,16 @@ impl CommitList { } } + /// Whether the commit graph column is currently visible. + pub const fn is_graph_visible(&self) -> bool { + self.show_graph + } + + /// Whether the loaded window already has up-to-date graph rows. + pub const fn is_graph_ready(&self) -> bool { + self.items.graph_ready + } + /// pub fn get_loaded_slice(&self) -> (Vec, usize) { let offset = self.items.index_offset(); @@ -238,6 +249,9 @@ impl CommitList { .or_default() .push(local_branch); } + + // branch tips and head are baked into graph rows + self.items.graph_ready = false; } /// @@ -253,6 +267,9 @@ impl CommitList { .or_default() .push(remote_branch); } + + // branch tips are baked into graph rows + self.items.graph_ready = false; } /// @@ -490,7 +507,6 @@ impl CommitList { } fn build_graph_spans<'a>( - &self, row: &'a GraphRow, graph_col_width: usize, empty_lanes: &std::collections::HashSet, @@ -503,73 +519,47 @@ impl CommitList { } let (sym, graph_color) = match conn { None => (SYM_SPACE, Color::Reset), - Some((ConnectionType::Vertical, color_idx)) => ( - SYM_VERTICAL, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::VerticalDotted, color_idx)) => ( - SYM_VERTICAL_DOTTED, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::Cross, color_idx)) => ( - SYM_CROSS, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::BranchUpMergeStart, color_idx)) => ( - SYM_BRANCH_UP, // Reusing '┛' - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some(( - ConnectionType::BranchUpRightMergeEnd, - color_idx, - )) => ( - SYM_BRANCH_UP_RIGHT, // Reusing '┗' - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::CommitNormal, color_idx)) => ( - SYM_COMMIT, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::CommitBranch, color_idx)) => ( - SYM_COMMIT_BRANCH, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::CommitMerge, color_idx)) => ( - SYM_COMMIT_MERGE, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::CommitStash, color_idx)) => ( - SYM_COMMIT_STASH, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::CommitUncommitted, color_idx)) => ( - SYM_COMMIT_UNCOMMITTED, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::MergeBridgeStart, color_idx)) => ( - SYM_MERGE_BRIDGE_START, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::MergeBridgeMid, color_idx)) => ( - SYM_MERGE_BRIDGE_MID, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::MergeBridgeEnd, color_idx)) => ( - SYM_MERGE_BRIDGE_END, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::BranchDown, color_idx)) => ( - SYM_BRANCH_DOWN, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::BranchUp, color_idx)) => ( - SYM_BRANCH_UP, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), - Some((ConnectionType::BranchUpRight, color_idx)) => ( - SYM_BRANCH_UP_RIGHT, - GRAPH_COLORS[color_idx % GRAPH_COLORS.len()], - ), + Some((conn_type, color_idx)) => { + let color = GRAPH_COLORS[usize::from(*color_idx) + % GRAPH_COLORS.len()]; + let sym = match conn_type { + ConnectionType::Vertical => SYM_VERTICAL, + ConnectionType::VerticalDotted => { + SYM_VERTICAL_DOTTED + } + ConnectionType::TeeLeft => SYM_TEE_LEFT, + ConnectionType::TeeRight => SYM_TEE_RIGHT, + ConnectionType::TeeUp => SYM_TEE_UP, + ConnectionType::TeeDown => SYM_TEE_DOWN, + ConnectionType::BranchUpRight => { + SYM_BRANCH_UP_RIGHT + } + ConnectionType::CommitNormal => SYM_COMMIT, + ConnectionType::CommitBranch => { + SYM_COMMIT_BRANCH + } + ConnectionType::CommitMerge => { + SYM_COMMIT_MERGE + } + ConnectionType::CommitStash => { + SYM_COMMIT_STASH + } + ConnectionType::CommitUncommitted => { + SYM_COMMIT_UNCOMMITTED + } + ConnectionType::MergeBridgeStart => { + SYM_MERGE_BRIDGE_START + } + ConnectionType::MergeBridgeMid => { + SYM_MERGE_BRIDGE_MID + } + ConnectionType::MergeBridgeEnd => { + SYM_MERGE_BRIDGE_END + } + ConnectionType::BranchUp => SYM_BRANCH_UP, + }; + (sym, color) + } }; spans.push(Span::styled( sym, @@ -580,24 +570,26 @@ impl CommitList { let mut is_bridge_lane = false; let mut spacer_color = graph_color; - if let Some((from, to)) = row.merge_bridge { - if lane_index >= from && lane_index < to { - is_bridge_lane = true; - spacer_color = GRAPH_COLORS - [row.commit_lane % GRAPH_COLORS.len()]; - } - } + let commit_lane = usize::from(row.commit_lane); + + let edges = row + .merge_bridge + .into_iter() + .chain(row.branches.iter().copied()); + + for (st, fin) in edges { + let (start, finish) = + (usize::from(st), usize::from(fin)); - for &(from, to) in &row.branches { - if lane_index >= from && lane_index < to { + if (start..finish).contains(&lane_index) { is_bridge_lane = true; - let branch_lane = if row.commit_lane == from { - to + let target_lane = if commit_lane == start { + finish } else { - from + start }; spacer_color = GRAPH_COLORS - [branch_lane % GRAPH_COLORS.len()]; + [target_lane % GRAPH_COLORS.len()]; } } @@ -690,7 +682,7 @@ impl CommitList { empty_lanes: &std::collections::HashSet, ) { if let Some(ref row) = e.graph { - txt.extend(self.build_graph_spans( + txt.extend(Self::build_graph_spans( row, self.graph_col_width.get(), empty_lanes, @@ -787,111 +779,114 @@ impl CommitList { fn get_text(&self, height: usize, width: usize) -> Vec> { let selection = self.relative_selection(); - - let mut txt: Vec = Vec::with_capacity(height); - - let mut empty_lanes = std::collections::HashSet::new(); + let now = Local::now(); + let any_marked = !self.marked.is_empty(); if self.show_graph { - let mut max_lane_in_view = 0; - for e in self + let view = self .items .iter() .skip(self.scroll_top.get()) - .take(height) - { - if let Some(row) = &e.graph { - max_lane_in_view = - max_lane_in_view.max(row.lanes.len()); - } - } - - // Assume all lanes up to max_lane_in_view are empty - empty_lanes.extend(0..max_lane_in_view); - - // Remove lanes that have content - for e in self - .items - .iter() - .skip(self.scroll_top.get()) - .take(height) - { - if let Some(row) = &e.graph { - for (lane_idx, conn) in - row.lanes.iter().enumerate() - { - if !matches!( - conn, - None | Some(( - ConnectionType::MergeBridgeMid, - _ - )) - ) { - empty_lanes.remove(&lane_idx); - } - } - } - } + .take(height); + + let max_lane = view + .clone() + .filter_map(|e| e.graph.as_ref()) + .map(|row| row.lanes.len()) + .max() + .unwrap_or(0); + + let empty_lanes: std::collections::HashSet = (0..max_lane) + .filter(|&index| { + view.clone().filter_map(|entry| entry.graph.as_ref()).all(|row| { + row.lanes.get(index).is_none_or(|conn| { + matches!(conn, None | Some((ConnectionType::MergeBridgeMid, _))) + }) + }) + }) + .collect(); + + self.graph_col_width.set( + ((max_lane.saturating_sub(empty_lanes.len())) * 2) + .max(2), + ); - let width = ((max_lane_in_view - .saturating_sub(empty_lanes.len())) - * 2) - .max(2); - self.graph_col_width.set(width); + self.collect_entries( + height, + width, + selection, + now, + any_marked, + &empty_lanes, + ) } else { self.graph_col_width.set(0); + self.collect_entries( + height, + width, + selection, + now, + any_marked, + &HashSet::new(), + ) } + } - let any_marked = !self.marked.is_empty(); - let now = Local::now(); - - for (idx, e) in self - .items + fn collect_entries( + &self, + height: usize, + width: usize, + selection: usize, + now: DateTime, + any_marked: bool, + empty_lanes: &HashSet, + ) -> Vec> { + self.items .iter() .skip(self.scroll_top.get()) .take(height) .enumerate() - { - let tags = - self.tags.as_ref().and_then(|t| t.get(&e.id)).map( - |tags| { + .map(|(index, entry)| { + let tags = self + .tags + .as_ref() + .and_then(|t| t.get(&entry.id)) + .map(|tags| { tags.iter() - .map(|t| format!("<{}>", t.name)) + .map(|tag| format!("<{}>", tag.name)) .join(" ") - }, - ); - - let local_branches = - self.local_branches.get(&e.id).map(|local_branch| { - local_branch - .iter() - .map(|local_branch| { - format!("{{{0}}}", local_branch.name) - }) - .join(" ") - }); - - let marked = if any_marked { - self.is_marked(&e.id) - } else { - None - }; - - txt.push(self.get_entry_to_add( - e, - idx + self.scroll_top.get() == selection, - tags, - local_branches, - self.remote_branches_string(e), - &self.theme, - width.into(), - now, - marked, - &empty_lanes, - )); - } - - txt + }); + + let local_branches = self + .local_branches + .get(&entry.id) + .map(|local_branch| { + local_branch + .iter() + .map(|branch| { + format!("{{{}}}", branch.name) + }) + .join(" ") + }); + + let marked = any_marked + .then(|| self.is_marked(&entry.id)) + .flatten(); + + self.get_entry_to_add( + entry, + index + self.scroll_top.get() == selection, + tags, + local_branches, + self.remote_branches_string(entry), + &self.theme, + width, + now, + marked, + empty_lanes, + ) + }) + .collect() } fn remote_branches_string(&self, e: &LogEntry) -> Option { @@ -1358,7 +1353,6 @@ mod tests { #[test] fn test_build_graph_spans() { - let cl = CommitList::default(); let row = GraphRow { lane_count: 1, commit_lane: 0, @@ -1369,7 +1363,11 @@ mod tests { merge_bridge: None, branches: vec![], }; - let spans = cl.build_graph_spans(&row, 1); + let spans = CommitList::build_graph_spans( + &row, + 1, + &std::collections::HashSet::new(), + ); assert_eq!(spans.len(), 2); assert_eq!(spans[0].content, Cow::from(SYM_COMMIT)); From c1d1189e9f088b8c529292ea44a545cb24c947a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:14:39 -0400 Subject: [PATCH 43/84] revlog tab graph method extraction Extract update_graph_rows and gate behind is_graph_visible/ready. --- src/tabs/revlog.rs | 76 ++++++++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index 23687c5493..ef2d6dc648 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -60,7 +60,7 @@ enum LogSearch { Results(LogSearchResult), } -/// +/// Top-level revlog tab that ties together the commit list, details, and search. pub struct Revlog { repo: RepoPathRef, commit_details: CommitDetailsComponent, @@ -135,35 +135,10 @@ impl Revlog { self.list .refresh_extend_data(self.git_log.extract_items()?); - let (slice, global_start) = self.list.get_loaded_slice(); - if !slice.is_empty() { - let mut branch_tips = HashSet::new(); - let mut head_id = None; - - for (id, branches) in self.list.local_branches() { - branch_tips.insert(*id); - if head_id.is_none() - && branches.iter().any(|b| { - b.local_details() - .is_some_and(|d| d.is_head) - }) { - head_id = Some(*id); - } - } - branch_tips - .extend(self.list.remote_branches().keys()); - - let stashes = HashSet::new(); - - if let Some(rows) = self.git_log.get_graph_rows( - &slice, - global_start, - &branch_tips, - &stashes, - head_id.as_ref(), - ) { - self.list.set_graph_rows(rows); - } + if self.list.is_graph_visible() + && !self.list.is_graph_ready() + { + self.update_graph_rows(); } self.git_tags.request(Duration::from_secs(3), false)?; @@ -182,6 +157,47 @@ impl Revlog { Ok(()) } + /// Computes graph rows for the current commit window. Rows + /// don't depend on the selection + /// so its is skipped while + /// [`CommitList::is_graph_ready`] holds the bag. + fn update_graph_rows(&mut self) { + let (slice, global_start) = self.list.get_loaded_slice(); + if slice.is_empty() { + return; + } + + let head_id = self.list.local_branches().iter().find_map( + |(id, branches)| { + let has_head = branches.iter().any(|branch| { + branch.local_details().is_some_and(|b| b.is_head) + }); + has_head.then_some(*id) + }, + ); + + let branch_tips: HashSet<_> = self + .list + .local_branches() + .keys() + .chain(self.list.remote_branches().keys()) + .copied() + .collect(); + + // TODO: include stashes (heh) + let stashes = HashSet::new(); + + if let Some(rows) = self.git_log.get_graph_rows( + &slice, + global_start, + &branch_tips, + &stashes, + head_id.as_ref(), + ) { + self.list.set_graph_rows(rows); + } + } + /// pub fn update_git( &mut self, From b031f2cdd3a05369d4a1191b57e058a11f310d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:14:44 -0400 Subject: [PATCH 44/84] snapshot test infrastructure sets up insta for reliable graph snapshots --- src/main.rs | 2 +- ...gitui__gitui__tests__log_graph_hidden.snap | 17 +++++++++++++++++ ...gitui__gitui__tests__log_graph_linear.snap | 17 +++++++++++++++++ ...__gitui__tests__log_graph_with_branch.snap | 19 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/snapshots/gitui__gitui__tests__log_graph_hidden.snap create mode 100644 src/snapshots/gitui__gitui__tests__log_graph_linear.snap create mode 100644 src/snapshots/gitui__gitui__tests__log_graph_with_branch.snap diff --git a/src/main.rs b/src/main.rs index fd662950a2..cb5fc69be6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -114,7 +114,7 @@ type Terminal = ratatui::Terminal>; static TICK_INTERVAL: Duration = Duration::from_secs(5); static SPINNER_INTERVAL: Duration = Duration::from_millis(80); -/// +/// Events sent to the event-loop to wake up the UI. #[derive(Clone)] pub enum QueueEvent { Tick, diff --git a/src/snapshots/gitui__gitui__tests__log_graph_hidden.snap b/src/snapshots/gitui__gitui__tests__log_graph_hidden.snap new file mode 100644 index 0000000000..bab83a0898 --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__log_graph_hidden.snap @@ -0,0 +1,17 @@ +--- +source: src/gitui.rs +assertion_line: 359 +expression: terminal.backend() +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Commit 3/3──────────────────────────────────────────────────────────────────────────────┐" +"│[COMMIT] <1m ago name commit B █" +"│[COMMIT] <1m ago name commit A ║" +"│[COMMIT] <1m ago name initial ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"└────────────────────────────────────────────────────────────────────────────────────────┘" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" diff --git a/src/snapshots/gitui__gitui__tests__log_graph_linear.snap b/src/snapshots/gitui__gitui__tests__log_graph_linear.snap new file mode 100644 index 0000000000..a03daefa14 --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__log_graph_linear.snap @@ -0,0 +1,17 @@ +--- +source: src/gitui.rs +assertion_line: 341 +expression: terminal.backend() +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Commit 3/3──────────────────────────────────────────────────────────────────────────────┐" +"│o [COMMIT] <1m ago name commit B █" +"│o [COMMIT] <1m ago name commit A ║" +"│o [COMMIT] <1m ago name initial ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"└────────────────────────────────────────────────────────────────────────────────────────┘" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" diff --git a/src/snapshots/gitui__gitui__tests__log_graph_with_branch.snap b/src/snapshots/gitui__gitui__tests__log_graph_with_branch.snap new file mode 100644 index 0000000000..b4033ba5ba --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__log_graph_with_branch.snap @@ -0,0 +1,19 @@ +--- +source: src/gitui.rs +assertion_line: 375 +expression: terminal.backend() +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Commit 4/4──────────────────────────────────────────────────────────────────────────────┐" +"│M━┓ [COMMIT] <1m ago name merge C into main █" +"│o ┃ [COMMIT] <1m ago name commit A ║" +"│┃ o [COMMIT] <1m ago name commit C ║" +"│o━┛ [COMMIT] <1m ago name initial ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"└────────────────────────────────────────────────────────────────────────────────────────┘" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" From 8d24453554912c66ef2f96138cc3abb2c147a5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:20:32 -0400 Subject: [PATCH 45/84] trailing comma and minor cleanup --- filetreelist/src/lib.rs | 1 - src/app.rs | 6 +++--- src/components/status_tree.rs | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/filetreelist/src/lib.rs b/filetreelist/src/lib.rs index 3e80065aba..74ccf6aed4 100644 --- a/filetreelist/src/lib.rs +++ b/filetreelist/src/lib.rs @@ -1,4 +1,3 @@ -// #![forbid(missing_docs)] #![forbid(unsafe_code)] #![deny( mismatched_lifetime_syntaxes, diff --git a/src/app.rs b/src/app.rs index ddb157af3f..435b5eedde 100644 --- a/src/app.rs +++ b/src/app.rs @@ -138,9 +138,9 @@ impl Environment { use crossbeam_channel::unbounded; Self { queue: Queue::new(), - theme: Default::default(), - key_config: Default::default(), - repo: RefCell::new(RepoPath::Path(Default::default())), + theme: Rc::default(), + key_config: Rc::default(), + repo: RefCell::new(RepoPath::Path(PathBuf::default())), options: Rc::new(RefCell::new(Options::test_env())), sender_git: unbounded().0, sender_app: unbounded().0, diff --git a/src/components/status_tree.rs b/src/components/status_tree.rs index ac4fc9f6a8..48935ca777 100644 --- a/src/components/status_tree.rs +++ b/src/components/status_tree.rs @@ -22,7 +22,7 @@ use std::{borrow::Cow, cell::Cell, path::Path}; //TODO: use new `filetreelist` crate -/// +/// Renders the working-tree status as a tree. #[allow(clippy::struct_excessive_bools)] pub struct StatusTreeComponent { title: String, From 9d7aa1ba33812bb4b099bf6fc3eabf3ccf419a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:44:07 -0400 Subject: [PATCH 46/84] fixed clippy lints --- asyncgit/src/graph/buffer.rs | 2 +- asyncgit/src/graph/walker.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index 3f2e7a47c7..c02354901e 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -206,7 +206,7 @@ impl Buffer { .push(Delta(std::mem::take(&mut self.pending_delta))); let step = self.deltas.len(); - if step % CHECKPOINT_INTERVAL == 0 { + if step.is_multiple_of(CHECKPOINT_INTERVAL) { self.checkpoints.insert(step - 1, self.current.clone()); } } diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index d5f7681aad..33c5e2eebb 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -187,7 +187,7 @@ impl GraphWalker { } /// Number of commits already folded into the graph buffer. - pub fn processed_commits(&self) -> usize { + pub const fn processed_commits(&self) -> usize { self.buffer.deltas.len() } From 260f27b3662f6f102959549bddda1539bc0dfe81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 12:57:18 -0400 Subject: [PATCH 47/84] restore [0.28.1] release notes lost in rebase conflict resolution --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 937d852125..d2b3a279a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * when staging the last file in a directory, the first item after the directory is no longer skipped [[@Tillerino](https://github.com/Tillerino)] ([#2748](https://github.com/gitui-org/gitui/issues/2748)) * index-out-of-bounds panic when unstaging lines near the end of a diff ([#2953](https://github.com/gitui-org/gitui/issues/2953)) +## [0.28.1] - 2026-03-21 + +### Changed +* support proper pre-push hook ([#2809](https://github.com/gitui-org/gitui/issues/2809)) +* improve `gitui --version` message [[@hlsxx](https://github.com/hlsxx)] ([#2838](https://github.com/gitui-org/gitui/issues/2838)) +* rust msrv bumped to `1.88` + +### Fixed +* fix extremely slow status loading in large repositories by replacing time-based cache invalidation with generation counter [[@DannyStoll1](https://github.com/DannyStoll1)] ([#2823](https://github.com/gitui-org/gitui/issues/2823)) +* fix panic when renaming or updating remote URL with no remotes configured [[@xvchris](https://github.com/xvchris)] ([#2868](https://github.com/gitui-org/gitui/issues/2868)) + ## [0.28.0] - 2025-12-14 **discard changes on checkout** From fc748e6421d87c896aaa7c2643354302bff32e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 13:10:12 -0400 Subject: [PATCH 48/84] fixed snapshot regression --- src/gitui.rs | 2 +- .../gitui__gitui__tests__app_log_tab_showing_one_commit.snap | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/gitui.rs b/src/gitui.rs index 03d73b11c1..8052218ac2 100644 --- a/src/gitui.rs +++ b/src/gitui.rs @@ -226,7 +226,7 @@ mod tests { // Linux Temp Folder settings.add_filter(r" */tmp/\.tmp\S+-insta/", "[TEMP_FILE]"); // Commit ids that follow a vertical bar - settings.add_filter(r"│[a-z0-9]{7} ", "│[AAAAA] "); + settings.add_filter(r"│[^a-f0-9]*[a-f0-9]{7} ", "│[AAAAA] "); let _bound = settings.bind_to_scope(); } } diff --git a/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap b/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap index bbdd5be8a4..ea57f3165b 100644 --- a/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap +++ b/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap @@ -6,7 +6,7 @@ snapshot_kind: text " Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " " ──────────────────────────────────────────────────────────────────────────────────────── " "┌Commit 1/1──────────────────────────────────────────────────────────────────────────────┐" -"│[AAAAA] <1m ago name initial █" +"│[AAAAA] <1m ago name initial █" "│ ║" "│ ║" "│ ║" @@ -14,4 +14,4 @@ snapshot_kind: text "│ ║" "│ ║" "└────────────────────────────────────────────────────────────────────────────────────────┘" -"Scroll [↑↓] Mark [˽] Details [⏎] Branches [b] Compare [⇧C] Copy Hash [y] Tag [t] more [.]" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" From ab1c3876866ba8470637777cb5eb5b28246adee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 13 Jun 2026 14:10:53 -0400 Subject: [PATCH 49/84] fixes to spatial positioning overly pessimistic collapsing rmeoved --- asyncgit/src/graph/buffer.rs | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index c02354901e..3550c834ce 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -148,16 +148,6 @@ impl Buffer { } fn flush_merge_commits(&mut self) { - // Collect available empty lanes once, before we start maybe filling them. - let mut empty_lanes: Vec = self - .current - .iter() - .enumerate() - .filter_map(|(index, slot)| { - slot.is_none().then_some(index) - }) - .collect(); - while let Some(alias) = self.merge_commits.pop() { // Search for an occupied slot that matches the target alias. // If found, extract its index and a mutable clone of the chunk. @@ -184,14 +174,14 @@ impl Buffer { marker: Markers::Commit, }; - if let Some(empty_index) = empty_lanes.pop() { - self.record_replace(empty_index, Some(new_lane)); - } else { - self.record_insert( - self.current.len(), - Some(new_lane), - ); - } + // Always append the merge's second-parent lane to + // the end instead of reusing an existing empty slot, + // so the new visual column does not collapse + // spatial ordering of lanes already in existence. + self.record_insert( + self.current.len(), + Some(new_lane), + ); } } } From 541ec861b63d4a4ba0caf39f58cb5eb9b9cd7407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:43:39 -0400 Subject: [PATCH 50/84] newtype wrappers for lane index and alias id --- asyncgit/src/graph/buffer.rs | 9 ++++--- asyncgit/src/graph/chunk.rs | 8 +++--- asyncgit/src/graph/mod.rs | 48 +++++++++++++++++++++++++++--------- asyncgit/src/graph/oids.rs | 9 ++++--- asyncgit/src/graph/walker.rs | 36 +++++++++++++-------------- 5 files changed, 69 insertions(+), 41 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index 3550c834ce..402ea24996 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -1,4 +1,5 @@ use super::chunk::{Chunk, Markers}; +use super::AliasId; use std::collections::BTreeMap; /// A single mutation of the lane state, recorded while processing one @@ -39,7 +40,7 @@ pub struct Buffer { /// Aliases of merge commits whose second parent still needs a new /// lane. - merge_commits: Vec, + merge_commits: Vec, /// Scratch list of the [`DeltaOp`]s recorded for processing commit pending_delta: Vec, @@ -64,7 +65,7 @@ impl Buffer { /// Remember `alias` as a merge commit whose second parent must be /// given its own lane. - pub fn track_merge_commit(&mut self, alias: usize) { + pub fn track_merge_commit(&mut self, alias: AliasId) { self.merge_commits.push(alias); } @@ -104,7 +105,7 @@ impl Buffer { fn find_lane_awaiting_parent( &self, - alias: Option, + alias: Option, ) -> Option { let alias = alias?; self.current.iter().position(|slot| { @@ -119,7 +120,7 @@ impl Buffer { fn consume_alias_in_other_chunks( &mut self, - alias: usize, + alias: AliasId, skip_index: usize, ) { for index in 0..self.current.len() { diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index 8c70795c21..20748330a9 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -1,3 +1,5 @@ +use super::AliasId; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum Markers { Uncommitted, @@ -6,8 +8,8 @@ pub enum Markers { #[derive(Clone, Debug, PartialEq, Eq)] pub struct Chunk { - pub alias: Option, - pub parent_a: Option, - pub parent_b: Option, + pub alias: Option, + pub parent_a: Option, + pub parent_b: Option, pub marker: Markers, } diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index d5687ea829..5bc8f87cd2 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -11,13 +11,39 @@ pub const MAX_LANE_COLORS: usize = 16; // Yes, there are repositories where this is exceeded // Are they very rare? Yes. // On most terminals can more than 256 lanes even be represneted usefully? Not really. -pub type LaneIdx = u8; +#[derive(Clone, Copy, Debug, Default)] +pub struct LaneIndex(u8); -/// Convert a lane position into the compact [`LaneIdx`] -/// representation. This way we can keep full granularity when computing, -/// but not when storing. -pub(crate) fn to_lane_idx(lane: usize) -> LaneIdx { - LaneIdx::try_from(lane).unwrap_or(LaneIdx::MAX) +/// Numeric alias assigned to each commit in the graph. +/// +/// The alias is a dense integer index created by [`GraphOids`](super::oids::GraphOids) +/// that avoids storing full [`CommitId`](crate::sync::CommitId)s inside the lane state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct AliasId(usize); + +impl std::ops::Deref for AliasId { + type Target = usize; + fn deref(&self) -> &usize { + &self.0 + } +} + +impl From for AliasId { + fn from(v: usize) -> Self { + Self(v) + } +} + +impl From for LaneIndex { + fn from(lane: usize) -> Self { + Self(u8::try_from(lane).unwrap_or(u8::MAX)) + } +} + +impl From for usize { + fn from(lane: LaneIndex) -> Self { + lane.0 as usize + } } /// The type of connection between nodes in the graph. @@ -53,10 +79,10 @@ pub enum ConnectionType { #[derive(Clone, Debug, Default)] pub struct GraphRow { /// Number of active lanes at this commit row - pub lane_count: LaneIdx, + pub lane_count: LaneIndex, /// Which lane index this commit sits on - pub commit_lane: LaneIdx, + pub commit_lane: LaneIndex, /// Whether this is a merge commit (two parents) pub is_merge: bool, @@ -70,13 +96,13 @@ pub struct GraphRow { /// Connections emitted per lane: /// None = empty space /// Some((ConnectionType, `color_index`)) = draw this connector in this color - pub lanes: Vec>, + pub lanes: Vec>, /// Horizontal merge bridge: if this commit merges rightward, /// (`from_lane`, `to_lane`) — the span to draw ─ ╭ ╮ across - pub merge_bridge: Option<(LaneIdx, LaneIdx)>, + pub merge_bridge: Option<(LaneIndex, LaneIndex)>, /// Horizontal branch bridges: if this commit spawns branches, /// spans to draw ─ ╭ ╮ across - pub branches: Vec<(LaneIdx, LaneIdx)>, + pub branches: Vec<(LaneIndex, LaneIndex)>, } diff --git a/asyncgit/src/graph/oids.rs b/asyncgit/src/graph/oids.rs index dca161de88..38827c24c9 100644 --- a/asyncgit/src/graph/oids.rs +++ b/asyncgit/src/graph/oids.rs @@ -1,8 +1,9 @@ +use super::AliasId; use crate::sync::CommitId; use std::collections::HashMap; /// mapping of `CommitId` to a numeric alias -pub struct GraphOids(HashMap); +pub struct GraphOids(HashMap); impl Default for GraphOids { fn default() -> Self { @@ -17,18 +18,18 @@ impl GraphOids { } /// Get the alias for `id`, assigning a new one if it doesn't exist yet. - pub fn get_or_insert(&mut self, id: &CommitId) -> usize { + pub fn get_or_insert(&mut self, id: &CommitId) -> AliasId { if let Some(&alias) = self.0.get(id) { return alias; } - let alias = self.0.len(); + let alias = AliasId::from(self.0.len()); self.0.insert(*id, alias); alias } /// Look up the alias for `id`, returning `None` if not found. - pub fn get(&self, id: &CommitId) -> Option { + pub fn get(&self, id: &CommitId) -> Option { self.0.get(id).copied() } } diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 33c5e2eebb..2b46b41233 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -1,15 +1,13 @@ use super::buffer::Buffer; use super::chunk::{Chunk, Markers}; use super::oids::GraphOids; -use super::{ - to_lane_idx, ConnectionType, GraphRow, LaneIdx, MAX_LANE_COLORS, -}; +use super::{AliasId, ConnectionType, GraphRow, LaneIndex, MAX_LANE_COLORS}; use crate::sync::CommitId; use std::collections::{HashMap, HashSet}; /// Get the lanes color index, which cycles through the ste palette. -fn lane_color(lane: usize) -> LaneIdx { - to_lane_idx(lane % MAX_LANE_COLORS) +fn lane_color(lane: usize) -> LaneIndex { + LaneIndex::from(lane % MAX_LANE_COLORS) } use bitflags::bitflags; @@ -103,9 +101,9 @@ const fn dirs_conn(dirs: Dirs, dotted: bool) -> ConnectionType { /// The line with a vertical component is the chosen way with color /// ensuring lanes stay visually continuous fn overlay_cell( - cell: &mut Option<(ConnectionType, LaneIdx)>, + cell: &mut Option<(ConnectionType, LaneIndex)>, add: Dirs, - color: LaneIdx, + color: LaneIndex, ) { if let Some((conn, existing_color)) = cell { if let Some(existing) = conn_dirs(*conn) { @@ -135,7 +133,7 @@ pub struct GraphWalker { pub branch_lane_map: HashMap, /// Maps a merge commit's alias to the alias of its second parent. - pub merge_parents: HashMap, + pub merge_parents: HashMap, } impl Default for GraphWalker { @@ -236,7 +234,7 @@ impl GraphWalker { } fn draw_merge_bridge( - lanes: &mut [Option<(ConnectionType, LaneIdx)>], + lanes: &mut [Option<(ConnectionType, LaneIndex)>], merge_bridge: Option<(usize, usize)>, commit_lane: usize, current: &[Option], @@ -287,15 +285,15 @@ impl GraphWalker { } fn draw_branching_lanes( - lanes: &mut Vec>, + lanes: &mut Vec>, branching_lanes: &[usize], commit_lane: usize, - ) -> Vec<(LaneIdx, LaneIdx)> { + ) -> Vec<(LaneIndex, LaneIndex)> { let mut branches = Vec::new(); for &branch_lane in branching_lanes { let from = std::cmp::min(branch_lane, commit_lane); let to = std::cmp::max(branch_lane, commit_lane); - branches.push((to_lane_idx(from), to_lane_idx(to))); + branches.push((LaneIndex::from(from), LaneIndex::from(to))); if lanes.len() <= to { lanes.resize(to + 1, None); @@ -403,14 +401,14 @@ impl GraphWalker { ); GraphRow { - lane_count: to_lane_idx(current.iter().flatten().count()), - commit_lane: to_lane_idx(commit_lane), + lane_count: LaneIndex::from(current.iter().flatten().count()), + commit_lane: LaneIndex::from(commit_lane), is_merge, is_branch_tip, is_stash, lanes, merge_bridge: merge_bridge - .map(|(f, t)| (to_lane_idx(f), to_lane_idx(t))), + .map(|(f, t)| (LaneIndex::from(f), LaneIndex::from(t))), branches, } } @@ -418,9 +416,9 @@ impl GraphWalker { #[allow(clippy::too_many_arguments)] fn fill_lanes( &self, - lanes: &mut [Option<(ConnectionType, LaneIdx)>], + lanes: &mut [Option<(ConnectionType, LaneIndex)>], curr: &[Option], - alias: Option, + alias: Option, head_id: Option<&CommitId>, is_stash: bool, is_merge: bool, @@ -473,10 +471,10 @@ impl GraphWalker { /// between its two ends, merging with whatever each cell already /// shows. fn draw_bridge_span( - lanes: &mut [Option<(ConnectionType, LaneIdx)>], + lanes: &mut [Option<(ConnectionType, LaneIndex)>], from: usize, to: usize, - color: LaneIdx, + color: LaneIndex, ) { for lane in lanes.iter_mut().take(to).skip(from + 1) { overlay_cell(lane, Dirs::LEFT | Dirs::RIGHT, color); From ed26ae786045def0bc8fb6622a00a1abb2e4d8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:43:58 -0400 Subject: [PATCH 51/84] rename Dirs to Directions --- asyncgit/src/graph/walker.rs | 54 ++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 2b46b41233..e78462062e 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -17,7 +17,7 @@ bitflags! { /// are merged through this representation rather than /// a naive overwrite for readability. #[derive(Clone, Copy, Default)] - struct Dirs: u8 { + struct Directions: u8 { const UP = 0b0001; const DOWN = 0b0010; const LEFT = 0b0100; @@ -25,7 +25,7 @@ bitflags! { } } -impl Dirs { +impl Directions { #[allow(clippy::missing_const_for_fn)] fn merge(self, other: Self) -> Self { Self::from_bits_retain(self.bits() | other.bits()) @@ -39,23 +39,23 @@ impl Dirs { /// The sub network of an existing connection glyph /// `None` represents commit markers, which are never drawn over. -fn conn_dirs(conn: ConnectionType) -> Option { +fn conn_dirs(conn: ConnectionType) -> Option { Some(match conn { ConnectionType::Vertical | ConnectionType::VerticalDotted => { - Dirs::UP | Dirs::DOWN + Directions::UP | Directions::DOWN } - ConnectionType::MergeBridgeMid => Dirs::LEFT | Dirs::RIGHT, - ConnectionType::MergeBridgeStart => Dirs::DOWN | Dirs::LEFT, - ConnectionType::MergeBridgeEnd => Dirs::DOWN | Dirs::RIGHT, - ConnectionType::BranchUp => Dirs::UP | Dirs::LEFT, - ConnectionType::BranchUpRight => Dirs::UP | Dirs::RIGHT, - ConnectionType::TeeLeft => Dirs::UP | Dirs::DOWN | Dirs::LEFT, + ConnectionType::MergeBridgeMid => Directions::LEFT | Directions::RIGHT, + ConnectionType::MergeBridgeStart => Directions::DOWN | Directions::LEFT, + ConnectionType::MergeBridgeEnd => Directions::DOWN | Directions::RIGHT, + ConnectionType::BranchUp => Directions::UP | Directions::LEFT, + ConnectionType::BranchUpRight => Directions::UP | Directions::RIGHT, + ConnectionType::TeeLeft => Directions::UP | Directions::DOWN | Directions::LEFT, ConnectionType::TeeRight => { - Dirs::UP | Dirs::DOWN | Dirs::RIGHT + Directions::UP | Directions::DOWN | Directions::RIGHT } - ConnectionType::TeeUp => Dirs::UP | Dirs::LEFT | Dirs::RIGHT, + ConnectionType::TeeUp => Directions::UP | Directions::LEFT | Directions::RIGHT, ConnectionType::TeeDown => { - Dirs::DOWN | Dirs::LEFT | Dirs::RIGHT + Directions::DOWN | Directions::LEFT | Directions::RIGHT } ConnectionType::CommitNormal | ConnectionType::CommitBranch @@ -69,11 +69,11 @@ fn conn_dirs(conn: ConnectionType) -> Option { /// Vertical lines take precedence in crossed cells. /// Yet the horizontal bridge continues in /// the spacer columns either side, so we retain wholeness. -const fn dirs_conn(dirs: Dirs, dotted: bool) -> ConnectionType { - let up = dirs.contains(Dirs::UP); - let down = dirs.contains(Dirs::DOWN); - let left = dirs.contains(Dirs::LEFT); - let right = dirs.contains(Dirs::RIGHT); +const fn dirs_conn(dirs: Directions, dotted: bool) -> ConnectionType { + let up = dirs.contains(Directions::UP); + let down = dirs.contains(Directions::DOWN); + let left = dirs.contains(Directions::LEFT); + let right = dirs.contains(Directions::RIGHT); match (up, down, left, right) { (true, true, true, false) => ConnectionType::TeeLeft, (true, true, false, true) => ConnectionType::TeeRight, @@ -102,7 +102,7 @@ const fn dirs_conn(dirs: Dirs, dotted: bool) -> ConnectionType { /// ensuring lanes stay visually continuous fn overlay_cell( cell: &mut Option<(ConnectionType, LaneIndex)>, - add: Dirs, + add: Directions, color: LaneIndex, ) { if let Some((conn, existing_color)) = cell { @@ -258,15 +258,15 @@ impl GraphWalker { // target lane with the precise corner/junction lanes[target_lane] = None; let target_dirs = { - let mut d = Dirs::DOWN; + let mut d = Directions::DOWN; if continues_up { - d |= Dirs::UP; + d |= Directions::UP; } if target_lane > commit_lane { - d |= Dirs::LEFT; + d |= Directions::LEFT; } if target_lane < commit_lane { - d |= Dirs::RIGHT; + d |= Directions::RIGHT; } d }; @@ -306,12 +306,12 @@ impl GraphWalker { lane_color(branch_lane), ); let branch_dirs = { - let mut d = Dirs::UP; + let mut d = Directions::UP; if branch_lane == to { - d |= Dirs::LEFT; + d |= Directions::LEFT; } if branch_lane == from { - d |= Dirs::RIGHT; + d |= Directions::RIGHT; } d }; @@ -477,7 +477,7 @@ impl GraphWalker { color: LaneIndex, ) { for lane in lanes.iter_mut().take(to).skip(from + 1) { - overlay_cell(lane, Dirs::LEFT | Dirs::RIGHT, color); + overlay_cell(lane, Directions::LEFT | Directions::RIGHT, color); } } } From 62e6b43ea9e6b2d725c493aeeab52d9df5fb0747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:45:07 -0400 Subject: [PATCH 52/84] extract rendering helpers from walker --- asyncgit/src/graph/mod.rs | 2 +- asyncgit/src/graph/walker.rs | 552 ++++++++++++++++++++++------------- 2 files changed, 354 insertions(+), 200 deletions(-) diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index 5bc8f87cd2..185962768e 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -11,7 +11,7 @@ pub const MAX_LANE_COLORS: usize = 16; // Yes, there are repositories where this is exceeded // Are they very rare? Yes. // On most terminals can more than 256 lanes even be represneted usefully? Not really. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct LaneIndex(u8); /// Numeric alias assigned to each commit in the graph. diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index e78462062e..01fca55b4c 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -3,6 +3,7 @@ use super::chunk::{Chunk, Markers}; use super::oids::GraphOids; use super::{AliasId, ConnectionType, GraphRow, LaneIndex, MAX_LANE_COLORS}; use crate::sync::CommitId; +use core::cmp::Ordering; use std::collections::{HashMap, HashSet}; /// Get the lanes color index, which cycles through the ste palette. @@ -44,16 +45,28 @@ fn conn_dirs(conn: ConnectionType) -> Option { ConnectionType::Vertical | ConnectionType::VerticalDotted => { Directions::UP | Directions::DOWN } - ConnectionType::MergeBridgeMid => Directions::LEFT | Directions::RIGHT, - ConnectionType::MergeBridgeStart => Directions::DOWN | Directions::LEFT, - ConnectionType::MergeBridgeEnd => Directions::DOWN | Directions::RIGHT, + ConnectionType::MergeBridgeMid => { + Directions::LEFT | Directions::RIGHT + } + ConnectionType::MergeBridgeStart => { + Directions::DOWN | Directions::LEFT + } + ConnectionType::MergeBridgeEnd => { + Directions::DOWN | Directions::RIGHT + } ConnectionType::BranchUp => Directions::UP | Directions::LEFT, - ConnectionType::BranchUpRight => Directions::UP | Directions::RIGHT, - ConnectionType::TeeLeft => Directions::UP | Directions::DOWN | Directions::LEFT, + ConnectionType::BranchUpRight => { + Directions::UP | Directions::RIGHT + } + ConnectionType::TeeLeft => { + Directions::UP | Directions::DOWN | Directions::LEFT + } ConnectionType::TeeRight => { Directions::UP | Directions::DOWN | Directions::RIGHT } - ConnectionType::TeeUp => Directions::UP | Directions::LEFT | Directions::RIGHT, + ConnectionType::TeeUp => { + Directions::UP | Directions::LEFT | Directions::RIGHT + } ConnectionType::TeeDown => { Directions::DOWN | Directions::LEFT | Directions::RIGHT } @@ -152,33 +165,36 @@ impl GraphWalker { } } - pub fn process(&mut self, id: CommitId, parents: &[CommitId]) { - let alias = self.oids.get_or_insert(&id); + pub fn process( + &mut self, + commit_id: CommitId, + parents: &[CommitId], + ) { + let commit_alias = self.oids.get_or_insert(&commit_id); - let mut mapped_parents = - parents.iter().map(|p| self.oids.get_or_insert(p)); + let mut mapped_parents = parents + .iter() + .map(|parent_id| self.oids.get_or_insert(parent_id)); - // We make the executive and saddening decision to not support octo/mega merges - // TUIs are simply a backwards medium for representing this complexity + // We explicitly cap support at 2 parents, ignoring octo/mega merges. let first_parent = mapped_parents.next(); let second_parent = mapped_parents.next(); let chunk = Chunk { - alias: Some(alias), + alias: Some(commit_alias), parent_a: first_parent, parent_b: second_parent, marker: Markers::Commit, }; - second_parent.map(|b| self.merge_parents.insert(alias, b)); + if let Some(second) = second_parent { + self.merge_parents.insert(commit_alias, second); - if first_parent.is_some() - && second_parent.is_some() - && !self.buffer.current.iter().flatten().any(|commit| { - commit.parent_a == second_parent - && commit.parent_b.is_none() - }) { - self.buffer.track_merge_commit(alias); + if first_parent.is_some() + && !self.is_redundant_merge_track(second) + { + self.buffer.track_merge_commit(commit_alias); + } } self.buffer.update(&chunk); @@ -192,7 +208,7 @@ impl GraphWalker { pub fn compute_rows( &self, commit_range: &[CommitId], - global_start: usize, + global_start_index: usize, branch_tips: &HashSet, stashes: &HashSet, head_id: Option<&CommitId>, @@ -201,30 +217,36 @@ impl GraphWalker { return Vec::new(); } - // decompress one row before the range (when there is one) so - // every row's predecessor state comes from the same replay - let snap_start = global_start.saturating_sub(1); - let end = global_start + commit_range.len() - 1; - let snapshots = self.buffer.decompress(snap_start, end); - let offset = global_start - snap_start; + // Decompress one row before the range to establish predecessor state + let snapshot_start_index = + global_start_index.saturating_sub(1); + let snapshot_end_index = + global_start_index + commit_range.len() - 1; + let snapshots = self + .buffer + .decompress(snapshot_start_index, snapshot_end_index); + let index_offset = global_start_index - snapshot_start_index; commit_range .iter() .enumerate() - .map(|(index, commit_id)| { - let place = index + offset; + .map(|(range_index, commit_id)| { + let snapshot_index = range_index + index_offset; + + let current_snapshot = snapshots + .get(snapshot_index) + .map(Vec::as_slice) + .unwrap_or_default(); - let current: &[Option] = - snapshots.get(place).map_or(&[], Vec::as_slice); - let previous = place + let previous_snapshot = snapshot_index .checked_sub(1) - .and_then(|i| snapshots.get(i)) + .and_then(|index| snapshots.get(index)) .map(Vec::as_slice); self.render_row( commit_id, - current, - previous, + current_snapshot, + previous_snapshot, branch_tips, stashes, head_id, @@ -237,50 +259,48 @@ impl GraphWalker { lanes: &mut [Option<(ConnectionType, LaneIndex)>], merge_bridge: Option<(usize, usize)>, commit_lane: usize, - current: &[Option], - previous: Option<&[Option]>, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, ) { - let Some((from, to)) = merge_bridge.filter(|(f, t)| f != t) - else { + let Some((source_lane, target_lane)) = merge_bridge else { return; }; - let target_lane = if from == commit_lane { to } else { from }; - - // only draw the corner continuing upward when the target - // lane already existed on the previous row; a brand-new - // lane starts at this corner - let continues_up = current[target_lane].is_some() - && previous.is_some_and(|prev| { - prev.get(target_lane) == Some(¤t[target_lane]) - }); + if source_lane == target_lane { + return; + } - // replace the plain vertical fill_lanes drew for the - // target lane with the precise corner/junction - lanes[target_lane] = None; - let target_dirs = { - let mut d = Directions::DOWN; - if continues_up { - d |= Directions::UP; - } - if target_lane > commit_lane { - d |= Directions::LEFT; - } - if target_lane < commit_lane { - d |= Directions::RIGHT; - } - d + let destination_lane = if source_lane == commit_lane { + target_lane + } else { + source_lane }; + let connection_color = lane_color(destination_lane); + + let continues_upwards = Self::lane_continues_upwards( + destination_lane, + current_snapshot, + previous_snapshot, + ); + + let target_directions = Self::calculate_merge_directions( + commit_lane, + destination_lane, + continues_upwards, + ); + + // Replace the plain vertical fill with the precise corner/junction + lanes[destination_lane] = None; overlay_cell( - &mut lanes[target_lane], - target_dirs, - lane_color(target_lane), + &mut lanes[destination_lane], + target_directions, + connection_color, ); Self::draw_bridge_span( lanes, - from, - to, - lane_color(target_lane), + source_lane, + target_lane, + connection_color, ); } @@ -289,109 +309,188 @@ impl GraphWalker { branching_lanes: &[usize], commit_lane: usize, ) -> Vec<(LaneIndex, LaneIndex)> { - let mut branches = Vec::new(); - for &branch_lane in branching_lanes { - let from = std::cmp::min(branch_lane, commit_lane); - let to = std::cmp::max(branch_lane, commit_lane); - branches.push((LaneIndex::from(from), LaneIndex::from(to))); - - if lanes.len() <= to { - lanes.resize(to + 1, None); - } + branching_lanes + .iter() + .map(|&branch_lane| { + let start_lane = + std::cmp::min(branch_lane, commit_lane); + let end_lane = + std::cmp::max(branch_lane, commit_lane); + + Self::ensure_lane_capacity(lanes, end_lane); + + let connection_color = lane_color(branch_lane); + Self::draw_bridge_span( + lanes, + start_lane, + end_lane, + connection_color, + ); + + let branch_directions = + Self::calculate_branch_directions( + branch_lane, + start_lane, + end_lane, + ); + overlay_cell( + &mut lanes[branch_lane], + branch_directions, + connection_color, + ); + + (LaneIndex::from(start_lane), LaneIndex::from(end_lane)) + }) + .collect() + } - Self::draw_bridge_span( - lanes, - from, - to, - lane_color(branch_lane), - ); - let branch_dirs = { - let mut d = Directions::UP; - if branch_lane == to { - d |= Directions::LEFT; - } - if branch_lane == from { - d |= Directions::RIGHT; - } - d - }; - overlay_cell( - &mut lanes[branch_lane], - branch_dirs, - lane_color(branch_lane), - ); + /// Checks if tracking a merge commit would be redundant based on current buffer state. + fn is_redundant_merge_track( + &self, + target_parent: AliasId, + ) -> bool { + self.buffer.current.iter().flatten().any(|commit| { + commit.parent_a == Some(target_parent) + && commit.parent_b.is_none() + }) + } + + /// Determines if a lane should draw an upward-connecting corner. + fn lane_continues_upwards( + target_lane: usize, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, + ) -> bool { + let exists_in_current = + current_snapshot.get(target_lane).is_some(); + let matches_previous = + previous_snapshot.is_some_and(|previous| { + previous.get(target_lane) + == current_snapshot.get(target_lane) + }); + + exists_in_current && matches_previous + } + + /// Uses `Ordering` to elegantly map spatial relationships to visual bitmasks. + fn calculate_merge_directions( + commit_lane: usize, + target_lane: usize, + continues_upwards: bool, + ) -> Directions { + let mut directions = Directions::DOWN; + + if continues_upwards { + directions |= Directions::UP; + } + + match target_lane.cmp(&commit_lane) { + Ordering::Greater => directions |= Directions::LEFT, + Ordering::Less => directions |= Directions::RIGHT, + Ordering::Equal => {} + } + + directions + } + + fn calculate_branch_directions( + branch_lane: usize, + start_lane: usize, + end_lane: usize, + ) -> Directions { + let mut directions = Directions::UP; + + if branch_lane == end_lane { + directions |= Directions::LEFT; + } + if branch_lane == start_lane { + directions |= Directions::RIGHT; + } + + directions + } + + fn ensure_lane_capacity( + lanes: &mut Vec>, + required_index: usize, + ) { + if lanes.len() <= required_index { + lanes.resize(required_index + 1, None); } - branches } - fn render_row( + pub fn render_row( &self, commit_id: &CommitId, - current: &[Option], - previous: Option<&[Option]>, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, branch_tips: &HashSet, stashes: &HashSet, head_id: Option<&CommitId>, ) -> GraphRow { - let alias = self.oids.get(commit_id); - let commit_lane = current - .iter() - .position(|c| { - c.as_ref().is_some_and(|chunk| { - alias.is_some() && chunk.alias == alias - }) - }) - .unwrap_or(0); - - let parent_b_alias = - alias.and_then(|a| self.merge_parents.get(&a).copied()); - - let is_merge = parent_b_alias.is_some(); + let commit_alias = self.oids.get(commit_id); + let head_alias = head_id.and_then(|id| self.oids.get(id)); + let second_parent_alias = commit_alias.and_then(|alias| { + self.merge_parents.get(&alias).copied() + }); + + let commit_lane = + Self::find_commit_lane(current_snapshot, commit_alias); + let is_merge = second_parent_alias.is_some(); let is_branch_tip = branch_tips.contains(commit_id); let is_stash = stashes.contains(commit_id); - let branching_lanes: Vec = previous - .into_iter() - .flatten() // Unwrapping the optional, returning empty vec when None - .enumerate() - .filter(|(i, pc)| { - pc.is_some() - && current.get(*i).is_none_or(Option::is_none) - }) - .map(|(i, _)| i) - .collect(); + let branching_lanes = Self::find_branching_lanes( + current_snapshot, + previous_snapshot, + ); - let mut lanes = vec![None; current.len()]; + let merge_bridge = + second_parent_alias.and_then(|parent_alias| { + Self::calculate_merge_bridge( + current_snapshot, + commit_lane, + parent_alias, + ) + }); - let merge_bridge = if is_merge && parent_b_alias.is_some() { - current + let mut lanes: Vec> = + current_snapshot .iter() - .position(|c| { - c.as_ref().is_some_and(|chunk| { - chunk.parent_a == parent_b_alias + .enumerate() + .map(|(lane_index, chunk_option)| { + let chunk = chunk_option.as_ref()?; // Returns None early if the chunk is missing + + if commit_alias.is_some() + && chunk.alias == commit_alias + { + let connection = + Self::determine_commit_connection( + is_stash, + is_merge, + is_branch_tip, + ); + return Some(( + connection, + lane_color(lane_index), + )); + } + + Self::determine_passthrough_connection( + chunk, lane_index, head_alias, + ) + .map(|connection| { + (connection, lane_color(lane_index)) }) }) - .map(|t| (commit_lane.min(t), commit_lane.max(t))) - } else { - None - }; - - self.fill_lanes( - &mut lanes, - current, - alias, - head_id, - is_stash, - is_merge, - is_branch_tip, - ); + .collect(); Self::draw_merge_bridge( &mut lanes, merge_bridge, commit_lane, - current, - previous, + current_snapshot, + previous_snapshot, ); let branches = Self::draw_branching_lanes( @@ -400,70 +499,121 @@ impl GraphWalker { commit_lane, ); + let active_lane_count = + current_snapshot.iter().flatten().count(); + GraphRow { - lane_count: LaneIndex::from(current.iter().flatten().count()), + lane_count: LaneIndex::from(active_lane_count), commit_lane: LaneIndex::from(commit_lane), is_merge, is_branch_tip, is_stash, lanes, - merge_bridge: merge_bridge - .map(|(f, t)| (LaneIndex::from(f), LaneIndex::from(t))), + merge_bridge: merge_bridge.map(|(source, target)| { + (LaneIndex::from(source), LaneIndex::from(target)) + }), branches, } } - #[allow(clippy::too_many_arguments)] - fn fill_lanes( - &self, - lanes: &mut [Option<(ConnectionType, LaneIndex)>], - curr: &[Option], - alias: Option, - head_id: Option<&CommitId>, + /// Locates the primary lane for the current commit. + fn find_commit_lane( + current_snapshot: &[Option], + commit_alias: Option, + ) -> usize { + let Some(target_alias) = commit_alias else { + return 0; + }; + + current_snapshot + .iter() + .position(|chunk_option| { + chunk_option.as_ref().is_some_and(|chunk| { + chunk.alias == Some(target_alias) + }) + }) + .unwrap_or(0) + } + + /// Computes the span (min, max) between the commit's lane and its second parent's lane. + fn calculate_merge_bridge( + current_snapshot: &[Option], + commit_lane: usize, + second_parent_alias: AliasId, + ) -> Option<(usize, usize)> { + current_snapshot + .iter() + .position(|chunk_option| { + chunk_option.as_ref().is_some_and(|chunk| { + chunk.parent_a == Some(second_parent_alias) + }) + }) + .map(|target_lane| { + ( + commit_lane.min(target_lane), + commit_lane.max(target_lane), + ) + }) + } + + /// Identifies lanes that existed in the previous row but terminated before the current row. + fn find_branching_lanes( + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, + ) -> Vec { + let Some(previous) = previous_snapshot else { + return Vec::new(); + }; + + previous + .iter() + .enumerate() + .filter(|(index, previous_chunk)| { + previous_chunk.is_some() + && current_snapshot + .get(*index) + .is_none_or(Option::is_none) + }) + .map(|(index, _)| index) + .collect() + } + + /// Determines the correct node type for the active commit lane. + fn determine_commit_connection( is_stash: bool, is_merge: bool, is_branch_tip: bool, - ) { - for (lane_idx, chunk_item) in curr.iter().enumerate() { - let Some(chunk) = chunk_item.as_ref() else { - continue; - }; - - if alias.is_some() && chunk.alias == alias { - let conn_type = - match (is_stash, is_merge, is_branch_tip) { - (true, _, _) => ConnectionType::CommitStash, - (_, true, _) => ConnectionType::CommitMerge, - (_, _, true) => ConnectionType::CommitBranch, - _ => ConnectionType::CommitNormal, - }; - - lanes[lane_idx] = - Some((conn_type, lane_color(lane_idx))); - } else { - let target_oid = - head_id.and_then(|h| self.oids.get(h)); - - let is_dotted = lane_idx == 0 - && target_oid.is_some() - && (chunk.parent_a == target_oid - || chunk.parent_b == target_oid); - - let is_orphan = chunk.parent_a.is_none() - && chunk.parent_b.is_none(); + ) -> ConnectionType { + match (is_stash, is_merge, is_branch_tip) { + (true, _, _) => ConnectionType::CommitStash, + (_, true, _) => ConnectionType::CommitMerge, + (_, _, true) => ConnectionType::CommitBranch, + _ => ConnectionType::CommitNormal, + } + } - if is_orphan { - continue; - } + /// Determines the correct vertical line style for non-commit passthrough lanes. + fn determine_passthrough_connection( + chunk: &Chunk, + lane_index: usize, + head_alias: Option, + ) -> Option { + let is_orphan = + chunk.parent_a.is_none() && chunk.parent_b.is_none(); + + if is_orphan { + return None; + } - let conn = if is_dotted { - ConnectionType::VerticalDotted - } else { - ConnectionType::Vertical - }; + let is_dotted = lane_index == 0 + && head_alias.is_some() + && (chunk.parent_a == head_alias + || chunk.parent_b == head_alias); - lanes[lane_idx] = Some((conn, lane_color(lane_idx))); - } + if is_dotted { + Some(ConnectionType::VerticalDotted) + } else { + Some(ConnectionType::Vertical) } } @@ -477,7 +627,11 @@ impl GraphWalker { color: LaneIndex, ) { for lane in lanes.iter_mut().take(to).skip(from + 1) { - overlay_cell(lane, Directions::LEFT | Directions::RIGHT, color); + overlay_cell( + lane, + Directions::LEFT | Directions::RIGHT, + color, + ); } } } From 963fa125f4951750de3af82c4da9b29fffc8d5df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:50:21 -0400 Subject: [PATCH 53/84] clippy fixes --- asyncgit/src/graph/mod.rs | 2 +- asyncgit/src/graph/walker.rs | 11 ++++++++--- src/components/commitlist.rs | 9 ++++++--- src/main.rs | 1 - 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index 185962768e..a69e89c2a7 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -42,7 +42,7 @@ impl From for LaneIndex { impl From for usize { fn from(lane: LaneIndex) -> Self { - lane.0 as usize + lane.0 as Self } } diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 01fca55b4c..d64774b1af 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -1,7 +1,9 @@ use super::buffer::Buffer; use super::chunk::{Chunk, Markers}; use super::oids::GraphOids; -use super::{AliasId, ConnectionType, GraphRow, LaneIndex, MAX_LANE_COLORS}; +use super::{ + AliasId, ConnectionType, GraphRow, LaneIndex, MAX_LANE_COLORS, +}; use crate::sync::CommitId; use core::cmp::Ordering; use std::collections::{HashMap, HashSet}; @@ -339,7 +341,10 @@ impl GraphWalker { connection_color, ); - (LaneIndex::from(start_lane), LaneIndex::from(end_lane)) + ( + LaneIndex::from(start_lane), + LaneIndex::from(end_lane), + ) }) .collect() } @@ -579,7 +584,7 @@ impl GraphWalker { } /// Determines the correct node type for the active commit lane. - fn determine_commit_connection( + const fn determine_commit_connection( is_stash: bool, is_merge: bool, is_branch_tip: bool, diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 5d84400c7f..2b524567cf 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1354,12 +1354,15 @@ mod tests { #[test] fn test_build_graph_spans() { let row = GraphRow { - lane_count: 1, - commit_lane: 0, + lane_count: 1.into(), + commit_lane: 0.into(), is_merge: false, is_branch_tip: false, is_stash: false, - lanes: vec![Some((ConnectionType::CommitNormal, 0))], + lanes: vec![Some(( + ConnectionType::CommitNormal, + 0.into(), + ))], merge_bridge: None, branches: vec![], }; diff --git a/src/main.rs b/src/main.rs index cb5fc69be6..db8b1dbf18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,6 @@ #![forbid(unsafe_code)] #![deny( - mismatched_lifetime_syntaxes, unused_imports, unused_must_use, dead_code, From b5aa433615ab78f45bb37ff95e8aacd1c6f7e072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 9 Jul 2026 13:27:17 -0400 Subject: [PATCH 54/84] rename AliasId to CommitAlias, add UnwalkedAlias --- asyncgit/src/graph/mod.rs | 18 +++++++++++++++--- asyncgit/src/graph/oids.rs | 19 +++++++------------ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index a69e89c2a7..36519fe818 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -19,16 +19,28 @@ pub struct LaneIndex(u8); /// The alias is a dense integer index created by [`GraphOids`](super::oids::GraphOids) /// that avoids storing full [`CommitId`](crate::sync::CommitId)s inside the lane state. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub struct AliasId(usize); +pub struct CommitAlias(usize); -impl std::ops::Deref for AliasId { +/// An alias for a commit that had not yet been walked when the value +/// was minted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UnwalkedAlias(CommitAlias); + +impl UnwalkedAlias { + /// The underlying alias, for comparisons against walked commits. + pub const fn get(self) -> CommitAlias { + self.0 + } +} + +impl std::ops::Deref for CommitAlias { type Target = usize; fn deref(&self) -> &usize { &self.0 } } -impl From for AliasId { +impl From for CommitAlias { fn from(v: usize) -> Self { Self(v) } diff --git a/asyncgit/src/graph/oids.rs b/asyncgit/src/graph/oids.rs index 38827c24c9..0ee8591532 100644 --- a/asyncgit/src/graph/oids.rs +++ b/asyncgit/src/graph/oids.rs @@ -1,35 +1,30 @@ -use super::AliasId; +use super::CommitAlias; use crate::sync::CommitId; use std::collections::HashMap; /// mapping of `CommitId` to a numeric alias -pub struct GraphOids(HashMap); - -impl Default for GraphOids { - fn default() -> Self { - Self::new() - } -} +#[derive(Default)] +pub struct GraphOids(HashMap); impl GraphOids { /// Create an empty alias map. pub fn new() -> Self { - Self(HashMap::new()) + Self::default() } /// Get the alias for `id`, assigning a new one if it doesn't exist yet. - pub fn get_or_insert(&mut self, id: &CommitId) -> AliasId { + pub fn get_or_insert(&mut self, id: &CommitId) -> CommitAlias { if let Some(&alias) = self.0.get(id) { return alias; } - let alias = AliasId::from(self.0.len()); + let alias = CommitAlias::from(self.0.len()); self.0.insert(*id, alias); alias } /// Look up the alias for `id`, returning `None` if not found. - pub fn get(&self, id: &CommitId) -> Option { + pub fn get(&self, id: &CommitId) -> Option { self.0.get(id).copied() } } From 0fe165d8e08c3c79d133347d5f000aa0d56e4a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 9 Jul 2026 13:27:21 -0400 Subject: [PATCH 55/84] replace Chunk/Markers with LaneSlot enum --- asyncgit/src/graph/chunk.rs | 66 +++++++++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index 20748330a9..e186047b60 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -1,15 +1,61 @@ -use super::AliasId; +use super::{CommitAlias, UnwalkedAlias}; +/// A lane's occupant at one point in the walk. #[derive(Clone, Debug, PartialEq, Eq)] -pub enum Markers { - Uncommitted, - Commit, +pub enum LaneSlot { + /// A walked commit flowing down to its first parent. + Flowing { + alias: CommitAlias, + parent: UnwalkedAlias, + }, + + /// A flowing lane that still owes a bridge to a second parent + FlowingMerge { + alias: CommitAlias, + parent: UnwalkedAlias, + second: UnwalkedAlias, + }, + + /// A root commit: draws its node, nothing continues below. + Settled { alias: CommitAlias }, + + /// Reserved for a merge's second parent that hasn't been reached + /// by the walk yet. Once that parent is processed, the + /// reservation is replaced by the parent's own slot. + Reserved { parent: UnwalkedAlias }, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Chunk { - pub alias: Option, - pub parent_a: Option, - pub parent_b: Option, - pub marker: Markers, +impl LaneSlot { + /// The commit this lane currently belongs to, `None` for a + /// reserved placeholder. + pub const fn alias(&self) -> Option { + match self { + Self::Flowing { alias, .. } + | Self::FlowingMerge { alias, .. } + | Self::Settled { alias } => Some(*alias), + Self::Reserved { .. } => None, + } + } + + /// The parent whose placement this lane waits on; the lane stays + /// open (drawing a vertical line) until that commit is walked. + pub const fn awaits(&self) -> Option { + match self { + Self::Flowing { parent, .. } + | Self::FlowingMerge { parent, .. } + | Self::Reserved { parent } => Some(parent.get()), + Self::Settled { .. } => None, + } + } + + /// The pending second parent of a merge that hasn't been given + /// its own lane. + pub const fn second(&self) -> Option { + match self { + Self::FlowingMerge { second, .. } => Some(second.get()), + Self::Flowing { .. } + | Self::Settled { .. } + | Self::Reserved { .. } => None, + } + } } From 5362647d8b3d564cc93653967dea38256b049986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 9 Jul 2026 13:27:25 -0400 Subject: [PATCH 56/84] refactor buffer for LaneSlot --- asyncgit/src/graph/buffer.rs | 163 +++++++++++++++++++++-------------- 1 file changed, 99 insertions(+), 64 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index 402ea24996..0278802b65 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -1,14 +1,22 @@ -use super::chunk::{Chunk, Markers}; -use super::AliasId; +use super::chunk::LaneSlot; +use super::CommitAlias; use std::collections::BTreeMap; /// A single mutation of the lane state, recorded while processing one /// commit. #[derive(Clone, Debug)] pub enum DeltaOp { - Insert { index: usize, item: Option }, - Remove { index: usize }, - Replace { index: usize, new: Option }, + Insert { + index: usize, + item: Option, + }, + Remove { + index: usize, + }, + Replace { + index: usize, + new: Option, + }, } /// All lane-state mutations caused by processing a single commit. @@ -22,25 +30,25 @@ const CHECKPOINT_INTERVAL: usize = 100; /// Delta-compressed history of the graph's lane state. /// /// While walking the log top-down, every commit mutates the set of -/// active lanes (a `Vec>`, one slot per lane). So, storign a +/// active lanes (a `Vec>`, one slot per lane). So, storign a /// full copy of that state for each commit is a waste. This /// buffer preserves ONLY the latest state PLUS the list of [`Delta`]s that /// produced it. /// Use [`Buffer::decompress`] to get the complete version. pub struct Buffer { /// Lane state after the most recently processed commit. - pub current: Vec>, + pub current: Vec>, /// One [`Delta`] per processed commit, in the order of the walk. pub deltas: Vec, /// Full lane-state snapshots taken every `CHECKPOINT_INTERVAL` /// commits, keyed by delta index, for reducing decompression cost. - pub checkpoints: BTreeMap>>, + pub checkpoints: BTreeMap>>, /// Aliases of merge commits whose second parent still needs a new /// lane. - merge_commits: Vec, + merge_commits: Vec, /// Scratch list of the [`DeltaOp`]s recorded for processing commit pending_delta: Vec, @@ -65,16 +73,16 @@ impl Buffer { /// Remember `alias` as a merge commit whose second parent must be /// given its own lane. - pub fn track_merge_commit(&mut self, alias: AliasId) { + pub fn track_merge_commit(&mut self, alias: CommitAlias) { self.merge_commits.push(alias); } - pub fn update(&mut self, new_chunk: &Chunk) { + pub fn update(&mut self, new_chunk: &LaneSlot) { // Phase 1: place the new chunk into the lane array. let placement_index = self.place_chunk(new_chunk); // Phase 2: consume the alias in all other live chunks. - if let Some(alias) = new_chunk.alias { + if let Some(alias) = new_chunk.alias() { self.consume_alias_in_other_chunks( alias, placement_index, @@ -88,10 +96,11 @@ impl Buffer { self.commit_delta(); } - fn place_chunk(&mut self, new_chunk: &Chunk) -> usize { - // Prefer a lane whose current occupant is waiting for this chunk as parent_a. + fn place_chunk(&mut self, new_chunk: &LaneSlot) -> usize { + // Prefer a lane whose current occupant awaits this chunk's + // commit as its primary parent. let target = self - .find_lane_awaiting_parent(new_chunk.alias) + .find_lane_awaiting_parent(new_chunk.alias()) .or_else(|| self.first_empty_lane()) .unwrap_or(self.current.len()); @@ -105,12 +114,14 @@ impl Buffer { fn find_lane_awaiting_parent( &self, - alias: Option, + alias: Option, ) -> Option { - let alias = alias?; - self.current.iter().position(|slot| { - slot.as_ref() - .is_some_and(|chunk| chunk.parent_a == Some(alias)) + alias.and_then(|alias| { + self.current.iter().position(|slot| { + slot.as_ref().is_some_and(|chunk| { + chunk.awaits() == Some(alias) + }) + }) }) } @@ -120,43 +131,52 @@ impl Buffer { fn consume_alias_in_other_chunks( &mut self, - alias: AliasId, + alias: CommitAlias, skip_index: usize, ) { - for index in 0..self.current.len() { - let mut chunk = match self.current[index].clone() { + let current = self.current.clone(); + for (index, slot) in current.into_iter().enumerate() { + let chunk = match slot { Some(chunk) if index != skip_index => chunk, _ => continue, }; - let changed_a = chunk.parent_a == Some(alias); - let changed_b = chunk.parent_b == Some(alias); - - if changed_a || changed_b { - if changed_a { - chunk.parent_a = None; - } - if changed_b { - chunk.parent_b = None; + let new = match chunk { + // The awaited parent was JUST placed. Close the lane. + // The pending second parent is dropped with it. + LaneSlot::Flowing { parent, .. } + | LaneSlot::FlowingMerge { parent, .. } + | LaneSlot::Reserved { parent } + if parent.get() == alias => + { + None } + // The second parent was just placed + // bridge resolves and the lane flows + LaneSlot::FlowingMerge { + alias: merge_alias, + parent, + second, + } if second.get() == alias => Some(LaneSlot::Flowing { + alias: merge_alias, + parent, + }), + _ => continue, + }; - self.record_replace( - index, - chunk.parent_a.is_some().then_some(chunk), - ); - } + self.record_replace(index, new); } } fn flush_merge_commits(&mut self) { while let Some(alias) = self.merge_commits.pop() { // Search for an occupied slot that matches the target alias. - // If found, extract its index and a mutable clone of the chunk. - let Some((index, mut chunk)) = + // If found, extract its index and a clone of the chunk. + let Some((index, chunk)) = self.current.iter().enumerate().find_map( |(index, slot)| { let chunk = slot.as_ref()?; - (chunk.alias == Some(alias)) + (chunk.alias() == Some(alias)) .then(|| (index, chunk.clone())) }, ) @@ -164,26 +184,33 @@ impl Buffer { continue; }; - let detached_parent = chunk.parent_b.take(); - self.record_replace(index, Some(chunk)); - - if let Some(parent) = detached_parent { - let new_lane = Chunk { - alias: None, - parent_a: Some(parent), - parent_b: None, - marker: Markers::Commit, - }; - - // Always append the merge's second-parent lane to - // the end instead of reusing an existing empty slot, - // so the new visual column does not collapse - // spatial ordering of lanes already in existence. - self.record_insert( - self.current.len(), - Some(new_lane), - ); - } + // Only a merge still owing its second parent needs a + // lane split off; anything else is a no-op. + let LaneSlot::FlowingMerge { + alias: merge_alias, + parent, + second, + } = chunk + else { + continue; + }; + + self.record_replace( + index, + Some(LaneSlot::Flowing { + alias: merge_alias, + parent, + }), + ); + + // Always append the merge's second-parent lane to + // the end instead of reusing an existing empty slot, + // so the new visual column does not collapse + // spatial ordering of lanes already in existence. + self.record_insert( + self.current.len(), + Some(LaneSlot::Reserved { parent: second }), + ); } } @@ -202,7 +229,11 @@ impl Buffer { } } - fn record_replace(&mut self, index: usize, new: Option) { + fn record_replace( + &mut self, + index: usize, + new: Option, + ) { self.pending_delta.push(DeltaOp::Replace { index, new: new.clone(), @@ -210,7 +241,11 @@ impl Buffer { self.current[index] = new; } - fn record_insert(&mut self, index: usize, item: Option) { + fn record_insert( + &mut self, + index: usize, + item: Option, + ) { self.pending_delta.push(DeltaOp::Insert { index, item: item.clone(), @@ -227,7 +262,7 @@ impl Buffer { &self, start: usize, end: usize, - ) -> Vec>> { + ) -> Vec>> { let (current_index, mut state) = self.checkpoints.range(..=start).next_back().map_or_else( || (None, Vec::new()), @@ -261,7 +296,7 @@ impl Buffer { } fn apply_delta_to_state( - state: &mut Vec>, + state: &mut Vec>, delta: &Delta, ) { for op in &delta.0 { From a2afc2dad9edfffb75a1f9df27814779eff67eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 9 Jul 2026 13:27:29 -0400 Subject: [PATCH 57/84] refactor walker for LaneSlot, add processed set --- asyncgit/src/graph/walker.rs | 158 ++++++++++++++++++++++++----------- 1 file changed, 111 insertions(+), 47 deletions(-) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index d64774b1af..e611c2c59e 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -1,8 +1,9 @@ use super::buffer::Buffer; -use super::chunk::{Chunk, Markers}; +use super::chunk::LaneSlot; use super::oids::GraphOids; use super::{ - AliasId, ConnectionType, GraphRow, LaneIndex, MAX_LANE_COLORS, + CommitAlias, ConnectionType, GraphRow, LaneIndex, UnwalkedAlias, + MAX_LANE_COLORS, }; use crate::sync::CommitId; use core::cmp::Ordering; @@ -145,10 +146,14 @@ fn overlay_cell( pub struct GraphWalker { pub buffer: Buffer, pub oids: GraphOids, - pub branch_lane_map: HashMap, /// Maps a merge commit's alias to the alias of its second parent. - pub merge_parents: HashMap, + pub merge_parents: HashMap, + + /// Aliases of commits already folded into the buffer; consulted + /// by [`Self::drawable_parent`], which refuses to mint an + /// [`UnwalkedAlias`] for any of them. + processed: HashSet, } impl Default for GraphWalker { @@ -162,11 +167,22 @@ impl GraphWalker { Self { buffer: Buffer::new(), oids: GraphOids::new(), - branch_lane_map: HashMap::new(), merge_parents: HashMap::new(), + processed: HashSet::new(), } } + /// Mint the drawable alias for a parent commit, or `None` if the + /// walk already passed it. + fn drawable_parent( + &mut self, + id: &CommitId, + ) -> Option { + let alias = self.oids.get_or_insert(id); + (!self.processed.contains(&alias)) + .then_some(UnwalkedAlias(alias)) + } + pub fn process( &mut self, commit_id: CommitId, @@ -174,31 +190,42 @@ impl GraphWalker { ) { let commit_alias = self.oids.get_or_insert(&commit_id); - let mut mapped_parents = parents + let mut drawable_parents = parents .iter() - .map(|parent_id| self.oids.get_or_insert(parent_id)); + .filter_map(|parent_id| self.drawable_parent(parent_id)); // We explicitly cap support at 2 parents, ignoring octo/mega merges. - let first_parent = mapped_parents.next(); - let second_parent = mapped_parents.next(); - - let chunk = Chunk { - alias: Some(commit_alias), - parent_a: first_parent, - parent_b: second_parent, - marker: Markers::Commit, + let first_parent = drawable_parents.next(); + let second_parent = drawable_parents.next(); + + let chunk = match (first_parent, second_parent) { + (Some(parent), Some(second)) => LaneSlot::FlowingMerge { + alias: commit_alias, + parent, + second, + }, + (Some(parent), None) => LaneSlot::Flowing { + alias: commit_alias, + parent, + }, + // `second_parent` is only ever `Some` once + // `first_parent` has already been consumed from the + // identical iterator, so a merge always has both parents. + (None, _) => LaneSlot::Settled { + alias: commit_alias, + }, }; - if let Some(second) = second_parent { + if let LaneSlot::FlowingMerge { second, .. } = &chunk { + let second = second.get(); self.merge_parents.insert(commit_alias, second); - if first_parent.is_some() - && !self.is_redundant_merge_track(second) - { + if !self.is_redundant_merge_track(second) { self.buffer.track_merge_commit(commit_alias); } } + self.processed.insert(commit_alias); self.buffer.update(&chunk); } @@ -261,8 +288,8 @@ impl GraphWalker { lanes: &mut [Option<(ConnectionType, LaneIndex)>], merge_bridge: Option<(usize, usize)>, commit_lane: usize, - current_snapshot: &[Option], - previous_snapshot: Option<&[Option]>, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, ) { let Some((source_lane, target_lane)) = merge_bridge else { return; @@ -349,22 +376,28 @@ impl GraphWalker { .collect() } - /// Checks if tracking a merge commit would be redundant based on current buffer state. + /// Checks if tracking a merge commit would be redundant based on + /// current buffer state: some lane already flows to the target + /// parent without owing a second parent of its own. fn is_redundant_merge_track( &self, - target_parent: AliasId, + target_parent: CommitAlias, ) -> bool { - self.buffer.current.iter().flatten().any(|commit| { - commit.parent_a == Some(target_parent) - && commit.parent_b.is_none() + self.buffer.current.iter().flatten().any(|slot| { + matches!( + slot, + LaneSlot::Flowing { parent, .. } + | LaneSlot::Reserved { parent } + if parent.get() == target_parent + ) }) } /// Determines if a lane should draw an upward-connecting corner. fn lane_continues_upwards( target_lane: usize, - current_snapshot: &[Option], - previous_snapshot: Option<&[Option]>, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, ) -> bool { let exists_in_current = current_snapshot.get(target_lane).is_some(); @@ -427,8 +460,8 @@ impl GraphWalker { pub fn render_row( &self, commit_id: &CommitId, - current_snapshot: &[Option], - previous_snapshot: Option<&[Option]>, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, branch_tips: &HashSet, stashes: &HashSet, head_id: Option<&CommitId>, @@ -467,7 +500,7 @@ impl GraphWalker { let chunk = chunk_option.as_ref()?; // Returns None early if the chunk is missing if commit_alias.is_some() - && chunk.alias == commit_alias + && chunk.alias() == commit_alias { let connection = Self::determine_commit_connection( @@ -523,8 +556,8 @@ impl GraphWalker { /// Locates the primary lane for the current commit. fn find_commit_lane( - current_snapshot: &[Option], - commit_alias: Option, + current_snapshot: &[Option], + commit_alias: Option, ) -> usize { let Some(target_alias) = commit_alias else { return 0; @@ -534,7 +567,7 @@ impl GraphWalker { .iter() .position(|chunk_option| { chunk_option.as_ref().is_some_and(|chunk| { - chunk.alias == Some(target_alias) + chunk.alias() == Some(target_alias) }) }) .unwrap_or(0) @@ -542,15 +575,15 @@ impl GraphWalker { /// Computes the span (min, max) between the commit's lane and its second parent's lane. fn calculate_merge_bridge( - current_snapshot: &[Option], + current_snapshot: &[Option], commit_lane: usize, - second_parent_alias: AliasId, + second_parent_alias: CommitAlias, ) -> Option<(usize, usize)> { current_snapshot .iter() .position(|chunk_option| { chunk_option.as_ref().is_some_and(|chunk| { - chunk.parent_a == Some(second_parent_alias) + chunk.awaits() == Some(second_parent_alias) }) }) .map(|target_lane| { @@ -563,8 +596,8 @@ impl GraphWalker { /// Identifies lanes that existed in the previous row but terminated before the current row. fn find_branching_lanes( - current_snapshot: &[Option], - previous_snapshot: Option<&[Option]>, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, ) -> Vec { let Some(previous) = previous_snapshot else { return Vec::new(); @@ -599,21 +632,19 @@ impl GraphWalker { /// Determines the correct vertical line style for non-commit passthrough lanes. fn determine_passthrough_connection( - chunk: &Chunk, + chunk: &LaneSlot, lane_index: usize, - head_alias: Option, + head_alias: Option, ) -> Option { - let is_orphan = - chunk.parent_a.is_none() && chunk.parent_b.is_none(); - - if is_orphan { + // A settled lane draws nothing below its commit. + if matches!(chunk, LaneSlot::Settled { .. }) { return None; } let is_dotted = lane_index == 0 && head_alias.is_some() - && (chunk.parent_a == head_alias - || chunk.parent_b == head_alias); + && (chunk.awaits() == head_alias + || chunk.second() == head_alias); if is_dotted { Some(ConnectionType::VerticalDotted) @@ -814,6 +845,39 @@ mod tests { ); } + #[test] + fn skewed_parent_before_child_leaves_no_phantom_lane() { + //1 to 2 must be dropped instead of + // opening a lane that waits for 2 until the end of the walk. + let rows = render(&[(2, &[3]), (1, &[2, 3]), (3, &[])]); + assert_eq!(rows, vec!["o", "┃ o", "o━┛"]); + } + + #[test] + fn complete_walk_settles_all_lanes() { + // Both parents of 1 appear before it in the walk (clock + // skew). After a complete walk no lane may still wait on a + // parent, creating the evil phantom lines + let history: &[(usize, &[usize])] = + &[(2, &[4]), (3, &[4]), (1, &[2, 3]), (4, &[])]; + + assert_eq!(render(history), vec!["o", "┃ o", "┃ ┃ o", "o━┛"]); + + let mut walker = GraphWalker::new(); + for (commit, parents) in history { + let parents: Vec = + parents.iter().map(|p| id(*p)).collect(); + walker.process(id(*commit), &parents); + } + + for slot in walker.buffer.current.iter().flatten() { + assert!( + matches!(slot, LaneSlot::Settled { .. }), + "lane still waiting on a parent after a complete walk: {slot:?}" + ); + } + } + #[test] fn crossed_lane_keeps_own_color() { let rows = &[ From 977247ebc029bc584d4e5fe485cd68b71ba1fd36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 9 Jul 2026 13:27:32 -0400 Subject: [PATCH 58/84] add graphrow symbol tests --- src/components/utils/graphrow.rs | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/components/utils/graphrow.rs b/src/components/utils/graphrow.rs index e45bae89a7..89cb6bb27a 100644 --- a/src/components/utils/graphrow.rs +++ b/src/components/utils/graphrow.rs @@ -16,3 +16,40 @@ pub const SYM_TEE_RIGHT: &str = "┣"; pub const SYM_TEE_UP: &str = "┻"; pub const SYM_TEE_DOWN: &str = "┳"; pub const SYM_SPACE: &str = " "; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn symbols_are_single_char_and_distinct() { + let symbols = [ + SYM_COMMIT, + SYM_COMMIT_BRANCH, + SYM_COMMIT_MERGE, + SYM_COMMIT_STASH, + SYM_COMMIT_UNCOMMITTED, + SYM_VERTICAL, + SYM_VERTICAL_DOTTED, + SYM_HORIZONTAL, + SYM_MERGE_BRIDGE_START, + SYM_MERGE_BRIDGE_MID, + SYM_MERGE_BRIDGE_END, + SYM_BRANCH_UP, + SYM_BRANCH_UP_RIGHT, + SYM_TEE_LEFT, + SYM_TEE_RIGHT, + SYM_TEE_UP, + SYM_TEE_DOWN, + SYM_SPACE, + ]; + + for symbol in symbols { + assert_eq!( + symbol.chars().count(), + 1, + "{symbol:?} should be a single glyph" + ); + } + } +} From e10e60d115e601f211b0842e123353f8ef6587e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 9 Jul 2026 13:44:53 -0400 Subject: [PATCH 59/84] parents on demand --- asyncgit/src/revlog.rs | 55 +++++++++++++++++++++++++--------- asyncgit/src/sync/logwalker.rs | 48 +++++++---------------------- asyncgit/src/sync/mod.rs | 4 +-- 3 files changed, 54 insertions(+), 53 deletions(-) diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index a70c39fd45..ce3a851f71 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -3,7 +3,7 @@ use crate::{ graph::{GraphRow, GraphWalker}, sync::{ gix_repo, repo, CommitId, LogWalker, LogWalkerWithoutFilter, - RepoPath, SharedCommitFilterFn, WalkEntry, + RepoPath, SharedCommitFilterFn, }, AsyncGitNotification, Error, }; @@ -47,9 +47,10 @@ pub struct AsyncLog { filter: Option, partial_extract: AtomicBool, repo: RepoPath, - /// All walk entries collected by the background thread, in walk order. - /// The graph walker reads these lazily, only as far as the viewport requires. - walk_entries: Arc>>, + /// All commit ids collected by the background thread, in walk order. + /// The graph walker reads these lazily, only as far as the viewport + /// requires, looking up each commit's parents on demand. + walk_entries: Arc>>, graph_walker: Arc>, } @@ -83,8 +84,8 @@ impl AsyncLog { /// Computes graph rows for `commit_slice` starting at `global_start`. /// - /// Driven lazily. Processes only as many - /// [`WalkEntry`]s as the viewport requires. + /// Driven lazily. Processes only as many walked commits as the + /// viewport requires, resolving their parents on demand. /// /// Returns `None` when the background walk hasn't reached /// `global_start + commit_slice.len()` yet. @@ -111,8 +112,18 @@ impl AsyncLog { // we know it is yet to have seen let processed = walker.processed_commits().min(needed_end); - for entry in &entries[processed..needed_end] { - walker.process(entry.id, &entry.parents); + + // The graph only needs topology for the commits it is + // about to fold in, so parents are looked up here on + // demand instead of being carried along the whole walk. + if processed < needed_end { + let mut repo = gix_repo(&self.repo).ok()?; + repo.object_cache_size_if_unset(2_usize.pow(14)); + + for id in &entries[processed..needed_end] { + let parents = Self::parents_of(&repo, *id).ok()?; + walker.process(*id, &parents); + } } } @@ -125,6 +136,22 @@ impl AsyncLog { )) } + /// Looks up a commit's (up to two) parents on demand. + /// + /// The graph caps support at two parents, ignoring octopus + /// merges, so anything beyond the first two is dropped here. + fn parents_of( + repo: &gix::Repository, + id: CommitId, + ) -> Result> { + Ok(repo + .find_commit(id)? + .parent_ids() + .take(2) + .map(Into::into) + .collect()) + } + /// pub fn count(&self) -> Result { Ok(self.current.lock()?.commits.len()) @@ -252,7 +279,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, - arc_walk_entries: &Arc>>, + arc_walk_entries: &Arc>>, filter: Option, ) -> Result<()> { filter.map_or_else( @@ -309,7 +336,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, - arc_walk_entries: &Arc>>, + arc_walk_entries: &Arc>>, ) -> Result<()> { let mut repo: gix::Repository = gix_repo(repo_path)?; let mut walker = @@ -332,15 +359,15 @@ impl AsyncLog { /// to `arc_current` and (when given) moving the full entries into /// `walk_entries` for the graph. fn walk_loop( - mut read: impl FnMut(&mut Vec) -> Result, + mut read: impl FnMut(&mut Vec) -> Result, arc_current: &Arc>, arc_background: &Arc, sender: &Sender, - walk_entries: Option<&Mutex>>, + walk_entries: Option<&Mutex>>, ) -> Result<()> { let start_time = Instant::now(); - let mut entries: Vec = + let mut entries: Vec = Vec::with_capacity(LIMIT_COUNT); loop { @@ -348,7 +375,7 @@ impl AsyncLog { { let mut current = arc_current.lock()?; - current.commits.extend(entries.iter().map(|e| e.id)); + current.commits.extend(entries.iter().copied()); current.duration = start_time.elapsed(); } diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index 29843271f6..c450022948 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -2,25 +2,11 @@ use super::{CommitId, SharedCommitFilterFn}; use crate::error::Result; use git2::{Commit, Oid, Repository}; use gix::revision::Walk; -use smallvec::SmallVec; use std::{ cmp::Ordering, collections::{BinaryHeap, HashSet}, }; -/// A commit id together with the ids of its TWO parents. -/// -/// The parents come for free during a walk. -/// Collecting here avoid a second, convulted pass. -#[derive(Debug, Clone)] -pub struct WalkEntry { - /// The commit's own unique identifier. - pub id: CommitId, - - /// The commit's parent identifiers. - pub parents: SmallVec<[CommitId; 2]>, -} - struct TimeOrderedCommit<'a>(Commit<'a>); impl Eq for TimeOrderedCommit<'_> {} @@ -86,16 +72,12 @@ impl<'a> LogWalker<'a> { /// pub fn read( &mut self, - out: &mut Vec, + out: &mut Vec, ) -> Result { let mut count = 0_usize; while let Some(c) = self.commits.pop() { - let mut parents = SmallVec::new(); for p in c.0.parents() { - if parents.len() < 2 { - parents.push(p.id().into()); - } self.visit(p); } @@ -108,7 +90,7 @@ impl<'a> LogWalker<'a> { }; if commit_should_be_included { - out.push(WalkEntry { id, parents }); + out.push(id); } count += 1; @@ -180,20 +162,12 @@ impl<'a> LogWalkerWithoutFilter<'a> { /// pub fn read( &mut self, - out: &mut Vec, + out: &mut Vec, ) -> Result { let mut count = 0_usize; while let Some(Ok(info)) = self.walk.next() { - out.push(WalkEntry { - id: info.id.into(), - parents: info - .parent_ids - .iter() - .take(2) - .map(|id| CommitId::from(*id)) - .collect(), - }); + out.push(info.id.into()); count += 1; @@ -246,7 +220,7 @@ mod tests { walk.read(&mut items).unwrap(); assert_eq!(items.len(), 1); - assert_eq!(items[0].id, oid2); + assert_eq!(items[0], oid2); Ok(()) } @@ -270,12 +244,12 @@ mod tests { let mut walk = LogWalker::new(&repo, 100)?; walk.read(&mut items).unwrap(); - let ids: Vec = items.iter().map(|e| e.id).collect(); + let ids: Vec = items.clone(); let info = get_commits_info(repo_path, &ids, 50).unwrap(); dbg!(&info); assert_eq!(items.len(), 2); - assert_eq!(items[0].id, oid2); + assert_eq!(items[0], oid2); let mut items = Vec::new(); walk.read(&mut items).unwrap(); @@ -305,12 +279,12 @@ mod tests { let mut items = Vec::new(); assert!(matches!(walk.read(&mut items), Ok(2))); - let ids: Vec = items.iter().map(|e| e.id).collect(); + let ids: Vec = items.clone(); let info = get_commits_info(repo_path, &ids, 50).unwrap(); dbg!(&info); assert_eq!(items.len(), 2); - assert_eq!(items[0].id, oid2); + assert_eq!(items[0], oid2); let mut items = Vec::new(); assert!(matches!(walk.read(&mut items), Ok(0))); @@ -352,7 +326,7 @@ mod tests { walker.read(&mut items).unwrap(); assert_eq!(items.len(), 1); - assert_eq!(items[0].id, second_commit_id); + assert_eq!(items[0], second_commit_id); let mut items = Vec::new(); walker.read(&mut items).unwrap(); @@ -399,7 +373,7 @@ mod tests { walker.read(&mut items).unwrap(); assert_eq!(items.len(), 1); - assert_eq!(items[0].id, second_commit_id); + assert_eq!(items[0], second_commit_id); let log_filter = filter_commit_by_search( LogFilterSearch::new(LogFilterSearchOptions { diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2e76a2128e..1b0c60f8f3 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -72,7 +72,7 @@ pub use hooks::{ }; pub use hunks::{reset_hunk, stage_hunk, unstage_hunk}; pub use ignore::add_to_ignore; -pub use logwalker::{LogWalker, LogWalkerWithoutFilter, WalkEntry}; +pub use logwalker::{LogWalker, LogWalkerWithoutFilter}; pub use merge::{ abort_pending_rebase, abort_pending_state, continue_pending_rebase, merge_branch, merge_commit, merge_msg, @@ -267,7 +267,7 @@ pub mod tests { .read(&mut entries) .unwrap(); - entries.iter().map(|e| e.id).collect() + entries } /// Same as `repo_init`, but the repo is a bare repo (--bare) From 85b85adc5bba7f3efb8cd1f854f6c7c2051217de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 28 Jul 2026 17:11:51 -0700 Subject: [PATCH 60/84] fixes for review comments --- asyncgit/src/graph/buffer.rs | 30 +----- asyncgit/src/graph/chunk.rs | 10 ++ asyncgit/src/graph/mod.rs | 14 +++ asyncgit/src/graph/walker.rs | 179 +++++++++++++++++---------------- asyncgit/src/revlog.rs | 5 +- asyncgit/src/sync/logwalker.rs | 10 +- 6 files changed, 129 insertions(+), 119 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index 0278802b65..52a8bdf1c9 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -35,6 +35,7 @@ const CHECKPOINT_INTERVAL: usize = 100; /// buffer preserves ONLY the latest state PLUS the list of [`Delta`]s that /// produced it. /// Use [`Buffer::decompress`] to get the complete version. +#[derive(Default)] pub struct Buffer { /// Lane state after the most recently processed commit. pub current: Vec>, @@ -54,23 +55,7 @@ pub struct Buffer { pending_delta: Vec, } -impl Default for Buffer { - fn default() -> Self { - Self::new() - } -} - impl Buffer { - pub const fn new() -> Self { - Self { - current: Vec::new(), - deltas: Vec::new(), - checkpoints: BTreeMap::new(), - merge_commits: Vec::new(), - pending_delta: Vec::new(), - } - } - /// Remember `alias` as a merge commit whose second parent must be /// given its own lane. pub fn track_merge_commit(&mut self, alias: CommitAlias) { @@ -78,10 +63,8 @@ impl Buffer { } pub fn update(&mut self, new_chunk: &LaneSlot) { - // Phase 1: place the new chunk into the lane array. let placement_index = self.place_chunk(new_chunk); - // Phase 2: consume the alias in all other live chunks. if let Some(alias) = new_chunk.alias() { self.consume_alias_in_other_chunks( alias, @@ -89,10 +72,7 @@ impl Buffer { ); } - // Phase 3: flush any pending merge commits into new lanes. self.flush_merge_commits(); - - // Phase 4: commit the delta and maybe checkpoint. self.commit_delta(); } @@ -141,13 +121,13 @@ impl Buffer { _ => continue, }; - let new = match chunk { + let new_chunk = match chunk { // The awaited parent was JUST placed. Close the lane. // The pending second parent is dropped with it. LaneSlot::Flowing { parent, .. } | LaneSlot::FlowingMerge { parent, .. } | LaneSlot::Reserved { parent } - if parent.get() == alias => + if *parent == alias => { None } @@ -157,14 +137,14 @@ impl Buffer { alias: merge_alias, parent, second, - } if second.get() == alias => Some(LaneSlot::Flowing { + } if *second == alias => Some(LaneSlot::Flowing { alias: merge_alias, parent, }), _ => continue, }; - self.record_replace(index, new); + self.record_replace(index, new_chunk); } } diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index e186047b60..1799005981 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -58,4 +58,14 @@ impl LaneSlot { | Self::Reserved { .. } => None, } } + + /// Whether this lane's [`Self::awaits`] alias is `alias` — the + /// parent of a `Flowing`/`FlowingMerge` lane, or the reservation + /// held by a `Reserved` lane. + pub fn waits_for_second_parent( + &self, + alias: CommitAlias, + ) -> bool { + self.awaits() == Some(alias) + } } diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index 36519fe818..3c8f5546b7 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -33,6 +33,13 @@ impl UnwalkedAlias { } } +impl std::ops::Deref for UnwalkedAlias { + type Target = CommitAlias; + fn deref(&self) -> &CommitAlias { + &self.0 + } +} + impl std::ops::Deref for CommitAlias { type Target = usize; fn deref(&self) -> &usize { @@ -88,6 +95,13 @@ pub enum ConnectionType { TeeDown, } +impl ConnectionType { + /// Whether this connection is a dotted vertical line. + pub const fn is_dotted(self) -> bool { + matches!(self, Self::VerticalDotted) + } +} + #[derive(Clone, Debug, Default)] pub struct GraphRow { /// Number of active lanes at this commit row diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index e611c2c59e..103f0e1d65 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -9,7 +9,7 @@ use crate::sync::CommitId; use core::cmp::Ordering; use std::collections::{HashMap, HashSet}; -/// Get the lanes color index, which cycles through the ste palette. +/// Get the lane's color index, which cycles through the color palette. fn lane_color(lane: usize) -> LaneIndex { LaneIndex::from(lane % MAX_LANE_COLORS) } @@ -122,9 +122,11 @@ fn overlay_cell( color: LaneIndex, ) { if let Some((conn, existing_color)) = cell { + // `conn_dirs` returns `None` for commit markers, which must + // never be drawn over; when that happens this branch is + // skipped and `cell` is left untouched. if let Some(existing) = conn_dirs(*conn) { - let is_dotted = - matches!(conn, ConnectionType::VerticalDotted); + let is_dotted = conn.is_dotted(); let resolved_color = if existing.vertical() || !add.vertical() { @@ -143,6 +145,7 @@ fn overlay_cell( } } +#[derive(Default)] pub struct GraphWalker { pub buffer: Buffer, pub oids: GraphOids, @@ -151,30 +154,19 @@ pub struct GraphWalker { pub merge_parents: HashMap, /// Aliases of commits already folded into the buffer; consulted - /// by [`Self::drawable_parent`], which refuses to mint an + /// by [`Self::mint_drawable_parent`], which refuses to mint an /// [`UnwalkedAlias`] for any of them. processed: HashSet, } -impl Default for GraphWalker { - fn default() -> Self { - Self::new() - } -} - impl GraphWalker { pub fn new() -> Self { - Self { - buffer: Buffer::new(), - oids: GraphOids::new(), - merge_parents: HashMap::new(), - processed: HashSet::new(), - } + Self::default() } /// Mint the drawable alias for a parent commit, or `None` if the /// walk already passed it. - fn drawable_parent( + fn mint_drawable_parent( &mut self, id: &CommitId, ) -> Option { @@ -190,9 +182,10 @@ impl GraphWalker { ) { let commit_alias = self.oids.get_or_insert(&commit_id); - let mut drawable_parents = parents - .iter() - .filter_map(|parent_id| self.drawable_parent(parent_id)); + let mut drawable_parents = + parents.iter().filter_map(|parent_id| { + self.mint_drawable_parent(parent_id) + }); // We explicitly cap support at 2 parents, ignoring octo/mega merges. let first_parent = drawable_parents.next(); @@ -208,19 +201,21 @@ impl GraphWalker { alias: commit_alias, parent, }, - // `second_parent` is only ever `Some` once - // `first_parent` has already been consumed from the - // identical iterator, so a merge always has both parents. - (None, _) => LaneSlot::Settled { + (None, None) => LaneSlot::Settled { alias: commit_alias, }, + (None, Some(_)) => unreachable!( + "second_parent is only ever Some once first_parent \ + has already been consumed from the identical \ + iterator, so a merge always has both parents" + ), }; if let LaneSlot::FlowingMerge { second, .. } = &chunk { let second = second.get(); self.merge_parents.insert(commit_alias, second); - if !self.is_redundant_merge_track(second) { + if !self.has_lane_to_parent(second) { self.buffer.track_merge_commit(commit_alias); } } @@ -230,7 +225,7 @@ impl GraphWalker { } /// Number of commits already folded into the graph buffer. - pub const fn processed_commits(&self) -> usize { + pub const fn processed_commit_count(&self) -> usize { self.buffer.deltas.len() } @@ -246,7 +241,15 @@ impl GraphWalker { return Vec::new(); } - // Decompress one row before the range to establish predecessor state + // Decompress one row before the range to establish predecessor + // state. `snapshot_start_index` is usually `global_start_index + // - 1`, which also makes `snapshot_end_index` equal to + // `snapshot_start_index + commit_range.len()`. But at the very + // start of the walk `saturating_sub` clamps to `0` instead of + // going negative, since there is no predecessor row to fetch + // there; `index_offset` below (`0` in that case, `1` + // otherwise) accounts for the difference between the two + // cases when indexing into `snapshots`. let snapshot_start_index = global_start_index.saturating_sub(1); let snapshot_end_index = @@ -294,6 +297,13 @@ impl GraphWalker { let Some((source_lane, target_lane)) = merge_bridge else { return; }; + // A commit that lists the same parent twice (git allows + // duplicate parent entries, however rare in practice) makes + // its own lane await its second parent's alias too, so the + // search in `calculate_merge_bridge` can land back on + // `commit_lane` itself. There is no separate lane to bridge + // to in that case, so skip drawing rather than laying a + // zero-length span. if source_lane == target_lane { return; } @@ -376,19 +386,17 @@ impl GraphWalker { .collect() } - /// Checks if tracking a merge commit would be redundant based on - /// current buffer state: some lane already flows to the target - /// parent without owing a second parent of its own. - fn is_redundant_merge_track( - &self, - target_parent: CommitAlias, - ) -> bool { + /// Whether some lane already flows or is reserved for + /// `target_parent` without owing a second parent of its own, + /// making it redundant to track a new merge bridge to that + /// parent. + fn has_lane_to_parent(&self, target_parent: CommitAlias) -> bool { self.buffer.current.iter().flatten().any(|slot| { matches!( slot, LaneSlot::Flowing { parent, .. } | LaneSlot::Reserved { parent } - if parent.get() == target_parent + if **parent == target_parent ) }) } @@ -399,15 +407,13 @@ impl GraphWalker { current_snapshot: &[Option], previous_snapshot: Option<&[Option]>, ) -> bool { - let exists_in_current = - current_snapshot.get(target_lane).is_some(); - let matches_previous = - previous_snapshot.is_some_and(|previous| { - previous.get(target_lane) - == current_snapshot.get(target_lane) - }); + let Some(previous_snapshot) = previous_snapshot else { + return false; + }; - exists_in_current && matches_previous + let current = current_snapshot.get(target_lane); + current.is_some() + && previous_snapshot.get(target_lane) == current } /// Uses `Ordering` to elegantly map spatial relationships to visual bitmasks. @@ -485,11 +491,13 @@ impl GraphWalker { let merge_bridge = second_parent_alias.and_then(|parent_alias| { - Self::calculate_merge_bridge( - current_snapshot, - commit_lane, - parent_alias, - ) + commit_lane.and_then(|cl| { + Self::calculate_merge_bridge( + current_snapshot, + cl, + parent_alias, + ) + }) }); let mut lanes: Vec> = @@ -497,7 +505,7 @@ impl GraphWalker { .iter() .enumerate() .map(|(lane_index, chunk_option)| { - let chunk = chunk_option.as_ref()?; // Returns None early if the chunk is missing + let chunk = chunk_option.as_ref()?; if commit_alias.is_some() && chunk.alias() == commit_alias @@ -515,7 +523,7 @@ impl GraphWalker { } Self::determine_passthrough_connection( - chunk, lane_index, head_alias, + chunk, head_alias, ) .map(|connection| { (connection, lane_color(lane_index)) @@ -523,26 +531,30 @@ impl GraphWalker { }) .collect(); - Self::draw_merge_bridge( - &mut lanes, - merge_bridge, - commit_lane, - current_snapshot, - previous_snapshot, - ); + let branches = commit_lane.map_or_else(Vec::new, |cl| { + Self::draw_merge_bridge( + &mut lanes, + merge_bridge, + cl, + current_snapshot, + previous_snapshot, + ); - let branches = Self::draw_branching_lanes( - &mut lanes, - &branching_lanes, - commit_lane, - ); + Self::draw_branching_lanes( + &mut lanes, + &branching_lanes, + cl, + ) + }); let active_lane_count = current_snapshot.iter().flatten().count(); GraphRow { - lane_count: LaneIndex::from(active_lane_count), - commit_lane: LaneIndex::from(commit_lane), + lane_count: active_lane_count.into(), + commit_lane: LaneIndex::from( + commit_lane.unwrap_or(0), + ), is_merge, is_branch_tip, is_stash, @@ -555,22 +567,19 @@ impl GraphWalker { } /// Locates the primary lane for the current commit. + /// + /// Returns `None` if `commit_alias` is `None` or no matching lane is found. fn find_commit_lane( current_snapshot: &[Option], commit_alias: Option, - ) -> usize { - let Some(target_alias) = commit_alias else { - return 0; - }; + ) -> Option { + let target_alias = commit_alias?; - current_snapshot - .iter() - .position(|chunk_option| { - chunk_option.as_ref().is_some_and(|chunk| { - chunk.alias() == Some(target_alias) - }) + current_snapshot.iter().position(|slot| { + slot.as_ref().is_some_and(|chunk| { + chunk.alias() == Some(target_alias) }) - .unwrap_or(0) + }) } /// Computes the span (min, max) between the commit's lane and its second parent's lane. @@ -583,7 +592,7 @@ impl GraphWalker { .iter() .position(|chunk_option| { chunk_option.as_ref().is_some_and(|chunk| { - chunk.awaits() == Some(second_parent_alias) + chunk.waits_for_second_parent(second_parent_alias) }) }) .map(|target_lane| { @@ -606,13 +615,13 @@ impl GraphWalker { previous .iter() .enumerate() - .filter(|(index, previous_chunk)| { - previous_chunk.is_some() + .filter_map(|(index, previous_chunk)| { + (previous_chunk.is_some() && current_snapshot - .get(*index) - .is_none_or(Option::is_none) + .get(index) + .is_none_or(Option::is_none)) + .then_some(index) }) - .map(|(index, _)| index) .collect() } @@ -633,7 +642,6 @@ impl GraphWalker { /// Determines the correct vertical line style for non-commit passthrough lanes. fn determine_passthrough_connection( chunk: &LaneSlot, - lane_index: usize, head_alias: Option, ) -> Option { // A settled lane draws nothing below its commit. @@ -641,8 +649,11 @@ impl GraphWalker { return None; } - let is_dotted = lane_index == 0 - && head_alias.is_some() + // Dot the segment of any lane, not just lane 0, that is + // heading toward HEAD: HEAD is not guaranteed to sit on the + // leftmost lane, e.g. once earlier lanes have closed and a + // later one has taken their place. + let is_dotted = head_alias.is_some() && (chunk.awaits() == head_alias || chunk.second() == head_alias); diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index ce3a851f71..82e861a795 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -111,7 +111,7 @@ impl AsyncLog { // (you know, scrolling up), so only feed it entries // we know it is yet to have seen let processed = - walker.processed_commits().min(needed_end); + walker.processed_commit_count().min(needed_end); // The graph only needs topology for the commits it is // about to fold in, so parents are looked up here on @@ -121,7 +121,8 @@ impl AsyncLog { repo.object_cache_size_if_unset(2_usize.pow(14)); for id in &entries[processed..needed_end] { - let parents = Self::parents_of(&repo, *id).ok()?; + let parents = + Self::parents_of(&repo, *id).ok()?; walker.process(*id, &parents); } } diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index c450022948..feb6d0999d 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -70,10 +70,7 @@ impl<'a> LogWalker<'a> { } /// - pub fn read( - &mut self, - out: &mut Vec, - ) -> Result { + pub fn read(&mut self, out: &mut Vec) -> Result { let mut count = 0_usize; while let Some(c) = self.commits.pop() { @@ -160,10 +157,7 @@ impl<'a> LogWalkerWithoutFilter<'a> { } /// - pub fn read( - &mut self, - out: &mut Vec, - ) -> Result { + pub fn read(&mut self, out: &mut Vec) -> Result { let mut count = 0_usize; while let Some(Ok(info)) = self.walk.next() { From 7ac40776f8dc769616ea55848cb3ffe82affbb75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Wed, 29 Jul 2026 23:54:59 -0700 Subject: [PATCH 61/84] new tests! --- asyncgit/src/graph/walker.rs | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 103f0e1d65..fc4559de88 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -765,6 +765,15 @@ mod tests { assert_eq!(rows, vec!["o", "o", "o"]); } + #[test] + fn history_with_multiple_roots() { + // Two independent commit chains, each terminating in its own + // root (a commit with no parents), STAY IN THEIR LANE! + let rows = + render(&[(1, &[2]), (2, &[]), (3, &[4]), (4, &[])]); + assert_eq!(rows, vec!["o", "o", " o", " o"]); + } + #[test] fn simple_merge() { // 1 merges 3 into the line 1 → 2 → 4, 3 → 4 @@ -923,4 +932,44 @@ mod tests { assert_eq!(crossed.0, ConnectionType::Vertical); assert_eq!(crossed.1, lane_color(1)); } + + #[test] + fn branch_bridge_crosses_unrelated_lane_keeps_own_color() { + // 5 is the fork point for both 1 and 3 + let history: &[(usize, &[usize])] = &[ + (1, &[5]), + (2, &[6]), + (3, &[5]), + (5, &[8]), + (6, &[8]), + (8, &[]), + ]; + let mut walker = GraphWalker::new(); + let ids: Vec = + history.iter().map(|(c, _)| id(*c)).collect(); + for (commit, parents) in history { + let parents: Vec = + parents.iter().map(|p| id(*p)).collect(); + walker.process(id(*commit), &parents); + } + let computed = walker.compute_rows( + &ids, + 0, + &HashSet::new(), + &HashSet::new(), + None, + ); + + assert_eq!( + computed.iter().map(row_to_string).collect::>(), + vec!["o", "┃ o", "┃ ┃ o", "o━┃━┛", "┃ o", "o━┛"] + ); + + // row of commit 5: lane 1 is crossed by the branch bridge but + // keeps both its vertical glyph and its own lane color (visual identity mans) + let crossed = computed[3].lanes[1] + .expect("crossed lane should not be empty"); + assert_eq!(crossed.0, ConnectionType::Vertical); + assert_eq!(crossed.1, lane_color(1)); + } } From efeb09c3dd66ea9b39997d5d9d3d39dd49145bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Wed, 29 Jul 2026 23:55:06 -0700 Subject: [PATCH 62/84] cleanup --- Cargo.lock | 668 ----------------------------------- asyncgit/Cargo.toml | 2 - asyncgit/src/graph/buffer.rs | 4 +- asyncgit/src/graph/walker.rs | 6 +- src/gitui.rs | 2 +- src/main.rs | 1 + 6 files changed, 6 insertions(+), 677 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44c2ebc66b..6aac533022 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,41 +17,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -169,7 +134,6 @@ dependencies = [ "git2", "git2-hooks", "gix", - "im", "invalidstring", "log", "openssl-sys", @@ -180,7 +144,6 @@ dependencies = [ "serde", "serial_test", "smallvec", - "ssh-key", "tempfile", "thiserror 2.0.19", "unicode-truncate", @@ -217,35 +180,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bcrypt-pbkdf" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" -dependencies = [ - "blowfish", - "pbkdf2", - "sha2 0.10.9", -] - [[package]] name = "bincode" version = "1.3.3" @@ -300,15 +240,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitmaps" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" -dependencies = [ - "typenum", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -327,25 +258,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - -[[package]] -name = "blowfish" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" -dependencies = [ - "byteorder", - "cipher", -] - [[package]] name = "bstr" version = "1.13.0" @@ -422,15 +334,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.4.0" @@ -455,17 +358,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - [[package]] name = "chrono" version = "0.4.45" @@ -477,16 +369,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", -] - [[package]] name = "clap" version = "4.6.4" @@ -555,12 +437,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "convert_case" version = "0.10.0" @@ -682,18 +558,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -723,41 +587,6 @@ dependencies = [ "phf", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "darling" version = "0.23.0" @@ -843,16 +672,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.8" @@ -894,9 +713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", "crypto-common 0.1.7", - "subtle", ] [[package]] @@ -962,66 +779,12 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59e68c9e2b6f969aa527564744c81d1ad24da08a36422a268ec6b61002a7eecd" -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "sha2 0.10.9", - "subtle", -] - [[package]] name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "pkcs8", - "rand_core", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "encode_unicode" version = "1.0.0" @@ -1128,22 +891,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "filedescriptor" version = "0.8.3" @@ -1283,7 +1030,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -1330,16 +1076,6 @@ dependencies = [ "regex", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "gimli" version = "0.32.3" @@ -2243,17 +1979,6 @@ dependencies = [ "parking_lot", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core", - "subtle", -] - [[package]] name = "hash32" version = "0.3.1" @@ -2313,15 +2038,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - [[package]] name = "hybrid-array" version = "0.4.13" @@ -2464,20 +2180,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "im" -version = "15.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" -dependencies = [ - "bitmaps", - "rand_core", - "rand_xoshiro", - "sized-chunks", - "typenum", - "version_check", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -2517,16 +2219,6 @@ dependencies = [ "libc", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - [[package]] name = "insta" version = "1.48.0" @@ -2704,9 +2396,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] [[package]] name = "libc" @@ -2982,22 +2671,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand", - "smallvec", - "zeroize", -] - [[package]] name = "num-conv" version = "0.2.2" @@ -3015,25 +2688,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3041,7 +2695,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -3102,12 +2755,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl-probe" version = "0.1.6" @@ -3151,44 +2798,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - -[[package]] -name = "p521" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" -dependencies = [ - "base16ct", - "ecdsa", - "elliptic-curve", - "primeorder", - "rand_core", - "sha2 0.10.9", -] - [[package]] name = "palette" version = "0.7.6" @@ -3236,24 +2845,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3360,27 +2951,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.33" @@ -3400,29 +2970,6 @@ dependencies = [ "time", ] -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.14.0" @@ -3453,15 +3000,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "pretty_assertions" version = "1.4.1" @@ -3472,15 +3010,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -3535,17 +3064,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", "rand_core", ] @@ -3554,18 +3072,6 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_xoshiro" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" -dependencies = [ - "rand_core", -] [[package]] name = "ratatui" @@ -3750,16 +3256,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - [[package]] name = "ron" version = "0.12.2" @@ -3774,27 +3270,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core", - "sha2 0.10.9", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rustc-demangle" version = "0.1.28" @@ -3870,20 +3345,6 @@ dependencies = [ "log", ] -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "semver" version = "1.0.28" @@ -4059,16 +3520,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -4097,16 +3548,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" -[[package]] -name = "sized-chunks" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" -dependencies = [ - "bitmaps", - "typenum", -] - [[package]] name = "slab" version = "0.4.12" @@ -4119,73 +3560,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "ssh-cipher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" -dependencies = [ - "aes", - "aes-gcm", - "cbc", - "chacha20", - "cipher", - "ctr", - "poly1305", - "ssh-encoding", - "subtle", -] - -[[package]] -name = "ssh-encoding" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" -dependencies = [ - "base64ct", - "pem-rfc7468", - "sha2 0.10.9", -] - -[[package]] -name = "ssh-key" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" -dependencies = [ - "bcrypt-pbkdf", - "ed25519-dalek", - "num-bigint-dig", - "p256", - "p384", - "p521", - "rand_core", - "rsa", - "sec1", - "sha2 0.10.9", - "signature", - "ssh-cipher", - "ssh-encoding", - "subtle", - "zeroize", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4254,12 +3628,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "1.0.109" @@ -4634,16 +4002,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - [[package]] name = "url" version = "2.5.8" @@ -5204,26 +4562,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -5245,12 +4583,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.4" diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index d3b1331c8a..a0d14f69f9 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -20,7 +20,6 @@ fuzzy-matcher = "0.3" git2 = { version = "0.21", features = ["https", "ssh", "unstable-sha256"] } git2-hooks = { path = "../git2-hooks", version = "0.7" } gix = { version = "0.84.0", default-features = false, features = ["mailmap", "max-performance", "revision", "sha1", "sha256", "status"] } -im = "15" log = "0.4" # git2 = { path = "../../extern/git2-rs", features = ["vendored-openssl"]} # git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]} @@ -31,7 +30,6 @@ rayon-core = "1.13" scopetime = { path = "../scopetime", version = "0.1" } serde = { version = "1.0", features = ["derive"] } smallvec = "1" -ssh-key = { version = "0.6.7", features = ["crypto", "encryption"] } tempfile = "3" thiserror = "2.0" unicode-truncate = "2.0" diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index 52a8bdf1c9..d370675287 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -49,10 +49,10 @@ pub struct Buffer { /// Aliases of merge commits whose second parent still needs a new /// lane. - merge_commits: Vec, + pub merge_commits: Vec, /// Scratch list of the [`DeltaOp`]s recorded for processing commit - pending_delta: Vec, + pub pending_delta: Vec, } impl Buffer { diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index fc4559de88..a7fce07745 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -156,7 +156,7 @@ pub struct GraphWalker { /// Aliases of commits already folded into the buffer; consulted /// by [`Self::mint_drawable_parent`], which refuses to mint an /// [`UnwalkedAlias`] for any of them. - processed: HashSet, + pub processed: HashSet, } impl GraphWalker { @@ -552,9 +552,7 @@ impl GraphWalker { GraphRow { lane_count: active_lane_count.into(), - commit_lane: LaneIndex::from( - commit_lane.unwrap_or(0), - ), + commit_lane: LaneIndex::from(commit_lane.unwrap_or(0)), is_merge, is_branch_tip, is_stash, diff --git a/src/gitui.rs b/src/gitui.rs index 8052218ac2..c001633060 100644 --- a/src/gitui.rs +++ b/src/gitui.rs @@ -226,7 +226,7 @@ mod tests { // Linux Temp Folder settings.add_filter(r" */tmp/\.tmp\S+-insta/", "[TEMP_FILE]"); // Commit ids that follow a vertical bar - settings.add_filter(r"│[^a-f0-9]*[a-f0-9]{7} ", "│[AAAAA] "); + settings.add_filter(r"│[a-f0-9]{7} ", "│[AAAAA] "); let _bound = settings.bind_to_scope(); } } diff --git a/src/main.rs b/src/main.rs index db8b1dbf18..cb5fc69be6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,7 @@ #![forbid(unsafe_code)] #![deny( + mismatched_lifetime_syntaxes, unused_imports, unused_must_use, dead_code, From 98c21a50874b677f89001efb67b9313eac088043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 30 Jul 2026 09:35:59 -0700 Subject: [PATCH 63/84] rewordin --- asyncgit/src/graph/walker.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index a7fce07745..4f75c87fe2 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -41,9 +41,9 @@ impl Directions { } } -/// The sub network of an existing connection glyph -/// `None` represents commit markers, which are never drawn over. -fn conn_dirs(conn: ConnectionType) -> Option { +/// Extract direction components from a connection glyph. +/// Returns `None` for commit markers, which are never drawn over. +fn connection_to_directions(conn: ConnectionType) -> Option { Some(match conn { ConnectionType::Vertical | ConnectionType::VerticalDotted => { Directions::UP | Directions::DOWN @@ -81,11 +81,11 @@ fn conn_dirs(conn: ConnectionType) -> Option { }) } -/// Determine the glyph for a cell's connectivity. +/// Synthesize a connection glyph from direction components. /// Vertical lines take precedence in crossed cells. /// Yet the horizontal bridge continues in /// the spacer columns either side, so we retain wholeness. -const fn dirs_conn(dirs: Directions, dotted: bool) -> ConnectionType { +const fn directions_to_connection(dirs: Directions, dotted: bool) -> ConnectionType { let up = dirs.contains(Directions::UP); let down = dirs.contains(Directions::DOWN); let left = dirs.contains(Directions::LEFT); @@ -103,7 +103,7 @@ const fn dirs_conn(dirs: Directions, dotted: bool) -> ConnectionType { (false, true, false, true) => ConnectionType::MergeBridgeEnd, (false, false, _, _) => ConnectionType::MergeBridgeMid, (true, true, _, _) - | (true | false, false | true, false, false) => { + | (_, false | true, false, false) => { if dotted { ConnectionType::VerticalDotted } else { @@ -122,10 +122,10 @@ fn overlay_cell( color: LaneIndex, ) { if let Some((conn, existing_color)) = cell { - // `conn_dirs` returns `None` for commit markers, which must + // `connection_to_directions` returns `None` for commit markers, which must // never be drawn over; when that happens this branch is // skipped and `cell` is left untouched. - if let Some(existing) = conn_dirs(*conn) { + if let Some(existing) = connection_to_directions(*conn) { let is_dotted = conn.is_dotted(); let resolved_color = @@ -136,12 +136,12 @@ fn overlay_cell( }; *cell = Some(( - dirs_conn(existing.merge(add), is_dotted), + directions_to_connection(existing.merge(add), is_dotted), resolved_color, )); } } else { - *cell = Some((dirs_conn(add, false), color)); + *cell = Some((directions_to_connection(add, false), color)); } } @@ -212,7 +212,7 @@ impl GraphWalker { }; if let LaneSlot::FlowingMerge { second, .. } = &chunk { - let second = second.get(); + let second = **second; self.merge_parents.insert(commit_alias, second); if !self.has_lane_to_parent(second) { From e94694db5fd7648734137946184b118bd0d6c0b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 30 Jul 2026 09:36:14 -0700 Subject: [PATCH 64/84] deref we lost const :( --- asyncgit/src/graph/chunk.rs | 8 ++++---- asyncgit/src/graph/mod.rs | 7 ------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index 1799005981..1fc7dcf455 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -39,20 +39,20 @@ impl LaneSlot { /// The parent whose placement this lane waits on; the lane stays /// open (drawing a vertical line) until that commit is walked. - pub const fn awaits(&self) -> Option { + pub fn awaits(&self) -> Option { match self { Self::Flowing { parent, .. } | Self::FlowingMerge { parent, .. } - | Self::Reserved { parent } => Some(parent.get()), + | Self::Reserved { parent } => Some(**parent), Self::Settled { .. } => None, } } /// The pending second parent of a merge that hasn't been given /// its own lane. - pub const fn second(&self) -> Option { + pub fn second(&self) -> Option { match self { - Self::FlowingMerge { second, .. } => Some(second.get()), + Self::FlowingMerge { second, .. } => Some(**second), Self::Flowing { .. } | Self::Settled { .. } | Self::Reserved { .. } => None, diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index 3c8f5546b7..0d6b319ed4 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -26,13 +26,6 @@ pub struct CommitAlias(usize); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct UnwalkedAlias(CommitAlias); -impl UnwalkedAlias { - /// The underlying alias, for comparisons against walked commits. - pub const fn get(self) -> CommitAlias { - self.0 - } -} - impl std::ops::Deref for UnwalkedAlias { type Target = CommitAlias; fn deref(&self) -> &CommitAlias { From 4906348139a3834958f5166cfd3f6972588d9693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 30 Jul 2026 10:14:39 -0700 Subject: [PATCH 65/84] fixed regex for real still mostly strict... --- src/gitui.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gitui.rs b/src/gitui.rs index c001633060..5a67e0ee4a 100644 --- a/src/gitui.rs +++ b/src/gitui.rs @@ -225,8 +225,8 @@ mod tests { settings.add_filter(r" *\[…\]\S+-insta/?", "[TEMP_FILE]"); // Linux Temp Folder settings.add_filter(r" */tmp/\.tmp\S+-insta/", "[TEMP_FILE]"); - // Commit ids that follow a vertical bar - settings.add_filter(r"│[a-f0-9]{7} ", "│[AAAAA] "); + // Commit ids that follow a vertical bar (may be preceded by graph glyphs) + settings.add_filter(r"│[^<]{0,10}[a-f0-9]{7} ", "│[AAAAA] "); let _bound = settings.bind_to_scope(); } } From d385b01779b2249de1f70dd82bac24273f781716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sat, 22 Aug 2026 21:59:06 -0700 Subject: [PATCH 66/84] fmt --- asyncgit/src/graph/walker.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 4f75c87fe2..5f84d2b3a5 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -43,7 +43,9 @@ impl Directions { /// Extract direction components from a connection glyph. /// Returns `None` for commit markers, which are never drawn over. -fn connection_to_directions(conn: ConnectionType) -> Option { +fn connection_to_directions( + conn: ConnectionType, +) -> Option { Some(match conn { ConnectionType::Vertical | ConnectionType::VerticalDotted => { Directions::UP | Directions::DOWN @@ -85,7 +87,10 @@ fn connection_to_directions(conn: ConnectionType) -> Option { /// Vertical lines take precedence in crossed cells. /// Yet the horizontal bridge continues in /// the spacer columns either side, so we retain wholeness. -const fn directions_to_connection(dirs: Directions, dotted: bool) -> ConnectionType { +const fn directions_to_connection( + dirs: Directions, + dotted: bool, +) -> ConnectionType { let up = dirs.contains(Directions::UP); let down = dirs.contains(Directions::DOWN); let left = dirs.contains(Directions::LEFT); @@ -102,8 +107,7 @@ const fn directions_to_connection(dirs: Directions, dotted: bool) -> ConnectionT } (false, true, false, true) => ConnectionType::MergeBridgeEnd, (false, false, _, _) => ConnectionType::MergeBridgeMid, - (true, true, _, _) - | (_, false | true, false, false) => { + (true, true, _, _) | (_, false | true, false, false) => { if dotted { ConnectionType::VerticalDotted } else { @@ -136,7 +140,10 @@ fn overlay_cell( }; *cell = Some(( - directions_to_connection(existing.merge(add), is_dotted), + directions_to_connection( + existing.merge(add), + is_dotted, + ), resolved_color, )); } From 0374bccb841ce9562abc5029a2f79fd62e8ba610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 27 Sep 2026 03:42:37 +0000 Subject: [PATCH 67/84] index graph aliases with vectors --- asyncgit/src/graph/walker.rs | 41 +++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 5f84d2b3a5..f348da9322 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -7,7 +7,7 @@ use super::{ }; use crate::sync::CommitId; use core::cmp::Ordering; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; /// Get the lane's color index, which cycles through the color palette. fn lane_color(lane: usize) -> LaneIndex { @@ -157,13 +157,15 @@ pub struct GraphWalker { pub buffer: Buffer, pub oids: GraphOids, - /// Maps a merge commit's alias to the alias of its second parent. - pub merge_parents: HashMap, + /// For each merge commit's alias, the alias of its second parent. + /// Indexed directly by alias: aliases are dense `0..n`, so no + /// hashing is needed. + pub merge_parents: Vec>, - /// Aliases of commits already folded into the buffer; consulted - /// by [`Self::mint_drawable_parent`], which refuses to mint an - /// [`UnwalkedAlias`] for any of them. - pub processed: HashSet, + /// Whether an alias has already been folded into the buffer; + /// consulted by [`Self::mint_drawable_parent`], which refuses to + /// mint an [`UnwalkedAlias`] for any of them. Indexed by alias. + pub processed: Vec, } impl GraphWalker { @@ -178,8 +180,19 @@ impl GraphWalker { id: &CommitId, ) -> Option { let alias = self.oids.get_or_insert(id); - (!self.processed.contains(&alias)) - .then_some(UnwalkedAlias(alias)) + (!self.is_processed(alias)).then_some(UnwalkedAlias(alias)) + } + + fn is_processed(&self, alias: CommitAlias) -> bool { + self.processed.get(*alias).copied().unwrap_or(false) + } + + fn mark_processed(&mut self, alias: CommitAlias) { + let index = *alias; + if self.processed.len() <= index { + self.processed.resize(index + 1, false); + } + self.processed[index] = true; } pub fn process( @@ -220,14 +233,18 @@ impl GraphWalker { if let LaneSlot::FlowingMerge { second, .. } = &chunk { let second = **second; - self.merge_parents.insert(commit_alias, second); + let index = *commit_alias; + if self.merge_parents.len() <= index { + self.merge_parents.resize(index + 1, None); + } + self.merge_parents[index] = Some(second); if !self.has_lane_to_parent(second) { self.buffer.track_merge_commit(commit_alias); } } - self.processed.insert(commit_alias); + self.mark_processed(commit_alias); self.buffer.update(&chunk); } @@ -482,7 +499,7 @@ impl GraphWalker { let commit_alias = self.oids.get(commit_id); let head_alias = head_id.and_then(|id| self.oids.get(id)); let second_parent_alias = commit_alias.and_then(|alias| { - self.merge_parents.get(&alias).copied() + self.merge_parents.get(*alias).copied().flatten() }); let commit_lane = From f92d723948247e1cc22f654ffcc4acbbad3409c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 27 Sep 2026 03:42:37 +0000 Subject: [PATCH 68/84] stop cloning lanes every commit --- asyncgit/src/graph/buffer.rs | 65 +++++++++++++++++------------------- asyncgit/src/graph/chunk.rs | 2 +- 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index d370675287..567e83c912 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -85,9 +85,9 @@ impl Buffer { .unwrap_or(self.current.len()); if target < self.current.len() { - self.record_replace(target, Some(new_chunk.clone())); + self.record_replace(target, Some(*new_chunk)); } else { - self.record_insert(target, Some(new_chunk.clone())); + self.record_insert(target, Some(*new_chunk)); } target } @@ -114,33 +114,36 @@ impl Buffer { alias: CommitAlias, skip_index: usize, ) { - let current = self.current.clone(); - for (index, slot) in current.into_iter().enumerate() { - let chunk = match slot { - Some(chunk) if index != skip_index => chunk, - _ => continue, - }; + for index in 0..self.current.len() { + if index == skip_index { + continue; + } - let new_chunk = match chunk { - // The awaited parent was JUST placed. Close the lane. - // The pending second parent is dropped with it. - LaneSlot::Flowing { parent, .. } - | LaneSlot::FlowingMerge { parent, .. } - | LaneSlot::Reserved { parent } - if *parent == alias => - { + // `LaneSlot` is `Copy`; matching by value lets us read + // the slot out before `record_replace` mutates `current`. + let new_chunk = match self.current[index] { + Some( + LaneSlot::Flowing { parent, .. } + | LaneSlot::FlowingMerge { parent, .. } + | LaneSlot::Reserved { parent }, + ) if *parent == alias => { + // The awaited parent was JUST placed. Close the + // lane. The pending second parent is dropped + // with it. None } - // The second parent was just placed - // bridge resolves and the lane flows - LaneSlot::FlowingMerge { + Some(LaneSlot::FlowingMerge { alias: merge_alias, parent, second, - } if *second == alias => Some(LaneSlot::Flowing { - alias: merge_alias, - parent, - }), + }) if *second == alias => { + // The second parent was just placed + // bridge resolves and the lane flows + Some(LaneSlot::Flowing { + alias: merge_alias, + parent, + }) + } _ => continue, }; @@ -155,9 +158,9 @@ impl Buffer { let Some((index, chunk)) = self.current.iter().enumerate().find_map( |(index, slot)| { - let chunk = slot.as_ref()?; + let chunk = *slot.as_ref()?; (chunk.alias() == Some(alias)) - .then(|| (index, chunk.clone())) + .then_some((index, chunk)) }, ) else { @@ -214,10 +217,7 @@ impl Buffer { index: usize, new: Option, ) { - self.pending_delta.push(DeltaOp::Replace { - index, - new: new.clone(), - }); + self.pending_delta.push(DeltaOp::Replace { index, new }); self.current[index] = new; } @@ -226,10 +226,7 @@ impl Buffer { index: usize, item: Option, ) { - self.pending_delta.push(DeltaOp::Insert { - index, - item: item.clone(), - }); + self.pending_delta.push(DeltaOp::Insert { index, item }); self.current.insert(index, item); } @@ -282,7 +279,7 @@ impl Buffer { for op in &delta.0 { match op { DeltaOp::Insert { index, item } => { - state.insert(*index, item.clone()); + state.insert(*index, *item); } DeltaOp::Remove { index } => { state.remove(*index); diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index 1fc7dcf455..d1d7ce92f7 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -1,7 +1,7 @@ use super::{CommitAlias, UnwalkedAlias}; /// A lane's occupant at one point in the walk. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LaneSlot { /// A walked commit flowing down to its first parent. Flowing { From 0390c3a7d47ce6dcd7b6cbe09bb44133181f0521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 27 Sep 2026 03:42:59 +0000 Subject: [PATCH 69/84] hash commit ids with foldhash --- Cargo.lock | 1 + asyncgit/Cargo.toml | 1 + asyncgit/src/graph/oids.rs | 10 ++++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6aac533022..385e61140a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,7 @@ dependencies = [ "dirs", "easy-cast", "env_logger", + "foldhash", "fuzzy-matcher", "git2", "git2-hooks", diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index a0d14f69f9..01c6c44d07 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -20,6 +20,7 @@ fuzzy-matcher = "0.3" git2 = { version = "0.21", features = ["https", "ssh", "unstable-sha256"] } git2-hooks = { path = "../git2-hooks", version = "0.7" } gix = { version = "0.84.0", default-features = false, features = ["mailmap", "max-performance", "revision", "sha1", "sha256", "status"] } +foldhash = "0.2" log = "0.4" # git2 = { path = "../../extern/git2-rs", features = ["vendored-openssl"]} # git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]} diff --git a/asyncgit/src/graph/oids.rs b/asyncgit/src/graph/oids.rs index 0ee8591532..c981df4766 100644 --- a/asyncgit/src/graph/oids.rs +++ b/asyncgit/src/graph/oids.rs @@ -2,9 +2,15 @@ use super::CommitAlias; use crate::sync::CommitId; use std::collections::HashMap; -/// mapping of `CommitId` to a numeric alias +/// mapping of `CommitId` to a numeric alias. +/// +/// `foldhash` is a non-cryptographic hasher — plenty for ids that +/// never leave this process — and notably cheaper than the default +/// `SipHash` for the millions of lookups a full history generates. #[derive(Default)] -pub struct GraphOids(HashMap); +pub struct GraphOids( + HashMap, +); impl GraphOids { /// Create an empty alias map. From 557429921055fc97e635a843bd442ddeb66fd87d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Sun, 27 Sep 2026 03:43:05 +0000 Subject: [PATCH 70/84] carry commit parents through walk --- asyncgit/src/revlog.rs | 103 +++++++++++------------------ asyncgit/src/sync/logwalker.rs | 114 ++++++++++++++++++++------------- asyncgit/src/sync/mod.rs | 4 +- 3 files changed, 110 insertions(+), 111 deletions(-) diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 82e861a795..af4fed90b0 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -3,7 +3,7 @@ use crate::{ graph::{GraphRow, GraphWalker}, sync::{ gix_repo, repo, CommitId, LogWalker, LogWalkerWithoutFilter, - RepoPath, SharedCommitFilterFn, + RepoPath, SharedCommitFilterFn, WalkEntry, }, AsyncGitNotification, Error, }; @@ -47,10 +47,10 @@ pub struct AsyncLog { filter: Option, partial_extract: AtomicBool, repo: RepoPath, - /// All commit ids collected by the background thread, in walk order. - /// The graph walker reads these lazily, only as far as the viewport - /// requires, looking up each commit's parents on demand. - walk_entries: Arc>>, + /// All commits collected by the background thread, in walk order, + /// together with their parents. The graph walker reads these + /// lazily, only as far as the viewport requires. + walk_entries: Arc>>, graph_walker: Arc>, } @@ -113,18 +113,11 @@ impl AsyncLog { let processed = walker.processed_commit_count().min(needed_end); - // The graph only needs topology for the commits it is - // about to fold in, so parents are looked up here on - // demand instead of being carried along the whole walk. - if processed < needed_end { - let mut repo = gix_repo(&self.repo).ok()?; - repo.object_cache_size_if_unset(2_usize.pow(14)); - - for id in &entries[processed..needed_end] { - let parents = - Self::parents_of(&repo, *id).ok()?; - walker.process(*id, &parents); - } + // Parents were already decoded by the walk and are carried + // along in `entries`, so folding a commit into the graph is + // pure in-memory work. + for (id, parents) in &entries[processed..needed_end] { + walker.process(*id, parents); } } @@ -137,22 +130,6 @@ impl AsyncLog { )) } - /// Looks up a commit's (up to two) parents on demand. - /// - /// The graph caps support at two parents, ignoring octopus - /// merges, so anything beyond the first two is dropped here. - fn parents_of( - repo: &gix::Repository, - id: CommitId, - ) -> Result> { - Ok(repo - .find_commit(id)? - .parent_ids() - .take(2) - .map(Into::into) - .collect()) - } - /// pub fn count(&self) -> Result { Ok(self.current.lock()?.commits.len()) @@ -280,7 +257,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, - arc_walk_entries: &Arc>>, + arc_walk_entries: &Arc>>, filter: Option, ) -> Result<()> { filter.map_or_else( @@ -337,7 +314,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, - arc_walk_entries: &Arc>>, + arc_walk_entries: &Arc>>, ) -> Result<()> { let mut repo: gix::Repository = gix_repo(repo_path)?; let mut walker = @@ -357,53 +334,47 @@ impl AsyncLog { } /// Drives `read` in batches, publishing every batch's commit ids - /// to `arc_current` and (when given) moving the full entries into - /// `walk_entries` for the graph. + /// to `arc_current` and, when given, moving the full entries, + /// both ids and parents; into `walk_entries` for the graph. fn walk_loop( - mut read: impl FnMut(&mut Vec) -> Result, + mut read: impl FnMut(&mut Vec) -> Result, arc_current: &Arc>, arc_background: &Arc, sender: &Sender, - walk_entries: Option<&Mutex>>, + walk_entries: Option<&Mutex>>, ) -> Result<()> { let start_time = Instant::now(); + let mut entries = Vec::with_capacity(LIMIT_COUNT); - let mut entries: Vec = - Vec::with_capacity(LIMIT_COUNT); - - loop { - let read_count = read(&mut entries)?; - - { - let mut current = arc_current.lock()?; - current.commits.extend(entries.iter().copied()); - current.duration = start_time.elapsed(); - } + while read(&mut entries)? > 0 { + // publish + let mut current = arc_current.lock()?; + current.commits.extend(entries.iter().map(|&(id, _)| id)); + current.duration = start_time.elapsed(); - if let Some(walk_entries) = walk_entries { - walk_entries.lock()?.append(&mut entries); - } else { - entries.clear(); + if let Some(arc_walk_entries) = walk_entries { + arc_walk_entries.lock()?.append(&mut entries); } + entries.clear(); - if read_count == 0 { - break; - } Self::notify(sender); - - let sleep_duration = - if arc_background.load(Ordering::Relaxed) { - SLEEP_BACKGROUND - } else { - SLEEP_FOREGROUND - }; - - thread::sleep(sleep_duration); + thread::sleep(Self::pause(arc_background)); } + arc_current.lock()?.duration = start_time.elapsed(); + Ok(()) } + /// The pause between batches + fn pause(arc_background: &Arc) -> Duration { + if arc_background.load(Ordering::Relaxed) { + SLEEP_BACKGROUND + } else { + SLEEP_FOREGROUND + } + } + fn clear(&self) -> Result<()> { self.current.lock()?.commits.clear(); *self.current_head.lock()? = None; diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index feb6d0999d..c144155a9f 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -2,11 +2,17 @@ use super::{CommitId, SharedCommitFilterFn}; use crate::error::Result; use git2::{Commit, Oid, Repository}; use gix::revision::Walk; +use smallvec::SmallVec; use std::{ cmp::Ordering, collections::{BinaryHeap, HashSet}, }; +/// One walked commit plus its parents, as decoded by the walk +/// itself, so consumers never need to touch the object database a +/// second time. +pub type WalkEntry = (CommitId, SmallVec<[CommitId; 4]>); + struct TimeOrderedCommit<'a>(Commit<'a>); impl Eq for TimeOrderedCommit<'_> {} @@ -69,11 +75,19 @@ impl<'a> LogWalker<'a> { Self { filter, ..self } } - /// - pub fn read(&mut self, out: &mut Vec) -> Result { + /// Reads up to `limit` commits and their parents, newest first. + /// Parents come straight from the already-parsed commit, at no + /// extra object-database cost. + pub fn read( + &mut self, + out: &mut Vec, + ) -> Result { let mut count = 0_usize; while let Some(c) = self.commits.pop() { + let parents: SmallVec<[CommitId; 4]> = + c.0.parent_ids().map(Into::into).collect(); + for p in c.0.parents() { self.visit(p); } @@ -87,7 +101,7 @@ impl<'a> LogWalker<'a> { }; if commit_should_be_included { - out.push(id); + out.push((id, parents)); } count += 1; @@ -156,12 +170,24 @@ impl<'a> LogWalkerWithoutFilter<'a> { self.visited } - /// - pub fn read(&mut self, out: &mut Vec) -> Result { + /// Reads up to `limit` commits and their parents, newest first. + /// The parents are already decoded by the walk, so carrying them + /// along costs nothing extra and we save a second traversal! + pub fn read( + &mut self, + out: &mut Vec, + ) -> Result { let mut count = 0_usize; while let Some(Ok(info)) = self.walk.next() { - out.push(info.id.into()); + out.push(( + info.id.into(), + info.parent_ids + .iter() + .copied() + .map(Into::into) + .collect(), + )); count += 1; @@ -209,12 +235,12 @@ mod tests { stage_add_file(repo_path, file_path).unwrap(); let oid2 = commit(repo_path, "commit2").unwrap(); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walk = LogWalker::new(&repo, 1)?; - walk.read(&mut items).unwrap(); + walk.read(&mut entries).unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0], oid2); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, oid2); Ok(()) } @@ -234,21 +260,22 @@ mod tests { stage_add_file(repo_path, file_path).unwrap(); let oid2 = commit(repo_path, "commit2").unwrap(); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walk = LogWalker::new(&repo, 100)?; - walk.read(&mut items).unwrap(); + walk.read(&mut entries).unwrap(); - let ids: Vec = items.clone(); + let ids: Vec = + entries.iter().map(|(id, _)| *id).collect(); let info = get_commits_info(repo_path, &ids, 50).unwrap(); dbg!(&info); - assert_eq!(items.len(), 2); - assert_eq!(items[0], oid2); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].0, oid2); - let mut items = Vec::new(); - walk.read(&mut items).unwrap(); + let mut entries = Vec::new(); + walk.read(&mut entries).unwrap(); - assert_eq!(items.len(), 0); + assert_eq!(entries.len(), 0); Ok(()) } @@ -270,20 +297,21 @@ mod tests { let mut repo: gix::Repository = gix_repo(repo_path)?; let mut walk = LogWalkerWithoutFilter::new(&mut repo, 100)?; - let mut items = Vec::new(); - assert!(matches!(walk.read(&mut items), Ok(2))); + let mut entries = Vec::new(); + assert!(matches!(walk.read(&mut entries), Ok(2))); - let ids: Vec = items.clone(); + let ids: Vec = + entries.iter().map(|(id, _)| *id).collect(); let info = get_commits_info(repo_path, &ids, 50).unwrap(); dbg!(&info); - assert_eq!(items.len(), 2); - assert_eq!(items[0], oid2); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].0, oid2); - let mut items = Vec::new(); - assert!(matches!(walk.read(&mut items), Ok(0))); + let mut entries = Vec::new(); + assert!(matches!(walk.read(&mut entries), Ok(0))); - assert_eq!(items.len(), 0); + assert_eq!(entries.len(), 0); Ok(()) } @@ -314,27 +342,27 @@ mod tests { let diff_contains_baz = diff_contains_file("baz".into()); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walker = LogWalker::new(&repo, 100)? .filter(Some(diff_contains_baz)); - walker.read(&mut items).unwrap(); + walker.read(&mut entries).unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0], second_commit_id); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, second_commit_id); - let mut items = Vec::new(); - walker.read(&mut items).unwrap(); + let mut entries = Vec::new(); + walker.read(&mut entries).unwrap(); - assert_eq!(items.len(), 0); + assert_eq!(entries.len(), 0); let diff_contains_bar = diff_contains_file("bar".into()); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walker = LogWalker::new(&repo, 100)? .filter(Some(diff_contains_bar)); - walker.read(&mut items).unwrap(); + walker.read(&mut entries).unwrap(); - assert_eq!(items.len(), 0); + assert_eq!(entries.len(), 0); Ok(()) } @@ -360,14 +388,14 @@ mod tests { }), ); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walker = LogWalker::new(&repo, 100) .unwrap() .filter(Some(log_filter)); - walker.read(&mut items).unwrap(); + walker.read(&mut entries).unwrap(); - assert_eq!(items.len(), 1); - assert_eq!(items[0], second_commit_id); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, second_commit_id); let log_filter = filter_commit_by_search( LogFilterSearch::new(LogFilterSearchOptions { @@ -377,12 +405,12 @@ mod tests { }), ); - let mut items = Vec::new(); + let mut entries = Vec::new(); let mut walker = LogWalker::new(&repo, 100) .unwrap() .filter(Some(log_filter)); - walker.read(&mut items).unwrap(); + walker.read(&mut entries).unwrap(); - assert_eq!(items.len(), 2); + assert_eq!(entries.len(), 2); } } diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 1b0c60f8f3..a7b8074098 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -72,7 +72,7 @@ pub use hooks::{ }; pub use hunks::{reset_hunk, stage_hunk, unstage_hunk}; pub use ignore::add_to_ignore; -pub use logwalker::{LogWalker, LogWalkerWithoutFilter}; +pub use logwalker::{LogWalker, LogWalkerWithoutFilter, WalkEntry}; pub use merge::{ abort_pending_rebase, abort_pending_state, continue_pending_rebase, merge_branch, merge_commit, merge_msg, @@ -267,7 +267,7 @@ pub mod tests { .read(&mut entries) .unwrap(); - entries + entries.into_iter().map(|(id, _)| id).collect() } /// Same as `repo_init`, but the repo is a bare repo (--bare) From 4a689af301748e5b59078a3ed3fa7d1694a38edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 02:03:05 +0000 Subject: [PATCH 71/84] graph: keep active lanes in fixed columns Use in-place lane updates and end-only growth or trimming so opening a merge lane does not shift existing columns. Reuse the nearest column to the right that was already blank above the current row. Exclude columns freed during the current step because their closing connectors still occupy that row. Treat vacant slots as absent when checking upward lane continuity. --- asyncgit/src/graph/buffer.rs | 189 ++++++++++++++++++++++------------- asyncgit/src/graph/walker.rs | 13 ++- 2 files changed, 131 insertions(+), 71 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index 567e83c912..a242e04c29 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -1,22 +1,30 @@ use super::chunk::LaneSlot; -use super::CommitAlias; +use super::{CommitAlias, UnwalkedAlias}; use std::collections::BTreeMap; /// A single mutation of the lane state, recorded while processing one /// commit. -#[derive(Clone, Debug)] +/// +/// A lane's column index *is* its visual identity. Rows are drawn one +/// at a time and joined to their neighbours by column, and the glyph +/// set has no diagonal, so a lane that moved sideways between two rows +/// could only be drawn as two segments that do not meet. These three +/// operations are therefore the only edits the walk may make: +/// [`Self::Set`] leaves a column where it is, and [`Self::Push`] and +/// [`Self::Pop`] only ever touch the right-hand end. No sequence of +/// them can shift a lane that is still open, which is why there is no +/// operation here for inserting or removing a column in the middle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DeltaOp { - Insert { - index: usize, - item: Option, - }, - Remove { + /// Put `slot` into the existing column `index`. + Set { index: usize, + slot: Option, }, - Replace { - index: usize, - new: Option, - }, + /// Append a new rightmost column, holding `slot`. + Push { slot: LaneSlot }, + /// Drop the rightmost column, which is always vacant. + Pop, } /// All lane-state mutations caused by processing a single commit. @@ -49,10 +57,15 @@ pub struct Buffer { /// Aliases of merge commits whose second parent still needs a new /// lane. - pub merge_commits: Vec, + merge_commits: Vec, - /// Scratch list of the [`DeltaOp`]s recorded for processing commit - pub pending_delta: Vec, + /// The [`DeltaOp`]s recorded so far for the commit in flight. + pending_delta: Vec, + + /// Which columns the row above the commit in flight leaves blank, + /// so a lane may start in one of them without a line above it to + /// join. Rebuilt at the start of every step. + blank_above: Vec, } impl Buffer { @@ -63,6 +76,8 @@ impl Buffer { } pub fn update(&mut self, new_chunk: &LaneSlot) { + self.start_step(); + let placement_index = self.place_chunk(new_chunk); if let Some(alias) = new_chunk.alias() { @@ -76,18 +91,41 @@ impl Buffer { self.commit_delta(); } + /// Note which columns the row above draws nothing in. + /// + /// A column that is vacant *now* is not the same thing: the ones + /// emptied while this very commit is being processed still have a + /// lane drawn in the row above, and are the reason + /// [`Self::reserve_lane_right_of`] cannot simply look for a hole. + /// + /// A root commit's column is not blank. [`LaneSlot::Settled`] + /// draws a marker and no line below it, so the column is dead for + /// the rest of the walk, and it stays occupied on purpose: a + /// commit marker drawn directly beneath another one, with no line + /// between them, is how a lane draws two consecutive commits, so + /// handing the column to an unrelated history would read as + /// joining the two. + fn start_step(&mut self) { + self.blank_above.clear(); + self.blank_above + .extend(self.current.iter().map(Option::is_none)); + } + fn place_chunk(&mut self, new_chunk: &LaneSlot) -> usize { // Prefer a lane whose current occupant awaits this chunk's - // commit as its primary parent. + // commit as its primary parent, then the leftmost column the + // row above leaves blank, and only then a new column. All + // three land the chunk somewhere no open lane is drawn, so + // nothing already on screen has to move. let target = self .find_lane_awaiting_parent(new_chunk.alias()) .or_else(|| self.first_empty_lane()) .unwrap_or(self.current.len()); if target < self.current.len() { - self.record_replace(target, Some(*new_chunk)); + self.record_set(target, Some(*new_chunk)); } else { - self.record_insert(target, Some(*new_chunk)); + self.record_push(*new_chunk); } target } @@ -109,6 +147,17 @@ impl Buffer { self.current.iter().position(Option::is_none) } + /// The column holding `alias`, with its slot. + fn lane_of( + &self, + alias: CommitAlias, + ) -> Option<(usize, LaneSlot)> { + self.current.iter().enumerate().find_map(|(index, slot)| { + let chunk = (*slot)?; + (chunk.alias() == Some(alias)).then_some((index, chunk)) + }) + } + fn consume_alias_in_other_chunks( &mut self, alias: CommitAlias, @@ -120,7 +169,7 @@ impl Buffer { } // `LaneSlot` is `Copy`; matching by value lets us read - // the slot out before `record_replace` mutates `current`. + // the slot out before `record_set` mutates `current`. let new_chunk = match self.current[index] { Some( LaneSlot::Flowing { parent, .. } @@ -147,23 +196,13 @@ impl Buffer { _ => continue, }; - self.record_replace(index, new_chunk); + self.record_set(index, new_chunk); } } fn flush_merge_commits(&mut self) { while let Some(alias) = self.merge_commits.pop() { - // Search for an occupied slot that matches the target alias. - // If found, extract its index and a clone of the chunk. - let Some((index, chunk)) = - self.current.iter().enumerate().find_map( - |(index, slot)| { - let chunk = *slot.as_ref()?; - (chunk.alias() == Some(alias)) - .then_some((index, chunk)) - }, - ) - else { + let Some((index, chunk)) = self.lane_of(alias) else { continue; }; @@ -178,7 +217,7 @@ impl Buffer { continue; }; - self.record_replace( + self.record_set( index, Some(LaneSlot::Flowing { alias: merge_alias, @@ -186,21 +225,49 @@ impl Buffer { }), ); - // Always append the merge's second-parent lane to - // the end instead of reusing an existing empty slot, - // so the new visual column does not collapse - // spatial ordering of lanes already in existence. - self.record_insert( - self.current.len(), - Some(LaneSlot::Reserved { parent: second }), - ); + self.reserve_lane_right_of(index, second); + } + } + + /// Open the lane that a merge's second parent will arrive in, + /// somewhere to the right of the merge itself. + /// + /// The nearest column the row above leaves blank wins, which keeps + /// the bridge short; failing that the lane goes on the right-hand + /// end. A column that only *became* blank while this very commit + /// was processed is not a candidate: the row above still draws a + /// lane there, and taking the column over would replace that + /// lane's closing corner with this one's, erasing an edge. + /// + /// Columns to the left are left alone even when they are free. + /// Reusing them would be just as sound, but it would put a merged + /// branch on the far side of the history it was merged into, and + /// `place_chunk` claims those columns for new branch tips anyway. + fn reserve_lane_right_of( + &mut self, + merge_lane: usize, + parent: UnwalkedAlias, + ) { + let slot = LaneSlot::Reserved { parent }; + let reusable = + (merge_lane + 1..self.current.len()).find(|&index| { + self.current[index].is_none() + && self + .blank_above + .get(index) + .copied() + .unwrap_or(false) + }); + + match reusable { + Some(index) => self.record_set(index, Some(slot)), + None => self.record_push(slot), } } fn commit_delta(&mut self) { while matches!(self.current.last(), Some(None)) { - let last = self.current.len() - 1; - self.record_remove(last); + self.record_pop(); } self.deltas @@ -212,27 +279,19 @@ impl Buffer { } } - fn record_replace( - &mut self, - index: usize, - new: Option, - ) { - self.pending_delta.push(DeltaOp::Replace { index, new }); - self.current[index] = new; + fn record_set(&mut self, index: usize, slot: Option) { + self.pending_delta.push(DeltaOp::Set { index, slot }); + self.current[index] = slot; } - fn record_insert( - &mut self, - index: usize, - item: Option, - ) { - self.pending_delta.push(DeltaOp::Insert { index, item }); - self.current.insert(index, item); + fn record_push(&mut self, slot: LaneSlot) { + self.pending_delta.push(DeltaOp::Push { slot }); + self.current.push(Some(slot)); } - fn record_remove(&mut self, index: usize) { - self.pending_delta.push(DeltaOp::Remove { index }); - self.current.remove(index); + fn record_pop(&mut self) { + self.pending_delta.push(DeltaOp::Pop); + self.current.pop(); } pub fn decompress( @@ -277,15 +336,11 @@ impl Buffer { delta: &Delta, ) { for op in &delta.0 { - match op { - DeltaOp::Insert { index, item } => { - state.insert(*index, *item); - } - DeltaOp::Remove { index } => { - state.remove(*index); - } - DeltaOp::Replace { index, new } => { - state[*index].clone_from(new); + match *op { + DeltaOp::Set { index, slot } => state[index] = slot, + DeltaOp::Push { slot } => state.push(Some(slot)), + DeltaOp::Pop => { + state.pop(); } } } diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index f348da9322..1a84dc49bc 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -425,7 +425,9 @@ impl GraphWalker { }) } - /// Determines if a lane should draw an upward-connecting corner. + /// Whether the lane in `target_lane` is the same lane the row + /// above had there, which is what lets a bridge end draw a + /// junction into it instead of a corner. fn lane_continues_upwards( target_lane: usize, current_snapshot: &[Option], @@ -435,9 +437,12 @@ impl GraphWalker { return false; }; - let current = current_snapshot.get(target_lane); - current.is_some() - && previous_snapshot.get(target_lane) == current + let occupant = |snapshot: &[Option]| { + snapshot.get(target_lane).copied().flatten() + }; + + let current = occupant(current_snapshot); + current.is_some() && occupant(previous_snapshot) == current } /// Uses `Ordering` to elegantly map spatial relationships to visual bitmasks. From e21354c926b2d35aba9e16a81853ee2944c7ff42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 02:06:33 +0000 Subject: [PATCH 72/84] graph: preserve junctions where merge bridges overlap Track bridge crossings separately from connections into a lane. Emit a four-way junction when one bridge joins a lane while another crosses the same cell, preserving the merge edge. Move connection-to-glyph selection into the graph-row symbol helper. --- asyncgit/src/graph/mod.rs | 2 + asyncgit/src/graph/walker.rs | 92 +++++++++++++++++++++----------- src/components/commitlist.rs | 56 +++---------------- src/components/utils/graphrow.rs | 27 ++++++++++ 4 files changed, 98 insertions(+), 79 deletions(-) diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index 0d6b319ed4..6f5dcb8a28 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -86,6 +86,8 @@ pub enum ConnectionType { TeeUp, /// a lane starting downward while a bridge passes through TeeDown, + /// a lane a bridge joins at a cell another bridge runs through + Cross, } impl ConnectionType { diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 1a84dc49bc..4c985d01bf 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -20,12 +20,20 @@ bitflags! { /// The neighboring cells a lane joins to. Overlapping lines /// are merged through this representation rather than /// a naive overwrite for readability. + /// + /// [`Self::LEFT`] and [`Self::RIGHT`] are a horizontal run that + /// *joins* this cell; [`Self::CROSS`] is one that runs past it + /// without joining. Keeping the two apart is what lets a bridge be + /// drawn behind an unrelated lane, while a bridge that really ends + /// on a lane still gets its junction even when a third line + /// crosses the same cell. #[derive(Clone, Copy, Default)] struct Directions: u8 { - const UP = 0b0001; - const DOWN = 0b0010; - const LEFT = 0b0100; - const RIGHT = 0b1000; + const UP = 0b0_0001; + const DOWN = 0b0_0010; + const LEFT = 0b0_0100; + const RIGHT = 0b0_1000; + const CROSS = 0b1_0000; } } @@ -75,6 +83,12 @@ fn connection_to_directions( ConnectionType::TeeDown => { Directions::DOWN | Directions::LEFT | Directions::RIGHT } + ConnectionType::Cross => { + Directions::UP + | Directions::DOWN + | Directions::LEFT + | Directions::RIGHT + } ConnectionType::CommitNormal | ConnectionType::CommitBranch | ConnectionType::CommitMerge @@ -83,36 +97,57 @@ fn connection_to_directions( }) } +const fn vertical_connection(dotted: bool) -> ConnectionType { + if dotted { + ConnectionType::VerticalDotted + } else { + ConnectionType::Vertical + } +} + /// Synthesize a connection glyph from direction components. -/// Vertical lines take precedence in crossed cells. -/// Yet the horizontal bridge continues in -/// the spacer columns either side, so we retain wholeness. const fn directions_to_connection( dirs: Directions, dotted: bool, ) -> ConnectionType { let up = dirs.contains(Directions::UP); let down = dirs.contains(Directions::DOWN); - let left = dirs.contains(Directions::LEFT); - let right = dirs.contains(Directions::RIGHT); + let joins_left = dirs.contains(Directions::LEFT); + let joins_right = dirs.contains(Directions::RIGHT); + let crossed = dirs.contains(Directions::CROSS); + + // A lane running straight through the cell. A bridge that merely + // crosses it is drawn behind it, which keeps the lane readable and + // is the whole reason a crossing is tracked apart from a join. A + // bridge that ends on the lane needs a junction instead, and when + // the cell is both joined and crossed there is nothing left to + // hide the join behind, so it takes the full cross. Drawing that + // case as a plain vertical, which is all a single horizontal bit + // could do, loses the merge edge out of the picture entirely. + if up && down { + return match (joins_left, joins_right, crossed) { + (false, false, _) => vertical_connection(dotted), + (true, false, false) => ConnectionType::TeeLeft, + (false, true, false) => ConnectionType::TeeRight, + _ => ConnectionType::Cross, + }; + } + + // Below here the cell has at most one vertical end, so a crossing + // and a join on the same side are drawn alike. + let left = joins_left || crossed; + let right = joins_right || crossed; + match (up, down, left, right) { - (true, true, true, false) => ConnectionType::TeeLeft, - (true, true, false, true) => ConnectionType::TeeRight, - (true, false, true, true) => ConnectionType::TeeUp, - (false, true, true, true) => ConnectionType::TeeDown, - (true, false, true, false) => ConnectionType::BranchUp, - (true, false, false, true) => ConnectionType::BranchUpRight, - (false, true, true, false) => { - ConnectionType::MergeBridgeStart - } - (false, true, false, true) => ConnectionType::MergeBridgeEnd, + (true, _, true, true) => ConnectionType::TeeUp, + (_, true, true, true) => ConnectionType::TeeDown, + (true, _, true, false) => ConnectionType::BranchUp, + (true, _, false, true) => ConnectionType::BranchUpRight, + (_, true, true, false) => ConnectionType::MergeBridgeStart, + (_, true, false, true) => ConnectionType::MergeBridgeEnd, (false, false, _, _) => ConnectionType::MergeBridgeMid, - (true, true, _, _) | (_, false | true, false, false) => { - if dotted { - ConnectionType::VerticalDotted - } else { - ConnectionType::Vertical - } + (true | false, _, false, false) => { + vertical_connection(dotted) } } } @@ -701,11 +736,7 @@ impl GraphWalker { color: LaneIndex, ) { for lane in lanes.iter_mut().take(to).skip(from + 1) { - overlay_cell( - lane, - Directions::LEFT | Directions::RIGHT, - color, - ); + overlay_cell(lane, Directions::CROSS, color); } } } @@ -737,6 +768,7 @@ mod tests { ConnectionType::TeeRight => '┣', ConnectionType::TeeUp => '┻', ConnectionType::TeeDown => '┳', + ConnectionType::Cross => '╋', } } diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 2b524567cf..5c880c7361 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,11 +1,4 @@ -use super::utils::graphrow::{ - SYM_BRANCH_UP, SYM_BRANCH_UP_RIGHT, SYM_COMMIT, - SYM_COMMIT_BRANCH, SYM_COMMIT_MERGE, SYM_COMMIT_STASH, - SYM_COMMIT_UNCOMMITTED, SYM_HORIZONTAL, SYM_MERGE_BRIDGE_END, - SYM_MERGE_BRIDGE_MID, SYM_MERGE_BRIDGE_START, SYM_SPACE, - SYM_TEE_DOWN, SYM_TEE_LEFT, SYM_TEE_RIGHT, SYM_TEE_UP, - SYM_VERTICAL, SYM_VERTICAL_DOTTED, -}; +use super::utils::graphrow::{symbol, SYM_HORIZONTAL, SYM_SPACE}; use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ app::Environment, @@ -519,47 +512,11 @@ impl CommitList { } let (sym, graph_color) = match conn { None => (SYM_SPACE, Color::Reset), - Some((conn_type, color_idx)) => { - let color = GRAPH_COLORS[usize::from(*color_idx) - % GRAPH_COLORS.len()]; - let sym = match conn_type { - ConnectionType::Vertical => SYM_VERTICAL, - ConnectionType::VerticalDotted => { - SYM_VERTICAL_DOTTED - } - ConnectionType::TeeLeft => SYM_TEE_LEFT, - ConnectionType::TeeRight => SYM_TEE_RIGHT, - ConnectionType::TeeUp => SYM_TEE_UP, - ConnectionType::TeeDown => SYM_TEE_DOWN, - ConnectionType::BranchUpRight => { - SYM_BRANCH_UP_RIGHT - } - ConnectionType::CommitNormal => SYM_COMMIT, - ConnectionType::CommitBranch => { - SYM_COMMIT_BRANCH - } - ConnectionType::CommitMerge => { - SYM_COMMIT_MERGE - } - ConnectionType::CommitStash => { - SYM_COMMIT_STASH - } - ConnectionType::CommitUncommitted => { - SYM_COMMIT_UNCOMMITTED - } - ConnectionType::MergeBridgeStart => { - SYM_MERGE_BRIDGE_START - } - ConnectionType::MergeBridgeMid => { - SYM_MERGE_BRIDGE_MID - } - ConnectionType::MergeBridgeEnd => { - SYM_MERGE_BRIDGE_END - } - ConnectionType::BranchUp => SYM_BRANCH_UP, - }; - (sym, color) - } + Some((conn_type, color_idx)) => ( + symbol(*conn_type), + GRAPH_COLORS[usize::from(*color_idx) + % GRAPH_COLORS.len()], + ), }; spans.push(Span::styled( sym, @@ -1174,6 +1131,7 @@ impl Component for CommitList { #[cfg(test)] mod tests { + use super::super::utils::graphrow::SYM_COMMIT; use asyncgit::sync::CommitInfo; use super::*; diff --git a/src/components/utils/graphrow.rs b/src/components/utils/graphrow.rs index 89cb6bb27a..a83fb41ed4 100644 --- a/src/components/utils/graphrow.rs +++ b/src/components/utils/graphrow.rs @@ -15,8 +15,34 @@ pub const SYM_TEE_LEFT: &str = "┫"; pub const SYM_TEE_RIGHT: &str = "┣"; pub const SYM_TEE_UP: &str = "┻"; pub const SYM_TEE_DOWN: &str = "┳"; +pub const SYM_CROSS: &str = "╋"; pub const SYM_SPACE: &str = " "; +/// The glyph the graph column draws for `conn`. +pub const fn symbol(conn: ConnectionType) -> &'static str { + match conn { + ConnectionType::Vertical => SYM_VERTICAL, + ConnectionType::VerticalDotted => SYM_VERTICAL_DOTTED, + ConnectionType::CommitNormal => SYM_COMMIT, + ConnectionType::CommitBranch => SYM_COMMIT_BRANCH, + ConnectionType::CommitMerge => SYM_COMMIT_MERGE, + ConnectionType::CommitStash => SYM_COMMIT_STASH, + ConnectionType::CommitUncommitted => SYM_COMMIT_UNCOMMITTED, + ConnectionType::MergeBridgeStart => SYM_MERGE_BRIDGE_START, + ConnectionType::MergeBridgeMid => SYM_MERGE_BRIDGE_MID, + ConnectionType::MergeBridgeEnd => SYM_MERGE_BRIDGE_END, + ConnectionType::BranchUp => SYM_BRANCH_UP, + ConnectionType::BranchUpRight => SYM_BRANCH_UP_RIGHT, + ConnectionType::TeeLeft => SYM_TEE_LEFT, + ConnectionType::TeeRight => SYM_TEE_RIGHT, + ConnectionType::TeeUp => SYM_TEE_UP, + ConnectionType::TeeDown => SYM_TEE_DOWN, + ConnectionType::Cross => SYM_CROSS, + } +} + +use asyncgit::graph::ConnectionType; + #[cfg(test)] mod tests { use super::*; @@ -41,6 +67,7 @@ mod tests { SYM_TEE_RIGHT, SYM_TEE_UP, SYM_TEE_DOWN, + SYM_CROSS, SYM_SPACE, ]; From d8e88ee0f1a56bc21c7dda26757ede35beb5593c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 02:06:47 +0000 Subject: [PATCH 73/84] graph: reuse lanes that already await a merge parent Identify an existing second-parent route by the parent a lane awaits, including lanes whose occupants are themselves merges. Use the same matching rule as commit placement and bridge routing. Avoid reserving a duplicate column that would draw an unconnected line. --- asyncgit/src/graph/walker.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 4c985d01bf..4cbabdb73a 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -445,19 +445,23 @@ impl GraphWalker { .collect() } - /// Whether some lane already flows or is reserved for - /// `target_parent` without owing a second parent of its own, - /// making it redundant to track a new merge bridge to that - /// parent. + /// Whether a lane is already waiting for `target_parent`, making + /// a second lane for it both redundant and wrong. + /// + /// This has to agree exactly with the two other places that go + /// looking for a lane by the parent it awaits: the one that places + /// a commit, and the one that picks the far end of a merge bridge. + /// Both take the *first* such lane. So if this said no while a + /// lane was in fact waiting, the reservation opened on the + /// strength of that answer would be left over — the bridge would + /// be drawn to the lane that was already there, and the reserved + /// column would draw a line with nothing above it. fn has_lane_to_parent(&self, target_parent: CommitAlias) -> bool { - self.buffer.current.iter().flatten().any(|slot| { - matches!( - slot, - LaneSlot::Flowing { parent, .. } - | LaneSlot::Reserved { parent } - if **parent == target_parent - ) - }) + self.buffer + .current + .iter() + .flatten() + .any(|slot| slot.awaits() == Some(target_parent)) } /// Whether the lane in `target_lane` is the same lane the row From a6d5f00c271ad8b57dc5607d7b9d7b2111825c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 02:07:19 +0000 Subject: [PATCH 74/84] graph: consolidate commit facts and normalize drawable parents Store processed status, merge metadata, and the drawable second parent in CommitFacts. Derive merge markers from Git's parent count, independently of how many edges can be drawn. Select up to two distinct unwalked parents, rejecting duplicate, self-naming, and already-walked entries before allocating lanes. Report row width from its column span and widen LaneIndex for wide graphs. --- asyncgit/src/graph/mod.rs | 27 +++-- asyncgit/src/graph/walker.rs | 200 +++++++++++++++++++++++------------ 2 files changed, 153 insertions(+), 74 deletions(-) diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index 6f5dcb8a28..f05f52697c 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -8,11 +8,17 @@ pub use walker::GraphWalker; /// The maximum number of colors to use for graph lanes pub const MAX_LANE_COLORS: usize = 16; -// Yes, there are repositories where this is exceeded -// Are they very rare? Yes. -// On most terminals can more than 256 lanes even be represneted usefully? Not really. +/// A lane's column within a row, or its slot in the color palette. +/// +/// Repositories that open more lanes than this can hold do exist, and +/// no terminal can show that many columns usefully, so the conversion +/// from a column index saturates rather than wrapping. It is sized so +/// that the clamp is out of reach of any history a person scrolls +/// through, because past the clamp every lane reports the same column: +/// a width that is merely too large is cosmetic, while a column index +/// that is wrong misplaces an edge. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct LaneIndex(u8); +pub struct LaneIndex(u16); /// Numeric alias assigned to each commit in the graph. /// @@ -48,7 +54,7 @@ impl From for CommitAlias { impl From for LaneIndex { fn from(lane: usize) -> Self { - Self(u8::try_from(lane).unwrap_or(u8::MAX)) + Self(u16::try_from(lane).unwrap_or(u16::MAX)) } } @@ -97,9 +103,16 @@ impl ConnectionType { } } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GraphRow { - /// Number of active lanes at this commit row + /// How many lane columns this row spans, counting any column it + /// leaves blank between two lanes. + /// + /// This is the width the row needs, which is what the log view + /// sizes its graph column from. It is deliberately not a count of + /// live lanes: a row whose middle lanes have closed still has to + /// reserve the columns to the right of the gap, and sizing from a + /// count would hide them behind the truncation marker. pub lane_count: LaneIndex, /// Which lane index this commit sits on diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 4cbabdb73a..4cb1fad2e5 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -187,20 +187,41 @@ fn overlay_cell( } } +/// What the walk learned about one commit, indexed by its +/// [`CommitAlias`]. Aliases are dense `0..n`, so the facts live in a +/// flat vector instead of a map. +#[derive(Clone, Copy, Debug, Default)] +struct CommitFacts { + /// Whether the commit has already been folded into the buffer. + /// + /// [`GraphWalker::mint_drawable_parent`] refuses to mint an + /// [`UnwalkedAlias`] for one of these: a lane waiting on a commit + /// the walk has already passed would stay open to the end of the + /// log, drawing a line that never reaches anything. + processed: bool, + + /// Whether Git reports more than one parent for this commit, which + /// is what makes it draw a merge marker. + /// + /// A merge can legitimately end up with a single drawn edge — an + /// octopus merge past its second parent, a duplicated parent + /// entry, or a second parent the walk reached first — and keeping + /// this apart from the lanes lets the marker stay honest about + /// what the commit is in all three cases. + is_merge: bool, + + /// The second parent that was given a lane of its own, which is + /// the far end of the row's merge bridge. + second_parent: Option, +} + #[derive(Default)] pub struct GraphWalker { pub buffer: Buffer, pub oids: GraphOids, - /// For each merge commit's alias, the alias of its second parent. - /// Indexed directly by alias: aliases are dense `0..n`, so no - /// hashing is needed. - pub merge_parents: Vec>, - - /// Whether an alias has already been folded into the buffer; - /// consulted by [`Self::mint_drawable_parent`], which refuses to - /// mint an [`UnwalkedAlias`] for any of them. Indexed by alias. - pub processed: Vec, + /// What the walk knows about each commit, indexed by alias. + facts: Vec, } impl GraphWalker { @@ -208,6 +229,18 @@ impl GraphWalker { Self::default() } + fn facts(&self, alias: CommitAlias) -> CommitFacts { + self.facts.get(*alias).copied().unwrap_or_default() + } + + fn facts_mut(&mut self, alias: CommitAlias) -> &mut CommitFacts { + let index = *alias; + if self.facts.len() <= index { + self.facts.resize(index + 1, CommitFacts::default()); + } + &mut self.facts[index] + } + /// Mint the drawable alias for a parent commit, or `None` if the /// walk already passed it. fn mint_drawable_parent( @@ -215,19 +248,49 @@ impl GraphWalker { id: &CommitId, ) -> Option { let alias = self.oids.get_or_insert(id); - (!self.is_processed(alias)).then_some(UnwalkedAlias(alias)) + (!self.facts(alias).processed).then_some(UnwalkedAlias(alias)) } - fn is_processed(&self, alias: CommitAlias) -> bool { - self.processed.get(*alias).copied().unwrap_or(false) - } + /// The parents this commit draws an edge to: the first two + /// distinct parents the walk has not already passed. + /// + /// Every parent left out is one that no lane will wait for, and + /// that is the point. Git lets a commit name the same parent twice + /// and lets an octopus merge name more than two, and a walk + /// ordered by commit time can reach a parent before its child. A + /// lane opened for any of those could never be closed by a later + /// row, so it would hang off the bottom of the log as a line that + /// goes nowhere. + fn drawable_parents( + &mut self, + parents: &[CommitId], + ) -> [Option; 2] { + let mut drawn: [Option; 2] = [None, None]; + let mut found = 0; - fn mark_processed(&mut self, alias: CommitAlias) { - let index = *alias; - if self.processed.len() <= index { - self.processed.resize(index + 1, false); + for parent_id in parents { + if found == drawn.len() { + break; + } + + let Some(parent) = self.mint_drawable_parent(parent_id) + else { + continue; + }; + + let repeated = drawn[..found] + .iter() + .flatten() + .any(|seen| **seen == *parent); + if repeated { + continue; + } + + drawn[found] = Some(parent); + found += 1; } - self.processed[index] = true; + + drawn } pub fn process( @@ -237,14 +300,14 @@ impl GraphWalker { ) { let commit_alias = self.oids.get_or_insert(&commit_id); - let mut drawable_parents = - parents.iter().filter_map(|parent_id| { - self.mint_drawable_parent(parent_id) - }); + // Mark the commit walked before resolving its parents, so that + // a parent entry naming the commit itself is turned down like + // any other already-walked parent instead of opening a lane + // that waits for the commit which just closed it. + self.facts_mut(commit_alias).processed = true; - // We explicitly cap support at 2 parents, ignoring octo/mega merges. - let first_parent = drawable_parents.next(); - let second_parent = drawable_parents.next(); + let [first_parent, second_parent] = + self.drawable_parents(parents); let chunk = match (first_parent, second_parent) { (Some(parent), Some(second)) => LaneSlot::FlowingMerge { @@ -256,30 +319,24 @@ impl GraphWalker { alias: commit_alias, parent, }, - (None, None) => LaneSlot::Settled { + // `drawable_parents` fills its slots left to right, so + // there is no second parent without a first. + (None, _) => LaneSlot::Settled { alias: commit_alias, }, - (None, Some(_)) => unreachable!( - "second_parent is only ever Some once first_parent \ - has already been consumed from the identical \ - iterator, so a merge always has both parents" - ), }; - if let LaneSlot::FlowingMerge { second, .. } = &chunk { - let second = **second; - let index = *commit_alias; - if self.merge_parents.len() <= index { - self.merge_parents.resize(index + 1, None); - } - self.merge_parents[index] = Some(second); + let second = second_parent.map(|second| *second); + let facts = self.facts_mut(commit_alias); + facts.is_merge = parents.len() > 1; + facts.second_parent = second; + if let Some(second) = second { if !self.has_lane_to_parent(second) { self.buffer.track_merge_commit(commit_alias); } } - self.mark_processed(commit_alias); self.buffer.update(&chunk); } @@ -356,13 +413,10 @@ impl GraphWalker { let Some((source_lane, target_lane)) = merge_bridge else { return; }; - // A commit that lists the same parent twice (git allows - // duplicate parent entries, however rare in practice) makes - // its own lane await its second parent's alias too, so the - // search in `calculate_merge_bridge` can land back on - // `commit_lane` itself. There is no separate lane to bridge - // to in that case, so skip drawing rather than laying a - // zero-length span. + // `drawable_parents` keeps the two parents distinct, so the + // search in `calculate_merge_bridge` cannot land back on the + // merge's own lane. The guard stays because the alternative + // is overwriting the commit marker with a zero-length span. if source_lane == target_lane { return; } @@ -445,14 +499,14 @@ impl GraphWalker { .collect() } - /// Whether a lane is already waiting for `target_parent`, making - /// a second lane for it both redundant and wrong. + /// Whether a lane is already waiting for `target_parent`, making a + /// second lane for it both redundant and wrong. /// /// This has to agree exactly with the two other places that go /// looking for a lane by the parent it awaits: the one that places /// a commit, and the one that picks the far end of a merge bridge. - /// Both take the *first* such lane. So if this said no while a - /// lane was in fact waiting, the reservation opened on the + /// Both of them take the *first* such lane. So if this said no + /// while a lane was in fact waiting, the reservation opened on the /// strength of that answer would be left over — the bridge would /// be drawn to the lane that was already there, and the reserved /// column would draw a line with nothing above it. @@ -464,9 +518,9 @@ impl GraphWalker { .any(|slot| slot.awaits() == Some(target_parent)) } - /// Whether the lane in `target_lane` is the same lane the row - /// above had there, which is what lets a bridge end draw a - /// junction into it instead of a corner. + /// Whether the lane in `target_lane` is the same lane the row above + /// had there, which is what lets a bridge end draw a junction into + /// it instead of a corner. fn lane_continues_upwards( target_lane: usize, current_snapshot: &[Option], @@ -542,13 +596,14 @@ impl GraphWalker { ) -> GraphRow { let commit_alias = self.oids.get(commit_id); let head_alias = head_id.and_then(|id| self.oids.get(id)); - let second_parent_alias = commit_alias.and_then(|alias| { - self.merge_parents.get(*alias).copied().flatten() - }); + let facts = commit_alias + .map_or_else(CommitFacts::default, |alias| { + self.facts(alias) + }); let commit_lane = Self::find_commit_lane(current_snapshot, commit_alias); - let is_merge = second_parent_alias.is_some(); + let is_merge = facts.is_merge; let is_branch_tip = branch_tips.contains(commit_id); let is_stash = stashes.contains(commit_id); @@ -558,7 +613,7 @@ impl GraphWalker { ); let merge_bridge = - second_parent_alias.and_then(|parent_alias| { + facts.second_parent.and_then(|parent_alias| { commit_lane.and_then(|cl| { Self::calculate_merge_bridge( current_snapshot, @@ -615,11 +670,8 @@ impl GraphWalker { ) }); - let active_lane_count = - current_snapshot.iter().flatten().count(); - GraphRow { - lane_count: active_lane_count.into(), + lane_count: LaneIndex::from(lanes.len()), commit_lane: LaneIndex::from(commit_lane.unwrap_or(0)), is_merge, is_branch_tip, @@ -669,7 +721,15 @@ impl GraphWalker { }) } - /// Identifies lanes that existed in the previous row but terminated before the current row. + /// Columns whose lane drew a line into the row above and has no + /// occupant now: each one closed into this row's commit and needs + /// a bridge drawn across to its lane. + /// + /// A column the row above held a root commit in is not one of + /// them. [`LaneSlot::Settled`] draws a marker and no line + /// downward, so there would be nothing above for the bridge to + /// join, and drawing one would invent an edge between two + /// unrelated histories. fn find_branching_lanes( current_snapshot: &[Option], previous_snapshot: Option<&[Option]>, @@ -682,7 +742,8 @@ impl GraphWalker { .iter() .enumerate() .filter_map(|(index, previous_chunk)| { - (previous_chunk.is_some() + (previous_chunk + .is_some_and(|chunk| chunk.awaits().is_some()) && current_snapshot .get(index) .is_none_or(Option::is_none)) @@ -733,6 +794,12 @@ impl GraphWalker { /// Lay the horizontal run of a bridge over the lanes strictly /// between its two ends, merging with whatever each cell already /// shows. + /// + /// These cells are passed *through*, never joined — a bridge's two + /// ends are drawn by the callers — so the run is recorded as a + /// crossing. That is what keeps an unrelated lane drawn whole + /// underneath it, and what stops it from swallowing a junction + /// another bridge left in the same cell. fn draw_bridge_span( lanes: &mut [Option<(ConnectionType, LaneIndex)>], from: usize, @@ -744,7 +811,6 @@ impl GraphWalker { } } } - #[cfg(test)] mod tests { use super::*; @@ -933,7 +999,7 @@ mod tests { //1 to 2 must be dropped instead of // opening a lane that waits for 2 until the end of the walk. let rows = render(&[(2, &[3]), (1, &[2, 3]), (3, &[])]); - assert_eq!(rows, vec!["o", "┃ o", "o━┛"]); + assert_eq!(rows, vec!["o", "┃ M", "o━┛"]); } #[test] @@ -944,7 +1010,7 @@ mod tests { let history: &[(usize, &[usize])] = &[(2, &[4]), (3, &[4]), (1, &[2, 3]), (4, &[])]; - assert_eq!(render(history), vec!["o", "┃ o", "┃ ┃ o", "o━┛"]); + assert_eq!(render(history), vec!["o", "┃ o", "┃ ┃ M", "o━┛"]); let mut walker = GraphWalker::new(); for (commit, parents) in history { From 8b309dfacf3a5d9a6852adcd106c86ccd415ebfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 02:10:37 +0000 Subject: [PATCH 75/84] graph: verify rendered histories with independent readback Add a shared Scene fixture that checks lane positions, delta validity, window reconstruction, and graph connectivity before golden comparisons. Decode rendered characters independently to detect missing parent paths, dangling lines, unrelated joins, and invalid stacked commits. Cover edge cases with focused drawings and deterministic generated histories. --- asyncgit/src/graph/harness/mod.rs | 865 +++++++++++++++++++++++++ asyncgit/src/graph/harness/readback.rs | 240 +++++++ asyncgit/src/graph/mod.rs | 19 + asyncgit/src/graph/walker.rs | 517 ++++++++++----- 4 files changed, 1461 insertions(+), 180 deletions(-) create mode 100644 asyncgit/src/graph/harness/mod.rs create mode 100644 asyncgit/src/graph/harness/readback.rs diff --git a/asyncgit/src/graph/harness/mod.rs b/asyncgit/src/graph/harness/mod.rs new file mode 100644 index 0000000000..95b0db06e4 --- /dev/null +++ b/asyncgit/src/graph/harness/mod.rs @@ -0,0 +1,865 @@ +//! Test scaffolding for the commit graph. +//! +//! A graph test used to be a golden drawing: walk a history, render it +//! to ASCII, compare against a picture written by hand. That catches +//! cosmetic drift, but on its own it cannot tell a correct graph from a +//! wrong one, because the cheapest way to make a failing golden test +//! pass is to paste in whatever the renderer now produces. A regression +//! then arrives already blessed, with a confident comment explaining +//! the new picture. +//! +//! So [`Scene::walk`] holds every history to the invariants below +//! before any test gets to look at it, and a golden drawing is left to +//! do what a drawing is actually good at: saying which of the legal +//! pictures we want. +//! +//! The invariants come in two kinds. Some are about the lane state — +//! a lane keeps its column, a delta only addresses columns that exist, +//! a window of rows rebuilds to what the whole walk drew. The sharpest +//! ones are not: [`readback`] reads the finished picture back into the +//! history a person would read off the screen, using nothing but the +//! meaning of the box-drawing characters, and that is compared against +//! the parent lists Git handed us. A renderer cannot satisfy those by +//! being self-consistent. + +mod readback; + +use super::buffer::DeltaOp; +use super::chunk::LaneSlot; +use super::{ + CommitAlias, ConnectionType, GraphRow, GraphWalker, LaneIndex, +}; +use crate::sync::CommitId; +use readback::{side_name, Cursor, Picture}; +use std::collections::{BTreeSet, HashMap, HashSet}; + +/// How many parents a commit can have an edge drawn for. +const DRAWN_PARENTS: usize = 2; + +/// Above this many rows, window checks sample offsets instead of +/// trying every one. +const EXHAUSTIVE_WINDOW_ROWS: usize = 64; + +/// The commit id test histories use for the commit named `n`. +pub fn oid(n: usize) -> CommitId { + CommitId::from_str_unchecked(&format!("{n:040x}")) + .expect("valid oid") +} + +/// The character the log view draws for a connection. +/// +/// This mirrors the symbol table in +/// `gitui::components::utils::graphrow`, including the fact that a +/// stash and a branch tip share one marker, so the drawings in these +/// tests are the picture a terminal shows rather than a prettier +/// stand-in. Which marker a row drew is pinned separately, by +/// [`Scene::assert_rows_are_well_formed`]. +pub fn glyph(conn: ConnectionType) -> char { + match conn { + ConnectionType::Vertical => '┃', + ConnectionType::VerticalDotted => '╏', + ConnectionType::CommitNormal => 'o', + ConnectionType::CommitBranch + | ConnectionType::CommitStash => '*', + ConnectionType::CommitMerge => 'M', + ConnectionType::CommitUncommitted => '+', + ConnectionType::MergeBridgeStart => '┓', + ConnectionType::MergeBridgeMid => '━', + ConnectionType::MergeBridgeEnd => '┏', + ConnectionType::BranchUp => '┛', + ConnectionType::BranchUpRight => '┗', + ConnectionType::TeeLeft => '┫', + ConnectionType::TeeRight => '┣', + ConnectionType::TeeUp => '┻', + ConnectionType::TeeDown => '┳', + ConnectionType::Cross => '╋', + } +} + +/// One walked history: the rows it draws, and the lane state behind +/// them. +pub struct Scene { + pub walker: GraphWalker, + pub ids: Vec, + pub parents: Vec>, + pub rows: Vec, + pub snapshots: Vec>>, + row_of: HashMap, + branch_tips: HashSet, + stashes: HashSet, + head: Option, +} + +impl Scene { + /// Walk `history`, newest commit first, with commits named by + /// small integers: `(commit, parents)`. + pub fn walk(history: &[(usize, &[usize])]) -> Self { + Self::walk_marked(history, &[], &[], None) + } + + /// [`Self::walk`] with branch tips, stashes and `HEAD` named by the + /// same integers. + pub fn walk_marked( + history: &[(usize, &[usize])], + branch_tips: &[usize], + stashes: &[usize], + head: Option, + ) -> Self { + let named = history.iter().map(|(commit, parents)| { + (oid(*commit), parents.iter().copied().map(oid).collect()) + }); + + Self::walk_ids( + named, + branch_tips.iter().copied().map(oid).collect(), + stashes.iter().copied().map(oid).collect(), + head.map(oid), + ) + } + + /// Walk a history already expressed as commit ids. + pub fn walk_ids( + history: impl IntoIterator)>, + branch_tips: HashSet, + stashes: HashSet, + head: Option, + ) -> Self { + let mut walker = GraphWalker::new(); + let mut ids = Vec::new(); + let mut parents = Vec::new(); + let mut row_of = HashMap::new(); + + for (commit, commit_parents) in history { + walker.process(commit, &commit_parents); + row_of.entry(commit).or_insert(ids.len()); + ids.push(commit); + parents.push(commit_parents); + } + + let rows = walker.compute_rows( + &ids, + 0, + &branch_tips, + &stashes, + head.as_ref(), + ); + let snapshots = ids + .len() + .checked_sub(1) + .map(|last| walker.buffer.decompress(0, last)) + .unwrap_or_default(); + + let scene = Self { + walker, + ids, + parents, + rows, + snapshots, + row_of, + branch_tips, + stashes, + head, + }; + scene.assert_invariants(); + scene + } + + /// The picture as the log view draws it: one glyph per lane column, + /// plus the spacer that carries a bridge's horizontal run. + fn cells(&self) -> Vec> { + self.rows + .iter() + .map(|row| { + let mut cells = + Vec::with_capacity(row.lanes.len() * 2); + for (lane, conn) in row.lanes.iter().enumerate() { + cells.push(conn.map_or(' ', |(c, _)| glyph(c))); + + let in_bridge = row + .merge_bridge + .into_iter() + .chain(row.branches.iter().copied()) + .any(|(from, to)| { + lane >= usize::from(from) + && lane < usize::from(to) + }); + cells.push(if in_bridge { '━' } else { ' ' }); + } + cells + }) + .collect() + } + + /// The drawn rows, trailing blanks trimmed. + pub fn drawing(&self) -> Vec { + self.cells() + .iter() + .map(|cells| { + cells.iter().collect::().trim_end().to_owned() + }) + .collect() + } + + /// Assert the rows draw exactly `expected`. + pub fn assert_drawing(&self, expected: &[&str]) { + assert_eq!( + self.drawing(), + expected, + "\ndrawn:\n{}", + self.picture() + ); + } + + /// Assert no lane is still waiting on a parent, which is how a + /// history whose every parent was walked has to end. + pub fn assert_fully_settled(&self) { + for slot in self.walker.buffer.current.iter().flatten() { + assert!( + matches!(slot, LaneSlot::Settled { .. }), + "lane still waiting on a parent after a complete \ + walk: {slot:?}\n{}", + self.picture() + ); + } + } + + /// How many columns are still open at the end of the walk. + pub fn open_lane_count(&self) -> usize { + self.walker + .buffer + .current + .iter() + .flatten() + .filter(|slot| slot.awaits().is_some()) + .count() + } + + /// The drawing with row numbers, for failure messages. + pub fn picture(&self) -> String { + self.drawing() + .iter() + .enumerate() + .map(|(index, row)| format!("{index:>4} {row}")) + .collect::>() + .join("\n") + } + + fn alias(&self, index: usize) -> CommitAlias { + self.walker + .oids + .get(&self.ids[index]) + .expect("every walked commit has an alias") + } + + /// Where the row's commit marker sits in the drawing. + fn marker(&self, index: usize) -> Cursor { + (index, usize::from(self.rows[index].commit_lane) * 2) + } + + fn slot(&self, index: usize, column: usize) -> Option { + self.snapshots.get(index)?.get(column).copied().flatten() + } + + /// The parents this row was entitled to draw an edge to: the first + /// two distinct parents the walk had not already passed. + /// + /// Stating the rule here, from the parent lists alone, is what + /// makes it testable. Anything left out could only be drawn as a + /// lane no later row can close — a duplicated parent entry, an + /// octopus merge's third parent and up, or a parent a clock-skewed + /// walk reached before its child. + fn drawn_parents(&self, index: usize) -> Vec { + let id = &self.ids[index]; + let mut drawn: Vec = Vec::new(); + + for parent in &self.parents[index] { + if drawn.len() == DRAWN_PARENTS { + break; + } + let walked_already = self + .row_of + .get(parent) + .is_some_and(|row| *row <= index); + if parent == id || walked_already { + continue; + } + if !drawn.contains(parent) { + drawn.push(*parent); + } + } + + drawn + } + + /// Rows of the parents this row draws an edge to, for the parents + /// that are themselves in the walked window. + fn drawn_parent_rows(&self, index: usize) -> Vec { + self.drawn_parents(index) + .iter() + .filter_map(|parent| self.row_of.get(parent).copied()) + .collect() + } +} + +/// The invariants every scene is held to. +impl Scene { + fn assert_invariants(&self) { + self.assert_snapshots_track_the_walk(); + self.assert_delta_stream_is_well_formed(); + self.assert_columns_hold_still(); + self.assert_rows_are_well_formed(); + self.assert_windows_decompress_exactly(); + + let picture = Picture::new(&self.cells()); + let joined = (0..self.rows.len()) + .map(|index| { + picture.commits_joined_to(self.marker(index)) + }) + .collect::>(); + + self.assert_no_dangling_lines(&picture); + self.assert_every_parent_edge_is_drawn(&joined); + self.assert_unrelated_histories_stay_apart(&joined); + self.assert_stacked_commits_are_parent_and_child(); + } + + /// One snapshot per walked commit, ending in the live lane state. + fn assert_snapshots_track_the_walk(&self) { + assert_eq!( + self.snapshots.len(), + self.ids.len(), + "decompressing the whole walk lost rows" + ); + assert_eq!( + self.walker.processed_commit_count(), + self.ids.len(), + "the buffer holds a delta count the walk did not produce" + ); + if let Some(last) = self.snapshots.last() { + assert_eq!( + last, &self.walker.buffer.current, + "the last decompressed row is not the live state" + ); + } + } + + /// Every recorded operation addresses a column that exists, and a + /// column is only ever dropped once it is vacant. + /// + /// Replaying the stream is what makes the delta buffer's promise + /// testable. The three operations cannot express a sideways move + /// at all; this checks that nothing in the stream leans on a + /// column which was never opened, which is the other way a replay + /// could drift from the walk. + fn assert_delta_stream_is_well_formed(&self) { + let mut columns: Vec = Vec::new(); + + for (index, delta) in + self.walker.buffer.deltas.iter().enumerate() + { + for op in &delta.0 { + match *op { + DeltaOp::Set { + index: column, + slot, + } => { + assert!( + column < columns.len(), + "delta {index} sets column {column} of \ + {}", + columns.len() + ); + columns[column] = slot.is_some(); + } + DeltaOp::Push { .. } => columns.push(true), + DeltaOp::Pop => { + assert_eq!( + columns.pop(), + Some(false), + "delta {index} dropped a column that \ + still held a lane" + ); + } + } + } + } + } + + /// A lane keeps its column for as long as it stays open. + /// + /// Rows are drawn one at a time and joined to their neighbours by + /// column, so a lane that changed column between two rows would be + /// two line segments that do not meet. The only lanes allowed to + /// leave a column are the ones the row's own commit closes, by + /// being the parent they were waiting for. + fn assert_columns_hold_still(&self) { + for index in 1..self.snapshots.len() { + let alias = self.alias(index); + + for column in 0..self.snapshots[index - 1].len() { + let Some(awaited) = self + .slot(index - 1, column) + .and_then(|slot| slot.awaits()) + else { + continue; + }; + let now = self.slot(index, column); + + if awaited == alias { + assert!( + now.is_none() + || now.and_then(|slot| slot.alias()) + == Some(alias), + "row {index} took over column {column} with \ + a lane that is not its own commit\n{}", + self.picture() + ); + continue; + } + + assert_eq!( + now.and_then(|slot| slot.awaits()), + Some(awaited), + "a lane waiting for {awaited:?} left column \ + {column} between rows {} and {index}\n{}", + index - 1, + self.picture() + ); + } + } + } + + /// Each row's own bookkeeping agrees with what it drew. + fn assert_rows_are_well_formed(&self) { + for (index, row) in self.rows.iter().enumerate() { + let width = row.lanes.len(); + assert_eq!( + usize::from(row.lane_count), + width, + "row {index} reports a width it did not draw" + ); + + let markers = row + .lanes + .iter() + .enumerate() + .filter(|(_, cell)| { + cell.is_some_and(|(conn, _)| conn.is_commit()) + }) + .map(|(column, _)| column) + .collect::>(); + assert_eq!( + markers, + vec![usize::from(row.commit_lane)], + "row {index} draws its commit somewhere other than \ + its own lane\n{}", + self.picture() + ); + + assert_eq!( + row.is_merge, + self.parents[index].len() > 1, + "row {index} disagrees with Git about being a merge" + ); + assert_eq!( + row.is_branch_tip, + self.branch_tips.contains(&self.ids[index]), + "row {index} disagrees about being a branch tip" + ); + assert_eq!( + row.is_stash, + self.stashes.contains(&self.ids[index]), + "row {index} disagrees about being a stash" + ); + + for (from, to) in row + .merge_bridge + .into_iter() + .chain(row.branches.iter().copied()) + { + let (from, to) = (usize::from(from), usize::from(to)); + assert!(from < to, "row {index} has an empty bridge"); + assert!( + to < width, + "row {index} bridges past its own width" + ); + assert!( + from == usize::from(row.commit_lane) + || to == usize::from(row.commit_lane), + "row {index} has a bridge with neither end at \ + its commit" + ); + } + } + } + + /// No line in the picture just stops. + /// + /// Every line end has to point at a cell that takes it up. This is + /// the local form of the column invariant, read off the drawing + /// rather than the lane state: a lane that moved sideways leaves a + /// vertical hanging in the column it left and another one with + /// nothing above it in the column it moved to. + fn assert_no_dangling_lines(&self, picture: &Picture) { + let dangling = picture + .dangling_ends() + .iter() + .map(|&((row, column), side)| { + format!( + "row {row} column {column} runs {} into nothing", + side_name(side) + ) + }) + .collect::>(); + + assert!( + dangling.is_empty(), + "{}\n{}", + dangling.join("\n"), + self.picture() + ); + } + + /// Every edge the walk was entitled to draw can be followed in the + /// picture. + /// + /// This is the invariant a golden drawing cannot give us. It reads + /// the finished characters back into a history and insists that + /// each commit really does reach each of its parents through the + /// lines on screen, which is the whole claim the graph column + /// makes. + fn assert_every_parent_edge_is_drawn( + &self, + joined: &[BTreeSet], + ) { + for (index, reached) in joined.iter().enumerate() { + for parent in self.drawn_parent_rows(index) { + assert!( + reached.contains(&self.marker(parent)), + "row {index} cannot be followed to its parent \ + on row {parent}\n{}", + self.picture() + ); + } + } + } + + /// The picture never joins two commits from histories that share + /// no ancestry. + /// + /// Lanes are shared on purpose — a merge bridges into a lane that + /// already leads to the same parent instead of opening a second + /// one — so a drawing legitimately joins commits that are not + /// directly related. What it must never do is run a line between + /// two unrelated histories, which is what reusing a column too + /// eagerly looks like on screen. + fn assert_unrelated_histories_stay_apart( + &self, + joined: &[BTreeSet], + ) { + let components = self.ancestry_components(); + + for (index, reached) in joined.iter().enumerate() { + for &(row, _) in reached { + assert_eq!( + components[index], + components[row], + "the picture joins row {index} to row {row}, \ + which share no ancestry\n{}", + self.picture() + ); + } + } + } + + /// Two commits drawn one directly above the other in a column read + /// as joined, because a lane draws no line between consecutive + /// commits on it. So they have to be parent and child. + fn assert_stacked_commits_are_parent_and_child(&self) { + for index in 1..self.rows.len() { + if self.rows[index - 1].commit_lane + != self.rows[index].commit_lane + { + continue; + } + + assert!( + self.drawn_parent_rows(index - 1).contains(&index), + "rows {} and {index} are drawn stacked in column \ + {:?} but are not parent and child\n{}", + index - 1, + self.rows[index].commit_lane, + self.picture() + ); + } + } + + /// Reading a window of rows draws the same thing as reading the + /// whole walk and slicing it. + /// + /// The log view only ever asks for the rows it can show, so every + /// window is rebuilt from the delta buffer and the nearest + /// checkpoint. Short histories check every offset; longer ones + /// sample, which still puts a checkpoint boundary inside the + /// window for any history long enough to have one. + fn assert_windows_decompress_exactly(&self) { + let total = self.ids.len(); + let stride = total.div_ceil(EXHAUSTIVE_WINDOW_ROWS).max(1); + + for length in [1, 2, 3, 7, total] { + if length == 0 || length > total { + continue; + } + + for start in (0..=total - length).step_by(stride) { + let end = start + length; + let window = self.walker.compute_rows( + &self.ids[start..end], + start, + &self.branch_tips, + &self.stashes, + self.head.as_ref(), + ); + + assert_eq!( + window, + self.rows[start..end], + "rows {start}..{end} drew differently on their \ + own\n{}", + self.picture() + ); + } + } + } + + /// A component id per row, joining each commit to the parents it + /// draws an edge to. + /// + /// Two children of the same unwalked parent land in one component + /// as well: they share ancestry the window does not show, and the + /// picture is allowed to route both through one lane. + fn ancestry_components(&self) -> Vec { + let mut parent_keys: HashMap = + HashMap::new(); + let mut union = Union::new(self.rows.len()); + + for index in 0..self.rows.len() { + for parent in self.drawn_parents(index) { + if let Some(row) = self.row_of.get(&parent) { + union.join(index, *row); + continue; + } + + let next = union.len(); + let key = *parent_keys.entry(parent).or_insert(next); + if key == next { + union.grow(); + } + union.join(index, key); + } + } + + (0..self.rows.len()).map(|row| union.root(row)).collect() + } +} + +/// Disjoint-set forest over row indices. +struct Union(Vec); + +impl Union { + fn new(size: usize) -> Self { + Self((0..size).collect()) + } + + fn len(&self) -> usize { + self.0.len() + } + + fn grow(&mut self) { + self.0.push(self.0.len()); + } + + fn root(&mut self, mut node: usize) -> usize { + while self.0[node] != node { + let parent = self.0[node]; + self.0[node] = self.0[parent]; + node = self.0[node]; + } + node + } + + fn join(&mut self, left: usize, right: usize) { + let (left, right) = (self.root(left), self.root(right)); + if left != right { + self.0[left] = right; + } + } +} + +/// A deterministic generator, so a generated topology is reproducible +/// from its seed alone without pulling in a property-testing +/// dependency. +struct Rng(u64); + +impl Rng { + const fn new(seed: u64) -> Self { + // Keep the low bit of the state moving. + Self(seed.wrapping_mul(2).wrapping_add(1)) + } + + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.0 ^ (self.0 >> 33) + } + + /// A value in `0..upper`, or `0` when `upper` is zero. + fn below(&mut self, upper: usize) -> usize { + let upper = u64::try_from(upper).unwrap_or(u64::MAX).max(1); + usize::try_from(self.next() % upper).unwrap_or(0) + } + + /// True one time in `odds`. + fn chance(&mut self, odds: usize) -> bool { + self.below(odds) == 0 + } +} + +/// The kind of history to generate. +#[derive(Clone, Copy, Debug)] +pub enum Shape { + /// A single lane, each commit the parent of the one above it. + Linear, + /// Long-running branches that merge back often. + MergeHeavy, + /// Short topic branches off a trunk, merged or abandoned. + Topics, + /// Several disjoint histories, each ending in its own root. + ManyRoots, + /// Parent lists Git permits and a walk has to survive: duplicated + /// entries, octopus merges, and parents a clock-skewed walk + /// reaches before their children. + Adversarial, +} + +impl Shape { + pub const ALL: [Self; 5] = [ + Self::Linear, + Self::MergeHeavy, + Self::Topics, + Self::ManyRoots, + Self::Adversarial, + ]; +} + +fn push_later( + parents: &mut Vec, + name: usize, + remaining: usize, + offset: usize, +) { + if offset > 0 && offset <= remaining { + parents.push(name + offset); + } +} + +/// Generate a history of `commits` rows, newest first. +/// +/// Commits are named `1..=commits` and listed in that order, so a +/// parent named above its child is one the walk reaches later: the +/// ordering a topological walk produces. [`Shape::Adversarial`] breaks +/// that on purpose for some parents, which is what a repository with +/// skewed commit times hands the walker. +pub fn generate( + seed: u64, + commits: usize, + shape: Shape, +) -> Vec<(usize, Vec)> { + let mut rng = Rng::new(seed); + let mut history = Vec::with_capacity(commits); + + for index in 0..commits { + let name = index + 1; + let left = commits - index - 1; + let mut parents: Vec = Vec::new(); + + match shape { + Shape::Linear => { + push_later(&mut parents, name, left, 1); + } + Shape::MergeHeavy => { + push_later(&mut parents, name, left, 1); + if rng.chance(3) { + let offset = 2 + rng.below(10); + push_later(&mut parents, name, left, offset); + } + } + Shape::Topics => { + let offset = 1 + rng.below(3); + push_later(&mut parents, name, left, offset); + if rng.chance(4) { + let offset = 2 + rng.below(20); + push_later(&mut parents, name, left, offset); + } + } + Shape::ManyRoots => { + if !rng.chance(8) { + push_later(&mut parents, name, left, 1); + } + } + Shape::Adversarial => { + push_later(&mut parents, name, left, 1); + if rng.chance(3) { + let offset = 1 + rng.below(6); + push_later(&mut parents, name, left, offset); + } + if rng.chance(5) { + let offset = 1 + rng.below(6); + push_later(&mut parents, name, left, offset); + } + // A parent the walk has already passed, and a + // repeated entry. Both are legal in a commit object + // and neither may open a lane. + if rng.chance(4) && index > 0 { + parents.push(1 + rng.below(index)); + } + if rng.chance(3) { + if let Some(first) = parents.first().copied() { + parents.push(first); + } + } + } + } + + history.push((name, parents)); + } + + history +} + +/// Walk a generated history and hold it to every invariant. +pub fn walk_generated( + seed: u64, + commits: usize, + shape: Shape, +) -> Scene { + let history = generate(seed, commits, shape); + let borrowed = history + .iter() + .map(|(commit, parents)| (*commit, parents.as_slice())) + .collect::>(); + + Scene::walk(&borrowed) +} + +/// Assert a `LaneIndex` round-trips the column it was built from, so a +/// wide graph is reported honestly rather than clamped. +pub fn assert_lane_index_exact(column: usize) { + assert_eq!( + usize::from(LaneIndex::from(column)), + column, + "column {column} does not survive a LaneIndex round trip" + ); +} diff --git a/asyncgit/src/graph/harness/readback.rs b/asyncgit/src/graph/harness/readback.rs new file mode 100644 index 0000000000..616a92206d --- /dev/null +++ b/asyncgit/src/graph/harness/readback.rs @@ -0,0 +1,240 @@ +//! Reads a drawn graph back into the history it depicts. +//! +//! Everything else in these tests would share the renderer's own idea +//! of what a glyph means, so a mistake in that table would be agreed +//! with rather than caught. This module starts from the characters +//! instead. `┫` joins up, down and left because that is what BOX +//! DRAWINGS HEAVY VERTICAL AND LEFT *is*, not because the walker says +//! so, and the table below is the only thing this module is told. +//! +//! What comes out is the history a person reads off the screen. Two +//! rules make that reading precise: +//! +//! - A commit marker is a node. Lines meet there and paths end there, +//! so two commits are joined only by a run of line cells with no +//! other commit on it. Two markers drawn one directly above the +//! other in a column are joined as well, since that is how a lane +//! with no line between consecutive commits is drawn. +//! - A plain vertical is transparent to a horizontal run: `o━┃━┛` +//! reads as a bridge passing *behind* an unrelated lane, not as a +//! junction with it. A run that really joins a lane is drawn as one +//! of the tees, so entering a vertical sideways can only leave it +//! sideways. + +use std::collections::{BTreeSet, HashSet, VecDeque}; + +/// The side of a cell a line leaves by. +pub const UP: u8 = 1 << 0; +pub const DOWN: u8 = 1 << 1; +pub const LEFT: u8 = 1 << 2; +pub const RIGHT: u8 = 1 << 3; + +/// Row and column of a cell in the drawing. +pub type Cursor = (usize, usize); + +pub const fn side_name(side: u8) -> &'static str { + match side { + UP => "up", + DOWN => "down", + LEFT => "left", + _ => "right", + } +} + +const fn opposite(side: u8) -> u8 { + match side { + UP => DOWN, + DOWN => UP, + LEFT => RIGHT, + _ => LEFT, + } +} + +/// What a character is, going by the character alone. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Cell { + /// A commit. Lines meet here, and a path through the drawing ends + /// here. + Node, + /// A line joining exactly these sides. + Joint(u8), + /// A plain vertical, which a horizontal run passes behind without + /// joining. + Crossing, + /// Nothing drawn. + Blank, +} + +fn classify(cell: char) -> Cell { + match cell { + 'o' | 'M' | '*' | '@' | '+' => Cell::Node, + '┃' | '╏' => Cell::Crossing, + '━' => Cell::Joint(LEFT | RIGHT), + '┓' => Cell::Joint(DOWN | LEFT), + '┏' => Cell::Joint(DOWN | RIGHT), + '┛' => Cell::Joint(UP | LEFT), + '┗' => Cell::Joint(UP | RIGHT), + '┫' => Cell::Joint(UP | DOWN | LEFT), + '┣' => Cell::Joint(UP | DOWN | RIGHT), + '┻' => Cell::Joint(UP | LEFT | RIGHT), + '┳' => Cell::Joint(DOWN | LEFT | RIGHT), + '╋' => Cell::Joint(UP | DOWN | LEFT | RIGHT), + // A blank, and anything the log view does not draw as part of + // the graph, carries no line. + _ => Cell::Blank, + } +} + +/// A rectangular picture: one `char` per cell, every row the same +/// width. +pub struct Picture(Vec>); + +impl Picture { + /// Pad `rows` into a rectangle. + /// + /// Padding matters: a line arriving in a column that the row above + /// does not even reach is exactly the failure this module is here + /// to find, so the short row has to be filled with blanks rather + /// than treated as absent. + pub fn new(rows: &[Vec]) -> Self { + let width = + rows.iter().map(Vec::len).max().unwrap_or_default(); + Self( + rows.iter() + .map(|row| { + let mut row = row.clone(); + row.resize(width, ' '); + row + }) + .collect(), + ) + } + + fn at(&self, (row, column): Cursor) -> char { + self.0 + .get(row) + .and_then(|row| row.get(column)) + .copied() + .unwrap_or(' ') + } + + fn neighbour( + &self, + (row, column): Cursor, + side: u8, + ) -> Option { + let next = match side { + UP => (row.checked_sub(1)?, column), + DOWN => (row + 1, column), + LEFT => (row, column.checked_sub(1)?), + _ => (row, column + 1), + }; + + (next.0 < self.0.len() + && next.1 < self.0.first().map_or(0, Vec::len)) + .then_some(next) + } + + /// Whether a line arriving from `from` is taken up by `cell`. + fn accepts(cell: char, from: u8) -> bool { + match classify(cell) { + // A commit takes a line from any side, and a vertical + // takes one from any side too: from above or below it + // continues the lane, from either side it passes behind. + Cell::Node | Cell::Crossing => true, + Cell::Joint(sides) => sides & from != 0, + Cell::Blank => false, + } + } + + /// The cell a line leaving `cursor` by `side` arrives in, if there + /// is one there to take it. + fn step(&self, cursor: Cursor, side: u8) -> Option { + let next = self.neighbour(cursor, side)?; + Self::accepts(self.at(next), opposite(side)).then_some(next) + } + + /// Which commits the drawing joins to the commit at `start`. + pub fn commits_joined_to( + &self, + start: Cursor, + ) -> BTreeSet { + let mut found = BTreeSet::new(); + let mut seen: HashSet<(Cursor, u8)> = HashSet::new(); + let mut queue: VecDeque<(Cursor, u8)> = VecDeque::new(); + + // A commit sends a line out of all four sides; which of them + // go anywhere is decided by the cell on the other side. + for side in [UP, DOWN, LEFT, RIGHT] { + if let Some(next) = self.step(start, side) { + queue.push_back((next, opposite(side))); + } + } + + while let Some((cursor, entered)) = queue.pop_front() { + if !seen.insert((cursor, entered)) { + continue; + } + + let onward = match classify(self.at(cursor)) { + Cell::Node => { + if cursor != start { + found.insert(cursor); + } + continue; + } + Cell::Blank => continue, + // Sideways in, sideways out; a run that joined this + // lane would have been drawn as a tee. + Cell::Crossing => opposite(entered), + Cell::Joint(sides) => sides & !entered, + }; + + for side in [UP, DOWN, LEFT, RIGHT] { + if onward & side == 0 { + continue; + } + if let Some(next) = self.step(cursor, side) { + queue.push_back((next, opposite(side))); + } + } + } + + found + } + + /// Every line end that points at a cell with nothing to take it. + /// + /// A stub pointing off the edge of the picture is left alone: the + /// log view shows a window onto a longer history, so lines running + /// off the top and bottom are the normal case. + pub fn dangling_ends(&self) -> Vec<(Cursor, u8)> { + let mut dangling = Vec::new(); + + for (row, cells) in self.0.iter().enumerate() { + for (column, cell) in cells.iter().enumerate() { + let sides = match classify(*cell) { + Cell::Joint(sides) => sides, + // Only the lane is a line end; the sideways + // transparency is not one. + Cell::Crossing => UP | DOWN, + Cell::Node | Cell::Blank => continue, + }; + + for side in [UP, DOWN, LEFT, RIGHT] { + if sides & side == 0 { + continue; + } + let cursor = (row, column); + if self.neighbour(cursor, side).is_some() + && self.step(cursor, side).is_none() + { + dangling.push((cursor, side)); + } + } + } + } + + dangling + } +} diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index f05f52697c..ce5293ebdb 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -3,6 +3,9 @@ pub mod chunk; pub mod oids; pub mod walker; +#[cfg(test)] +mod harness; + pub use walker::GraphWalker; /// The maximum number of colors to use for graph lanes @@ -101,6 +104,22 @@ impl ConnectionType { pub const fn is_dotted(self) -> bool { matches!(self, Self::VerticalDotted) } + + /// Whether this is a commit marker rather than a line. + /// + /// A marker carries no line of its own and is never drawn over: + /// whether a line reaches it from above, or leaves it below, is a + /// fact about the commit's lane and not about the glyph. + pub const fn is_commit(self) -> bool { + matches!( + self, + Self::CommitNormal + | Self::CommitBranch + | Self::CommitMerge + | Self::CommitStash + | Self::CommitUncommitted + ) + } } #[derive(Clone, Debug, Default, PartialEq, Eq)] diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 4cb1fad2e5..e3e3362adf 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -811,122 +811,54 @@ impl GraphWalker { } } } + #[cfg(test)] mod tests { use super::*; - - fn id(n: usize) -> CommitId { - CommitId::from_str_unchecked(&format!("{n:040x}")) - .expect("valid oid") - } - - fn sym(conn: ConnectionType) -> char { - match conn { - ConnectionType::Vertical => '┃', - ConnectionType::VerticalDotted => '╏', - ConnectionType::CommitNormal => 'o', - ConnectionType::CommitBranch => '*', - ConnectionType::CommitMerge => 'M', - ConnectionType::CommitStash => '*', - ConnectionType::CommitUncommitted => '+', - ConnectionType::MergeBridgeStart => '┓', - ConnectionType::MergeBridgeMid => '━', - ConnectionType::MergeBridgeEnd => '┏', - ConnectionType::BranchUp => '┛', - ConnectionType::BranchUpRight => '┗', - ConnectionType::TeeLeft => '┫', - ConnectionType::TeeRight => '┣', - ConnectionType::TeeUp => '┻', - ConnectionType::TeeDown => '┳', - ConnectionType::Cross => '╋', - } - } - - /// Render a row the way the UI does: one glyph per lane plus a - /// spacer that carries a bridge's horizontal run. - fn row_to_string(row: &GraphRow) -> String { - let mut out = String::new(); - for (lane_index, conn) in row.lanes.iter().enumerate() { - out.push(conn.map_or(' ', |(c, _)| sym(c))); - - let in_bridge = row - .merge_bridge - .into_iter() - .chain(row.branches.iter().copied()) - .any(|(from, to)| { - lane_index >= usize::from(from) - && lane_index < usize::from(to) - }); - out.push(if in_bridge { '━' } else { ' ' }); - } - out.trim_end().to_string() - } - - /// Walk `history` (newest first, `(commit, parents)`) and render - /// every row. - fn render(history: &[(usize, &[usize])]) -> Vec { - let mut walker = GraphWalker::new(); - let ids: Vec = - history.iter().map(|(c, _)| id(*c)).collect(); - - for (commit, parents) in history { - let parents: Vec = - parents.iter().map(|p| id(*p)).collect(); - walker.process(id(*commit), &parents); - } - - walker - .compute_rows( - &ids, - 0, - &HashSet::new(), - &HashSet::new(), - None, - ) - .iter() - .map(row_to_string) - .collect() - } + use crate::graph::harness::{ + assert_lane_index_exact, oid, walk_generated, Scene, Shape, + }; #[test] fn linear_history() { - let rows = render(&[(1, &[2]), (2, &[3]), (3, &[])]); - assert_eq!(rows, vec!["o", "o", "o"]); + Scene::walk(&[(1, &[2]), (2, &[3]), (3, &[])]) + .assert_drawing(&["o", "o", "o"]); } #[test] fn history_with_multiple_roots() { // Two independent commit chains, each terminating in its own - // root (a commit with no parents), STAY IN THEIR LANE! - let rows = - render(&[(1, &[2]), (2, &[]), (3, &[4]), (4, &[])]); - assert_eq!(rows, vec!["o", "o", " o", " o"]); + // root (a commit with no parents), STAY IN THEIR LANE! A root + // draws a marker and no line below it, so handing its column + // to the next history would stack two commit markers in one + // column — which is exactly how a lane draws two consecutive + // commits, and would read as joining the two histories. + let scene = + Scene::walk(&[(1, &[2]), (2, &[]), (3, &[4]), (4, &[])]); + scene.assert_drawing(&["o", "o", " o", " o"]); + scene.assert_fully_settled(); } #[test] fn simple_merge() { // 1 merges 3 into the line 1 → 2 → 4, 3 → 4 - let rows = - render(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]); - assert_eq!(rows, vec!["M━┓", "o ┃", "┃ o", "o━┛"]); + Scene::walk(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]) + .assert_drawing(&["M━┓", "o ┃", "┃ o", "o━┛"]); } #[test] fn merge_into_tracked_lane_continues_through_corner() { // 3's merge line joins lane 1 which keeps flowing to 5, // so the corner must be a junction (┫), not a dead end (┓) - let rows = render(&[ + Scene::walk(&[ (1, &[3]), (2, &[5]), (3, &[4, 5]), (4, &[6]), (5, &[6]), (6, &[]), - ]); - assert_eq!( - rows, - vec!["o", "┃ o", "M━┫", "o ┃", "┃ o", "o━┛"] - ); + ]) + .assert_drawing(&["o", "┃ o", "M━┫", "o ┃", "┃ o", "o━┛"]); } #[test] @@ -934,7 +866,7 @@ mod tests { // 3 (lane 2) merges into 1's line (lane 0) while 2's line // (lane 1) passes through: the crossed lane keeps its // vertical instead of being cut by the bridge - let rows = render(&[ + Scene::walk(&[ (1, &[4]), (2, &[5]), (3, &[6, 4]), @@ -942,19 +874,16 @@ mod tests { (5, &[7]), (6, &[7]), (7, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┣━┃━M", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", ]); - assert_eq!( - rows, - vec![ - "o", - "┃ o", - "┣━┃━M", - "o ┃ ┃", - "┃ o ┃", - "┃ ┃ o", - "o━┻━┛", - ] - ); } #[test] @@ -962,16 +891,21 @@ mod tests { // lanes 1 and 2 both close into the commit on lane 0; the // outer bridge passes through the inner corner (┻) instead // of erasing it - let rows = - render(&[(1, &[4]), (2, &[4]), (3, &[4]), (4, &[])]); - assert_eq!(rows, vec!["o", "┃ o", "┃ ┃ o", "o━┻━┛"]); + Scene::walk(&[(1, &[4]), (2, &[4]), (3, &[4]), (4, &[])]) + .assert_drawing(&["o", "┃ o", "┃ ┃ o", "o━┻━┛"]); } #[test] - fn merge_and_branch_bridges_overlap() { - // commit 3 closes a branch from lane 2 while opening a merge - // to lane 3, crossing lane 1: every line stays continuous - let rows = render(&[ + fn nested_merges_keep_every_lane_in_its_column() { + // Commit 3 closes a branch while opening a merge of its own. + // The lane reserved for commit 1's second parent is already + // open in column 1, and has to stay there: moving it along to + // make room next to commit 3 would leave its line with + // nothing above it in the column it moved to, and would take + // commit 2's closing bridge with it. The merge's own lane goes + // at the right-hand end instead, and the bridge reaches it + // across the lanes in between. + Scene::walk(&[ (1, &[3, 4]), (2, &[3]), (3, &[5, 6]), @@ -979,27 +913,143 @@ mod tests { (5, &[7]), (6, &[7]), (7, &[]), + ]) + .assert_drawing(&[ + "M━┓", + "┃ ┃ o", + "M━┃━┻━┓", + "┃ o ┃", + "o━┛ ┃", + "┃ o", + "o━━━━━┛", + ]); + } + + #[test] + fn second_parent_lane_takes_a_column_left_blank_above() { + // Commit 4 closes the lane in column 1, so by the time commit + // 5 merges, the row above it draws nothing there. Starting the + // second parent's lane in that column keeps the bridge one + // step long instead of reaching past every lane still open. + Scene::walk(&[ + (1, &[4]), + (2, &[4]), + (3, &[9]), + (4, &[5]), + (5, &[6, 7]), + (6, &[9]), + (7, &[9]), + (9, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "o━┛ ┃", + "M━┓ ┃", + "o ┃ ┃", + "┃ o ┃", + "o━┻━┛", + ]); + } + + #[test] + fn second_parent_lane_skips_a_column_this_commit_freed() { + // Commit 4 both closes the lane in column 1 and opens a merge. + // Column 1 is vacant by the end of the step, but the row above + // still draws commit 2's lane there and this row draws its + // closing corner, so the merge's second parent cannot have it + // — taking it would put a new lane's corner where that edge + // ends and lose the edge. + Scene::walk(&[ + (1, &[4]), + (2, &[4]), + (3, &[7]), + (4, &[5, 6]), + (5, &[8]), + (6, &[8]), + (7, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "M━┻━┃━┓", + "o ┃ ┃", + "┃ ┃ o", + "┃ o ┃", + "o━━━┻━┛", + ]); + } + + #[test] + fn merge_bridges_to_the_lane_its_second_parent_already_has() { + // Commit 2 is a merge that found a lane already heading for + // its second parent, so it owes that parent nothing and keeps + // a lane of its own open for it. Commit 3 then merges the + // same commit 6 that commit 2's lane is waiting for: it has to + // bridge into that lane, not reserve a second column for a + // parent only one lane can receive. A second column would be + // left drawing a line that starts out of nothing, because the + // bridge is always drawn to the first lane waiting. + Scene::walk(&[ + (1, &[5]), + (2, &[6, 5]), + (3, &[7, 6]), + (5, &[8]), + (6, &[8]), + (7, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┣━M", + "┃ ┣━M", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", + ]); + } + + #[test] + fn a_bridge_crossing_another_bridges_junction_keeps_it() { + // Commit 4 does two things at once: it merges commit 6, whose + // lane is already open in column 1, and it closes the branch + // in column 2. The branch's bridge has to reach across column + // 1, which is where the merge's bridge ends. That cell joins + // up, down, left and right, and there is no way to draw it as + // a lane with something passing behind — drawing it as a plain + // vertical would take the merge edge out of the picture. + Scene::walk(&[ + (1, &[4]), + (2, &[6]), + (3, &[4]), + (4, &[5, 6]), + (5, &[8]), + (6, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "M━╋━┛", + "o ┃", + "┃ o", + "o━┛", ]); - assert_eq!( - rows, - vec![ - "M━┓", - "┃ ┃ o", - "M━┃━┻━┓", - "┃ o ┃", - "o━┛ ┃", - "┃ o", - "o━━━━━┛", - ] - ); } #[test] fn skewed_parent_before_child_leaves_no_phantom_lane() { - //1 to 2 must be dropped instead of - // opening a lane that waits for 2 until the end of the walk. - let rows = render(&[(2, &[3]), (1, &[2, 3]), (3, &[])]); - assert_eq!(rows, vec!["o", "┃ M", "o━┛"]); + // Commit 2 is walked before commit 1, which lists it as a + // parent. The edge up to it cannot be drawn, and must not open + // a lane that waits for a commit the walk has gone past. The + // marker still says merge, because commit 1 is one. + Scene::walk(&[(2, &[3]), (1, &[2, 3]), (3, &[])]) + .assert_drawing(&["o", "┃ M", "o━┛"]); } #[test] @@ -1007,56 +1057,104 @@ mod tests { // Both parents of 1 appear before it in the walk (clock // skew). After a complete walk no lane may still wait on a // parent, creating the evil phantom lines - let history: &[(usize, &[usize])] = - &[(2, &[4]), (3, &[4]), (1, &[2, 3]), (4, &[])]; + let scene = Scene::walk(&[ + (2, &[4]), + (3, &[4]), + (1, &[2, 3]), + (4, &[]), + ]); + scene.assert_drawing(&["o", "┃ o", "┃ ┃ M", "o━┛"]); + scene.assert_fully_settled(); + } - assert_eq!(render(history), vec!["o", "┃ o", "┃ ┃ M", "o━┛"]); + #[test] + fn duplicate_parent_entries_draw_one_edge() { + // A commit object may name the same parent twice. There is one + // edge to draw, and the second entry must not reserve a lane + // for a commit that the first entry's lane already receives. + Scene::walk(&[(1, &[2, 2]), (2, &[3]), (3, &[])]) + .assert_drawing(&["M", "o", "o"]); + } - let mut walker = GraphWalker::new(); - for (commit, parents) in history { - let parents: Vec = - parents.iter().map(|p| id(*p)).collect(); - walker.process(id(*commit), &parents); - } + #[test] + fn octopus_merge_draws_its_first_two_parents() { + // Support stops at two parents. The third parent's commit + // still appears, on a lane of its own with no edge into it, + // which is the honest picture of an edge this graph cannot + // show — not a lane left waiting for something. + let scene = Scene::walk(&[ + (1, &[2, 3, 4]), + (2, &[5]), + (3, &[5]), + (4, &[5]), + (5, &[]), + ]); + scene.assert_drawing(&[ + "M━┓", + "o ┃", + "┃ o", + "┃ ┃ o", + "o━┻━┛", + ]); + scene.assert_fully_settled(); + } - for slot in walker.buffer.current.iter().flatten() { - assert!( - matches!(slot, LaneSlot::Settled { .. }), - "lane still waiting on a parent after a complete walk: {slot:?}" - ); - } + #[test] + fn a_commit_naming_itself_as_a_parent_opens_no_lane() { + let scene = Scene::walk(&[(1, &[1, 2]), (2, &[])]); + scene.assert_drawing(&["M", "o"]); + scene.assert_fully_settled(); + } + + #[test] + fn unwalked_parents_keep_their_lanes_open() { + // The log view shows a window onto a longer history, so a lane + // heading for a commit below the window stays open and draws + // off the bottom of the picture. + let scene = Scene::walk(&[(1, &[8]), (2, &[9])]); + scene.assert_drawing(&["o", "┃ o"]); + assert_eq!(scene.open_lane_count(), 2); + } + + #[test] + fn head_lane_is_dotted_in_any_column() { + // HEAD is not guaranteed to sit on the leftmost lane, so the + // dotted segment has to follow the lane heading for it. + Scene::walk_marked( + &[(1, &[3]), (2, &[4]), (3, &[5]), (4, &[5]), (5, &[])], + &[], + &[], + Some(4), + ) + .assert_drawing(&["o", "┃ o", "o ╏", "┃ o", "o━┛"]); + } + + #[test] + fn branch_tip_and_stash_markers_leave_the_lines_alone() { + Scene::walk_marked( + &[(1, &[2]), (2, &[3]), (3, &[])], + &[1], + &[3], + None, + ) + .assert_drawing(&["*", "o", "*"]); } #[test] fn crossed_lane_keeps_own_color() { - let rows = &[ - (1usize, &[4usize][..]), + let scene = Scene::walk(&[ + (1, &[4]), (2, &[5]), (3, &[6, 4]), (4, &[7]), (5, &[7]), (6, &[7]), (7, &[]), - ]; - let mut walker = GraphWalker::new(); - let ids: Vec = - rows.iter().map(|(c, _)| id(*c)).collect(); - for (commit, parents) in rows { - let parents: Vec = - parents.iter().map(|p| id(*p)).collect(); - walker.process(id(*commit), &parents); - } - let computed = walker.compute_rows( - &ids, - 0, - &HashSet::new(), - &HashSet::new(), - None, - ); + ]); // row of commit 3: lane 1 is crossed by the merge bridge but // keeps both its vertical glyph and its own lane color - let crossed = computed[2].lanes[1] + let crossed = scene.rows[2].lanes[1] .expect("crossed lane should not be empty"); assert_eq!(crossed.0, ConnectionType::Vertical); assert_eq!(crossed.1, lane_color(1)); @@ -1065,40 +1163,99 @@ mod tests { #[test] fn branch_bridge_crosses_unrelated_lane_keeps_own_color() { // 5 is the fork point for both 1 and 3 - let history: &[(usize, &[usize])] = &[ + let scene = Scene::walk(&[ (1, &[5]), (2, &[6]), (3, &[5]), (5, &[8]), (6, &[8]), (8, &[]), - ]; + ]); + scene.assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "o━┃━┛", + "┃ o", + "o━┛", + ]); + + // row of commit 5: lane 1 is crossed by the branch bridge but + // keeps both its vertical glyph and its own lane color (visual identity mans) + let crossed = scene.rows[3].lanes[1] + .expect("crossed lane should not be empty"); + assert_eq!(crossed.0, ConnectionType::Vertical); + assert_eq!(crossed.1, lane_color(1)); + } + + #[test] + fn windows_decompress_across_checkpoint_boundaries() { + // Long enough to take several lane-state checkpoints, which is + // the path the log view uses for every scroll. The window + // comparison itself is part of every scene's invariants. + let scene = walk_generated(7, 250, Shape::MergeHeavy); + assert!( + scene.walker.buffer.checkpoints.len() >= 2, + "expected the walk to take checkpoints" + ); + } + + #[test] + fn a_wide_history_reports_every_column() { + // One lane per commit, none of them ever closing. A column + // index has to survive being reported: clamping it would + // relabel every lane past the clamp as the same column and + // put their edges in the wrong place. + const LANES: usize = 300; + + let history = (1..=LANES) + .map(|commit| (commit, vec![10_000 + commit])) + .collect::>(); + let borrowed = history + .iter() + .map(|(commit, parents)| (*commit, parents.as_slice())) + .collect::>(); + + let scene = Scene::walk(&borrowed); + let last = &scene.rows[LANES - 1]; + + assert_eq!(usize::from(last.lane_count), LANES); + assert_eq!(usize::from(last.commit_lane), LANES - 1); + assert_eq!(scene.open_lane_count(), LANES); + assert_lane_index_exact(LANES - 1); + } + + #[test] + fn rows_requested_beyond_the_walk_draw_nothing() { let mut walker = GraphWalker::new(); - let ids: Vec = - history.iter().map(|(c, _)| id(*c)).collect(); - for (commit, parents) in history { - let parents: Vec = - parents.iter().map(|p| id(*p)).collect(); - walker.process(id(*commit), &parents); - } - let computed = walker.compute_rows( - &ids, - 0, + walker.process(oid(1), &[oid(2)]); + + let rows = walker.compute_rows( + &[oid(2), oid(3)], + 1, &HashSet::new(), &HashSet::new(), None, ); - assert_eq!( - computed.iter().map(row_to_string).collect::>(), - vec!["o", "┃ o", "┃ ┃ o", "o━┃━┛", "┃ o", "o━┛"] - ); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|row| row.lanes.is_empty())); + } - // row of commit 5: lane 1 is crossed by the branch bridge but - // keeps both its vertical glyph and its own lane color (visual identity mans) - let crossed = computed[3].lanes[1] - .expect("crossed lane should not be empty"); - assert_eq!(crossed.0, ConnectionType::Vertical); - assert_eq!(crossed.1, lane_color(1)); + #[test] + fn generated_histories_hold_every_invariant() { + // Scene::walk holds each of these to every invariant, so this + // is the broad sweep behind the drawings above: the shapes + // cover long-lived branches, short topics, disjoint roots, and + // the parent lists Git permits but a naive walk mishandles. + const COMMITS: usize = 70; + const SEEDS: u64 = 10; + + for shape in Shape::ALL { + for seed in 0..SEEDS { + eprintln!("graph topology: {shape:?} seed {seed}"); + walk_generated(seed, COMMITS, shape); + } + } } } From 4758624bef044a48b273c52b85cac0488d6c24d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 02:43:05 +0000 Subject: [PATCH 76/84] graph: preserve lane continuity when merge state changes Compare lane occupants by owning commit and awaited parent instead of the whole LaneSlot. A merge slot becoming an ordinary flowing slot keeps its route and still needs a junction when another bridge reaches that lane. Add a shared-parent regression and generated histories whose first parents skip side branches. Extend the sweeps across checkpoint boundaries. --- asyncgit/src/graph/chunk.rs | 15 ++++++++++ asyncgit/src/graph/harness/mod.rs | 22 +++++++++++++- asyncgit/src/graph/walker.rs | 49 ++++++++++++++++++++++++++++--- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index d1d7ce92f7..78c305090a 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -59,6 +59,21 @@ impl LaneSlot { } } + /// What makes this the same lane from one row to the next: the + /// commit it belongs to, together with the parent it waits for. + /// + /// Whole slots cannot answer that question. A merge that still + /// owes a second parent keeps its lane when that parent is + /// finally placed, but stops being a [`Self::FlowingMerge`] in + /// the process, and comparing slots by value calls the result a + /// different lane — which cuts the lane's line at exactly the row + /// where a bridge is reaching it. + pub fn identity( + &self, + ) -> (Option, Option) { + (self.alias(), self.awaits()) + } + /// Whether this lane's [`Self::awaits`] alias is `alias` — the /// parent of a `Flowing`/`FlowingMerge` lane, or the reservation /// held by a `Reserved` lane. diff --git a/asyncgit/src/graph/harness/mod.rs b/asyncgit/src/graph/harness/mod.rs index 95b0db06e4..1399b11bf5 100644 --- a/asyncgit/src/graph/harness/mod.rs +++ b/asyncgit/src/graph/harness/mod.rs @@ -738,6 +738,16 @@ pub enum Shape { Topics, /// Several disjoint histories, each ending in its own root. ManyRoots, + /// Merges whose own first parent is a commit later merges reach + /// too. + /// + /// Both parents are drawn from the same spread, so a mainline can + /// skip over a side branch the way a real one does. Shapes whose + /// first parent is always the very next commit cannot produce + /// that, and so cannot produce a lane that is left waiting for a + /// commit while another merge still owes it — which is the state a + /// merge bridge has to reach into. + Entangled, /// Parent lists Git permits and a walk has to survive: duplicated /// entries, octopus merges, and parents a clock-skewed walk /// reaches before their children. @@ -745,11 +755,12 @@ pub enum Shape { } impl Shape { - pub const ALL: [Self; 5] = [ + pub const ALL: [Self; 6] = [ Self::Linear, Self::MergeHeavy, Self::Topics, Self::ManyRoots, + Self::Entangled, Self::Adversarial, ]; } @@ -809,6 +820,15 @@ pub fn generate( push_later(&mut parents, name, left, 1); } } + Shape::Entangled => { + const SPAN: usize = 10; + let offset = 1 + rng.below(SPAN); + push_later(&mut parents, name, left, offset); + if rng.chance(2) { + let offset = 1 + rng.below(SPAN); + push_later(&mut parents, name, left, offset); + } + } Shape::Adversarial => { push_later(&mut parents, name, left, 1); if rng.chance(3) { diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index e3e3362adf..74794c1bf5 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -531,7 +531,11 @@ impl GraphWalker { }; let occupant = |snapshot: &[Option]| { - snapshot.get(target_lane).copied().flatten() + snapshot + .get(target_lane) + .copied() + .flatten() + .map(|slot| slot.identity()) }; let current = occupant(current_snapshot); @@ -861,6 +865,37 @@ mod tests { .assert_drawing(&["o", "┃ o", "M━┫", "o ┃", "┃ o", "o━┛"]); } + #[test] + fn merge_into_a_lane_that_stops_owing_a_parent_keeps_flowing() { + // Commit 2 is a merge that found a lane already heading for + // its second parent, so its own lane keeps that debt open. + // Commit 4 settles it — commit 4 *is* that second parent — and + // in the same row merges the commit that lane is waiting for, + // so its bridge ends there. That lane runs through the row, + // which makes the bridge end a junction; it is only the slot's + // shape that changed, not the lane. + Scene::walk(&[ + (1, &[4]), + (2, &[6, 4]), + (3, &[7]), + (4, &[5, 6]), + (5, &[8]), + (6, &[8]), + (7, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┣━M", + "┃ ┃ o", + "M━┫ ┃", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", + ]); + } + #[test] fn merge_bridge_crosses_unrelated_lane() { // 3 (lane 2) merges into 1's line (lane 0) while 2's line @@ -1246,9 +1281,14 @@ mod tests { fn generated_histories_hold_every_invariant() { // Scene::walk holds each of these to every invariant, so this // is the broad sweep behind the drawings above: the shapes - // cover long-lived branches, short topics, disjoint roots, and - // the parent lists Git permits but a naive walk mishandles. - const COMMITS: usize = 70; + // cover long-lived branches, short topics, disjoint roots, + // mainlines that skip over the branches they merge, and the + // parent lists Git permits but a naive walk mishandles. + // + // Long enough that each history crosses a lane-state + // checkpoint, and that the shapes reach the states a short + // history never gets into. + const COMMITS: usize = 120; const SEEDS: u64 = 10; for shape in Shape::ALL { @@ -1259,3 +1299,4 @@ mod tests { } } } + From 4beea8124248846df6f6984ec58f300bf72e3acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 02:43:34 +0000 Subject: [PATCH 77/84] graph: validate repository histories with the invariant fixture Add an ignored test that reads git rev-list --parents output from GITUI_GRAPH_PARENTS and applies the same Scene invariants used for synthetic histories. Document how to export and validate a repository's commit history. --- asyncgit/src/graph/walker.rs | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 74794c1bf5..e49205cde7 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -1300,3 +1300,55 @@ mod tests { } } +/// Holds a real repository's history to the same invariants as the +/// tests above. +/// +/// Every bug these invariants have caught so far was reachable from a +/// generated topology once the generator knew to produce the right +/// shape — but two of them were found here first, on a repository +/// busy enough to stumble into states the generator was not reaching. +/// Ignored by default, because it needs a checkout to point at: +/// +/// ```text +/// git -C rev-list --parents --topo-order --max-count=20000 HEAD > /tmp/parents +/// GITUI_GRAPH_PARENTS=/tmp/parents cargo test -p asyncgit \ +/// graph::walker::real_repository -- --ignored --nocapture +/// ``` +#[cfg(test)] +mod real_repository { + use crate::graph::harness::Scene; + use crate::sync::CommitId; + use std::collections::HashSet; + + #[test] + #[ignore = "needs GITUI_GRAPH_PARENTS; see the module docs"] + fn holds_every_invariant() { + let Ok(path) = std::env::var("GITUI_GRAPH_PARENTS") else { + return; + }; + let text = std::fs::read_to_string(&path) + .expect("GITUI_GRAPH_PARENTS names a readable file"); + + let history = text + .lines() + .map(|line| { + let mut ids = line.split_whitespace().map(|id| { + CommitId::from_str_unchecked(id) + .expect("rev-list emits valid object ids") + }); + let commit = + ids.next().expect("a line names its commit"); + (commit, ids.collect()) + }) + .collect::>(); + + let total = history.len(); + Scene::walk_ids( + history, + HashSet::new(), + HashSet::new(), + None, + ); + eprintln!("{total} commits drawn; every invariant holds"); + } +} From e85176ab545425ccd6d3253dd9e577f60d598efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 06:16:04 +0000 Subject: [PATCH 78/84] graph: separate fixtures from the walker implementation Move golden histories, generated topologies, and the optional repository check into dedicated modules. Keep the existing test behavior and filters intact so later logic changes can be reviewed separately. --- asyncgit/src/graph/harness/generated.rs | 179 +++++++ asyncgit/src/graph/harness/mod.rs | 181 +------ asyncgit/src/graph/walker.rs | 535 +------------------ asyncgit/src/graph/walker/real_repository.rs | 43 ++ asyncgit/src/graph/walker/tests.rs | 470 ++++++++++++++++ 5 files changed, 697 insertions(+), 711 deletions(-) create mode 100644 asyncgit/src/graph/harness/generated.rs create mode 100644 asyncgit/src/graph/walker/real_repository.rs create mode 100644 asyncgit/src/graph/walker/tests.rs diff --git a/asyncgit/src/graph/harness/generated.rs b/asyncgit/src/graph/harness/generated.rs new file mode 100644 index 0000000000..aa5e6dd93d --- /dev/null +++ b/asyncgit/src/graph/harness/generated.rs @@ -0,0 +1,179 @@ +use super::Scene; + +/// A deterministic generator, so a generated topology is reproducible +/// from its seed alone without pulling in a property-testing +/// dependency. +struct Rng(u64); + +impl Rng { + const fn new(seed: u64) -> Self { + // Keep the low bit of the state moving. + Self(seed.wrapping_mul(2).wrapping_add(1)) + } + + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.0 ^ (self.0 >> 33) + } + + /// A value in `0..upper`, or `0` when `upper` is zero. + fn below(&mut self, upper: usize) -> usize { + let upper = u64::try_from(upper).unwrap_or(u64::MAX).max(1); + usize::try_from(self.next() % upper).unwrap_or(0) + } + + /// True one time in `odds`. + fn chance(&mut self, odds: usize) -> bool { + self.below(odds) == 0 + } +} + +/// The kind of history to generate. +#[derive(Clone, Copy, Debug)] +pub enum Shape { + /// A single lane, each commit the parent of the one above it. + Linear, + /// Long-running branches that merge back often. + MergeHeavy, + /// Short topic branches off a trunk, merged or abandoned. + Topics, + /// Several disjoint histories, each ending in its own root. + ManyRoots, + /// Merges whose own first parent is a commit later merges reach + /// too. + /// + /// Both parents are drawn from the same spread, so a mainline can + /// skip over a side branch the way a real one does. Shapes whose + /// first parent is always the very next commit cannot produce + /// that, and so cannot produce a lane that is left waiting for a + /// commit while another merge still owes it — which is the state a + /// merge bridge has to reach into. + Entangled, + /// Parent lists Git permits and a walk has to survive: duplicated + /// entries, octopus merges, and parents a clock-skewed walk + /// reaches before their children. + Adversarial, +} + +impl Shape { + pub const ALL: [Self; 6] = [ + Self::Linear, + Self::MergeHeavy, + Self::Topics, + Self::ManyRoots, + Self::Entangled, + Self::Adversarial, + ]; +} + +fn push_later( + parents: &mut Vec, + name: usize, + remaining: usize, + offset: usize, +) { + if offset > 0 && offset <= remaining { + parents.push(name + offset); + } +} + +/// Generate a history of `commits` rows, newest first. +/// +/// Commits are named `1..=commits` and listed in that order, so a +/// parent named above its child is one the walk reaches later: the +/// ordering a topological walk produces. [`Shape::Adversarial`] breaks +/// that on purpose for some parents, which is what a repository with +/// skewed commit times hands the walker. +pub fn generate( + seed: u64, + commits: usize, + shape: Shape, +) -> Vec<(usize, Vec)> { + let mut rng = Rng::new(seed); + let mut history = Vec::with_capacity(commits); + + for index in 0..commits { + let name = index + 1; + let left = commits - index - 1; + let mut parents: Vec = Vec::new(); + + match shape { + Shape::Linear => { + push_later(&mut parents, name, left, 1); + } + Shape::MergeHeavy => { + push_later(&mut parents, name, left, 1); + if rng.chance(3) { + let offset = 2 + rng.below(10); + push_later(&mut parents, name, left, offset); + } + } + Shape::Topics => { + let offset = 1 + rng.below(3); + push_later(&mut parents, name, left, offset); + if rng.chance(4) { + let offset = 2 + rng.below(20); + push_later(&mut parents, name, left, offset); + } + } + Shape::ManyRoots => { + if !rng.chance(8) { + push_later(&mut parents, name, left, 1); + } + } + Shape::Entangled => { + const SPAN: usize = 10; + let offset = 1 + rng.below(SPAN); + push_later(&mut parents, name, left, offset); + if rng.chance(2) { + let offset = 1 + rng.below(SPAN); + push_later(&mut parents, name, left, offset); + } + } + Shape::Adversarial => { + push_later(&mut parents, name, left, 1); + if rng.chance(3) { + let offset = 1 + rng.below(6); + push_later(&mut parents, name, left, offset); + } + if rng.chance(5) { + let offset = 1 + rng.below(6); + push_later(&mut parents, name, left, offset); + } + // A parent the walk has already passed, and a + // repeated entry. Both are legal in a commit object + // and neither may open a lane. + if rng.chance(4) && index > 0 { + parents.push(1 + rng.below(index)); + } + if rng.chance(3) { + if let Some(first) = parents.first().copied() { + parents.push(first); + } + } + } + } + + history.push((name, parents)); + } + + history +} + +/// Walk a generated history and hold it to every invariant. +pub fn walk_generated( + seed: u64, + commits: usize, + shape: Shape, +) -> Scene { + let history = generate(seed, commits, shape); + let borrowed = history + .iter() + .map(|(commit, parents)| (*commit, parents.as_slice())) + .collect::>(); + + Scene::walk(&borrowed) +} diff --git a/asyncgit/src/graph/harness/mod.rs b/asyncgit/src/graph/harness/mod.rs index 1399b11bf5..5291d44281 100644 --- a/asyncgit/src/graph/harness/mod.rs +++ b/asyncgit/src/graph/harness/mod.rs @@ -22,8 +22,11 @@ //! the parent lists Git handed us. A renderer cannot satisfy those by //! being self-consistent. +mod generated; mod readback; +pub use generated::{walk_generated, Shape}; + use super::buffer::DeltaOp; use super::chunk::LaneSlot; use super::{ @@ -696,184 +699,6 @@ impl Union { } } -/// A deterministic generator, so a generated topology is reproducible -/// from its seed alone without pulling in a property-testing -/// dependency. -struct Rng(u64); - -impl Rng { - const fn new(seed: u64) -> Self { - // Keep the low bit of the state moving. - Self(seed.wrapping_mul(2).wrapping_add(1)) - } - - fn next(&mut self) -> u64 { - self.0 = self - .0 - .wrapping_mul(6_364_136_223_846_793_005) - .wrapping_add(1_442_695_040_888_963_407); - self.0 ^ (self.0 >> 33) - } - - /// A value in `0..upper`, or `0` when `upper` is zero. - fn below(&mut self, upper: usize) -> usize { - let upper = u64::try_from(upper).unwrap_or(u64::MAX).max(1); - usize::try_from(self.next() % upper).unwrap_or(0) - } - - /// True one time in `odds`. - fn chance(&mut self, odds: usize) -> bool { - self.below(odds) == 0 - } -} - -/// The kind of history to generate. -#[derive(Clone, Copy, Debug)] -pub enum Shape { - /// A single lane, each commit the parent of the one above it. - Linear, - /// Long-running branches that merge back often. - MergeHeavy, - /// Short topic branches off a trunk, merged or abandoned. - Topics, - /// Several disjoint histories, each ending in its own root. - ManyRoots, - /// Merges whose own first parent is a commit later merges reach - /// too. - /// - /// Both parents are drawn from the same spread, so a mainline can - /// skip over a side branch the way a real one does. Shapes whose - /// first parent is always the very next commit cannot produce - /// that, and so cannot produce a lane that is left waiting for a - /// commit while another merge still owes it — which is the state a - /// merge bridge has to reach into. - Entangled, - /// Parent lists Git permits and a walk has to survive: duplicated - /// entries, octopus merges, and parents a clock-skewed walk - /// reaches before their children. - Adversarial, -} - -impl Shape { - pub const ALL: [Self; 6] = [ - Self::Linear, - Self::MergeHeavy, - Self::Topics, - Self::ManyRoots, - Self::Entangled, - Self::Adversarial, - ]; -} - -fn push_later( - parents: &mut Vec, - name: usize, - remaining: usize, - offset: usize, -) { - if offset > 0 && offset <= remaining { - parents.push(name + offset); - } -} - -/// Generate a history of `commits` rows, newest first. -/// -/// Commits are named `1..=commits` and listed in that order, so a -/// parent named above its child is one the walk reaches later: the -/// ordering a topological walk produces. [`Shape::Adversarial`] breaks -/// that on purpose for some parents, which is what a repository with -/// skewed commit times hands the walker. -pub fn generate( - seed: u64, - commits: usize, - shape: Shape, -) -> Vec<(usize, Vec)> { - let mut rng = Rng::new(seed); - let mut history = Vec::with_capacity(commits); - - for index in 0..commits { - let name = index + 1; - let left = commits - index - 1; - let mut parents: Vec = Vec::new(); - - match shape { - Shape::Linear => { - push_later(&mut parents, name, left, 1); - } - Shape::MergeHeavy => { - push_later(&mut parents, name, left, 1); - if rng.chance(3) { - let offset = 2 + rng.below(10); - push_later(&mut parents, name, left, offset); - } - } - Shape::Topics => { - let offset = 1 + rng.below(3); - push_later(&mut parents, name, left, offset); - if rng.chance(4) { - let offset = 2 + rng.below(20); - push_later(&mut parents, name, left, offset); - } - } - Shape::ManyRoots => { - if !rng.chance(8) { - push_later(&mut parents, name, left, 1); - } - } - Shape::Entangled => { - const SPAN: usize = 10; - let offset = 1 + rng.below(SPAN); - push_later(&mut parents, name, left, offset); - if rng.chance(2) { - let offset = 1 + rng.below(SPAN); - push_later(&mut parents, name, left, offset); - } - } - Shape::Adversarial => { - push_later(&mut parents, name, left, 1); - if rng.chance(3) { - let offset = 1 + rng.below(6); - push_later(&mut parents, name, left, offset); - } - if rng.chance(5) { - let offset = 1 + rng.below(6); - push_later(&mut parents, name, left, offset); - } - // A parent the walk has already passed, and a - // repeated entry. Both are legal in a commit object - // and neither may open a lane. - if rng.chance(4) && index > 0 { - parents.push(1 + rng.below(index)); - } - if rng.chance(3) { - if let Some(first) = parents.first().copied() { - parents.push(first); - } - } - } - } - - history.push((name, parents)); - } - - history -} - -/// Walk a generated history and hold it to every invariant. -pub fn walk_generated( - seed: u64, - commits: usize, - shape: Shape, -) -> Scene { - let history = generate(seed, commits, shape); - let borrowed = history - .iter() - .map(|(commit, parents)| (*commit, parents.as_slice())) - .collect::>(); - - Scene::walk(&borrowed) -} - /// Assert a `LaneIndex` round-trips the column it was built from, so a /// wide graph is reported honestly rather than clamped. pub fn assert_lane_index_exact(column: usize) { diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index e49205cde7..be33192265 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -817,538 +817,7 @@ impl GraphWalker { } #[cfg(test)] -mod tests { - use super::*; - use crate::graph::harness::{ - assert_lane_index_exact, oid, walk_generated, Scene, Shape, - }; - - #[test] - fn linear_history() { - Scene::walk(&[(1, &[2]), (2, &[3]), (3, &[])]) - .assert_drawing(&["o", "o", "o"]); - } - - #[test] - fn history_with_multiple_roots() { - // Two independent commit chains, each terminating in its own - // root (a commit with no parents), STAY IN THEIR LANE! A root - // draws a marker and no line below it, so handing its column - // to the next history would stack two commit markers in one - // column — which is exactly how a lane draws two consecutive - // commits, and would read as joining the two histories. - let scene = - Scene::walk(&[(1, &[2]), (2, &[]), (3, &[4]), (4, &[])]); - scene.assert_drawing(&["o", "o", " o", " o"]); - scene.assert_fully_settled(); - } - - #[test] - fn simple_merge() { - // 1 merges 3 into the line 1 → 2 → 4, 3 → 4 - Scene::walk(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]) - .assert_drawing(&["M━┓", "o ┃", "┃ o", "o━┛"]); - } - - #[test] - fn merge_into_tracked_lane_continues_through_corner() { - // 3's merge line joins lane 1 which keeps flowing to 5, - // so the corner must be a junction (┫), not a dead end (┓) - Scene::walk(&[ - (1, &[3]), - (2, &[5]), - (3, &[4, 5]), - (4, &[6]), - (5, &[6]), - (6, &[]), - ]) - .assert_drawing(&["o", "┃ o", "M━┫", "o ┃", "┃ o", "o━┛"]); - } - - #[test] - fn merge_into_a_lane_that_stops_owing_a_parent_keeps_flowing() { - // Commit 2 is a merge that found a lane already heading for - // its second parent, so its own lane keeps that debt open. - // Commit 4 settles it — commit 4 *is* that second parent — and - // in the same row merges the commit that lane is waiting for, - // so its bridge ends there. That lane runs through the row, - // which makes the bridge end a junction; it is only the slot's - // shape that changed, not the lane. - Scene::walk(&[ - (1, &[4]), - (2, &[6, 4]), - (3, &[7]), - (4, &[5, 6]), - (5, &[8]), - (6, &[8]), - (7, &[8]), - (8, &[]), - ]) - .assert_drawing(&[ - "o", - "┣━M", - "┃ ┃ o", - "M━┫ ┃", - "o ┃ ┃", - "┃ o ┃", - "┃ ┃ o", - "o━┻━┛", - ]); - } - - #[test] - fn merge_bridge_crosses_unrelated_lane() { - // 3 (lane 2) merges into 1's line (lane 0) while 2's line - // (lane 1) passes through: the crossed lane keeps its - // vertical instead of being cut by the bridge - Scene::walk(&[ - (1, &[4]), - (2, &[5]), - (3, &[6, 4]), - (4, &[7]), - (5, &[7]), - (6, &[7]), - (7, &[]), - ]) - .assert_drawing(&[ - "o", - "┃ o", - "┣━┃━M", - "o ┃ ┃", - "┃ o ┃", - "┃ ┃ o", - "o━┻━┛", - ]); - } - - #[test] - fn overlapping_branch_bridges_keep_inner_corner() { - // lanes 1 and 2 both close into the commit on lane 0; the - // outer bridge passes through the inner corner (┻) instead - // of erasing it - Scene::walk(&[(1, &[4]), (2, &[4]), (3, &[4]), (4, &[])]) - .assert_drawing(&["o", "┃ o", "┃ ┃ o", "o━┻━┛"]); - } - - #[test] - fn nested_merges_keep_every_lane_in_its_column() { - // Commit 3 closes a branch while opening a merge of its own. - // The lane reserved for commit 1's second parent is already - // open in column 1, and has to stay there: moving it along to - // make room next to commit 3 would leave its line with - // nothing above it in the column it moved to, and would take - // commit 2's closing bridge with it. The merge's own lane goes - // at the right-hand end instead, and the bridge reaches it - // across the lanes in between. - Scene::walk(&[ - (1, &[3, 4]), - (2, &[3]), - (3, &[5, 6]), - (4, &[5]), - (5, &[7]), - (6, &[7]), - (7, &[]), - ]) - .assert_drawing(&[ - "M━┓", - "┃ ┃ o", - "M━┃━┻━┓", - "┃ o ┃", - "o━┛ ┃", - "┃ o", - "o━━━━━┛", - ]); - } - - #[test] - fn second_parent_lane_takes_a_column_left_blank_above() { - // Commit 4 closes the lane in column 1, so by the time commit - // 5 merges, the row above it draws nothing there. Starting the - // second parent's lane in that column keeps the bridge one - // step long instead of reaching past every lane still open. - Scene::walk(&[ - (1, &[4]), - (2, &[4]), - (3, &[9]), - (4, &[5]), - (5, &[6, 7]), - (6, &[9]), - (7, &[9]), - (9, &[]), - ]) - .assert_drawing(&[ - "o", - "┃ o", - "┃ ┃ o", - "o━┛ ┃", - "M━┓ ┃", - "o ┃ ┃", - "┃ o ┃", - "o━┻━┛", - ]); - } - - #[test] - fn second_parent_lane_skips_a_column_this_commit_freed() { - // Commit 4 both closes the lane in column 1 and opens a merge. - // Column 1 is vacant by the end of the step, but the row above - // still draws commit 2's lane there and this row draws its - // closing corner, so the merge's second parent cannot have it - // — taking it would put a new lane's corner where that edge - // ends and lose the edge. - Scene::walk(&[ - (1, &[4]), - (2, &[4]), - (3, &[7]), - (4, &[5, 6]), - (5, &[8]), - (6, &[8]), - (7, &[8]), - (8, &[]), - ]) - .assert_drawing(&[ - "o", - "┃ o", - "┃ ┃ o", - "M━┻━┃━┓", - "o ┃ ┃", - "┃ ┃ o", - "┃ o ┃", - "o━━━┻━┛", - ]); - } - - #[test] - fn merge_bridges_to_the_lane_its_second_parent_already_has() { - // Commit 2 is a merge that found a lane already heading for - // its second parent, so it owes that parent nothing and keeps - // a lane of its own open for it. Commit 3 then merges the - // same commit 6 that commit 2's lane is waiting for: it has to - // bridge into that lane, not reserve a second column for a - // parent only one lane can receive. A second column would be - // left drawing a line that starts out of nothing, because the - // bridge is always drawn to the first lane waiting. - Scene::walk(&[ - (1, &[5]), - (2, &[6, 5]), - (3, &[7, 6]), - (5, &[8]), - (6, &[8]), - (7, &[8]), - (8, &[]), - ]) - .assert_drawing(&[ - "o", - "┣━M", - "┃ ┣━M", - "o ┃ ┃", - "┃ o ┃", - "┃ ┃ o", - "o━┻━┛", - ]); - } - - #[test] - fn a_bridge_crossing_another_bridges_junction_keeps_it() { - // Commit 4 does two things at once: it merges commit 6, whose - // lane is already open in column 1, and it closes the branch - // in column 2. The branch's bridge has to reach across column - // 1, which is where the merge's bridge ends. That cell joins - // up, down, left and right, and there is no way to draw it as - // a lane with something passing behind — drawing it as a plain - // vertical would take the merge edge out of the picture. - Scene::walk(&[ - (1, &[4]), - (2, &[6]), - (3, &[4]), - (4, &[5, 6]), - (5, &[8]), - (6, &[8]), - (8, &[]), - ]) - .assert_drawing(&[ - "o", - "┃ o", - "┃ ┃ o", - "M━╋━┛", - "o ┃", - "┃ o", - "o━┛", - ]); - } - - #[test] - fn skewed_parent_before_child_leaves_no_phantom_lane() { - // Commit 2 is walked before commit 1, which lists it as a - // parent. The edge up to it cannot be drawn, and must not open - // a lane that waits for a commit the walk has gone past. The - // marker still says merge, because commit 1 is one. - Scene::walk(&[(2, &[3]), (1, &[2, 3]), (3, &[])]) - .assert_drawing(&["o", "┃ M", "o━┛"]); - } - - #[test] - fn complete_walk_settles_all_lanes() { - // Both parents of 1 appear before it in the walk (clock - // skew). After a complete walk no lane may still wait on a - // parent, creating the evil phantom lines - let scene = Scene::walk(&[ - (2, &[4]), - (3, &[4]), - (1, &[2, 3]), - (4, &[]), - ]); - scene.assert_drawing(&["o", "┃ o", "┃ ┃ M", "o━┛"]); - scene.assert_fully_settled(); - } - - #[test] - fn duplicate_parent_entries_draw_one_edge() { - // A commit object may name the same parent twice. There is one - // edge to draw, and the second entry must not reserve a lane - // for a commit that the first entry's lane already receives. - Scene::walk(&[(1, &[2, 2]), (2, &[3]), (3, &[])]) - .assert_drawing(&["M", "o", "o"]); - } - - #[test] - fn octopus_merge_draws_its_first_two_parents() { - // Support stops at two parents. The third parent's commit - // still appears, on a lane of its own with no edge into it, - // which is the honest picture of an edge this graph cannot - // show — not a lane left waiting for something. - let scene = Scene::walk(&[ - (1, &[2, 3, 4]), - (2, &[5]), - (3, &[5]), - (4, &[5]), - (5, &[]), - ]); - scene.assert_drawing(&[ - "M━┓", - "o ┃", - "┃ o", - "┃ ┃ o", - "o━┻━┛", - ]); - scene.assert_fully_settled(); - } - - #[test] - fn a_commit_naming_itself_as_a_parent_opens_no_lane() { - let scene = Scene::walk(&[(1, &[1, 2]), (2, &[])]); - scene.assert_drawing(&["M", "o"]); - scene.assert_fully_settled(); - } +mod tests; - #[test] - fn unwalked_parents_keep_their_lanes_open() { - // The log view shows a window onto a longer history, so a lane - // heading for a commit below the window stays open and draws - // off the bottom of the picture. - let scene = Scene::walk(&[(1, &[8]), (2, &[9])]); - scene.assert_drawing(&["o", "┃ o"]); - assert_eq!(scene.open_lane_count(), 2); - } - - #[test] - fn head_lane_is_dotted_in_any_column() { - // HEAD is not guaranteed to sit on the leftmost lane, so the - // dotted segment has to follow the lane heading for it. - Scene::walk_marked( - &[(1, &[3]), (2, &[4]), (3, &[5]), (4, &[5]), (5, &[])], - &[], - &[], - Some(4), - ) - .assert_drawing(&["o", "┃ o", "o ╏", "┃ o", "o━┛"]); - } - - #[test] - fn branch_tip_and_stash_markers_leave_the_lines_alone() { - Scene::walk_marked( - &[(1, &[2]), (2, &[3]), (3, &[])], - &[1], - &[3], - None, - ) - .assert_drawing(&["*", "o", "*"]); - } - - #[test] - fn crossed_lane_keeps_own_color() { - let scene = Scene::walk(&[ - (1, &[4]), - (2, &[5]), - (3, &[6, 4]), - (4, &[7]), - (5, &[7]), - (6, &[7]), - (7, &[]), - ]); - - // row of commit 3: lane 1 is crossed by the merge bridge but - // keeps both its vertical glyph and its own lane color - let crossed = scene.rows[2].lanes[1] - .expect("crossed lane should not be empty"); - assert_eq!(crossed.0, ConnectionType::Vertical); - assert_eq!(crossed.1, lane_color(1)); - } - - #[test] - fn branch_bridge_crosses_unrelated_lane_keeps_own_color() { - // 5 is the fork point for both 1 and 3 - let scene = Scene::walk(&[ - (1, &[5]), - (2, &[6]), - (3, &[5]), - (5, &[8]), - (6, &[8]), - (8, &[]), - ]); - scene.assert_drawing(&[ - "o", - "┃ o", - "┃ ┃ o", - "o━┃━┛", - "┃ o", - "o━┛", - ]); - - // row of commit 5: lane 1 is crossed by the branch bridge but - // keeps both its vertical glyph and its own lane color (visual identity mans) - let crossed = scene.rows[3].lanes[1] - .expect("crossed lane should not be empty"); - assert_eq!(crossed.0, ConnectionType::Vertical); - assert_eq!(crossed.1, lane_color(1)); - } - - #[test] - fn windows_decompress_across_checkpoint_boundaries() { - // Long enough to take several lane-state checkpoints, which is - // the path the log view uses for every scroll. The window - // comparison itself is part of every scene's invariants. - let scene = walk_generated(7, 250, Shape::MergeHeavy); - assert!( - scene.walker.buffer.checkpoints.len() >= 2, - "expected the walk to take checkpoints" - ); - } - - #[test] - fn a_wide_history_reports_every_column() { - // One lane per commit, none of them ever closing. A column - // index has to survive being reported: clamping it would - // relabel every lane past the clamp as the same column and - // put their edges in the wrong place. - const LANES: usize = 300; - - let history = (1..=LANES) - .map(|commit| (commit, vec![10_000 + commit])) - .collect::>(); - let borrowed = history - .iter() - .map(|(commit, parents)| (*commit, parents.as_slice())) - .collect::>(); - - let scene = Scene::walk(&borrowed); - let last = &scene.rows[LANES - 1]; - - assert_eq!(usize::from(last.lane_count), LANES); - assert_eq!(usize::from(last.commit_lane), LANES - 1); - assert_eq!(scene.open_lane_count(), LANES); - assert_lane_index_exact(LANES - 1); - } - - #[test] - fn rows_requested_beyond_the_walk_draw_nothing() { - let mut walker = GraphWalker::new(); - walker.process(oid(1), &[oid(2)]); - - let rows = walker.compute_rows( - &[oid(2), oid(3)], - 1, - &HashSet::new(), - &HashSet::new(), - None, - ); - - assert_eq!(rows.len(), 2); - assert!(rows.iter().all(|row| row.lanes.is_empty())); - } - - #[test] - fn generated_histories_hold_every_invariant() { - // Scene::walk holds each of these to every invariant, so this - // is the broad sweep behind the drawings above: the shapes - // cover long-lived branches, short topics, disjoint roots, - // mainlines that skip over the branches they merge, and the - // parent lists Git permits but a naive walk mishandles. - // - // Long enough that each history crosses a lane-state - // checkpoint, and that the shapes reach the states a short - // history never gets into. - const COMMITS: usize = 120; - const SEEDS: u64 = 10; - - for shape in Shape::ALL { - for seed in 0..SEEDS { - eprintln!("graph topology: {shape:?} seed {seed}"); - walk_generated(seed, COMMITS, shape); - } - } - } -} - -/// Holds a real repository's history to the same invariants as the -/// tests above. -/// -/// Every bug these invariants have caught so far was reachable from a -/// generated topology once the generator knew to produce the right -/// shape — but two of them were found here first, on a repository -/// busy enough to stumble into states the generator was not reaching. -/// Ignored by default, because it needs a checkout to point at: -/// -/// ```text -/// git -C rev-list --parents --topo-order --max-count=20000 HEAD > /tmp/parents -/// GITUI_GRAPH_PARENTS=/tmp/parents cargo test -p asyncgit \ -/// graph::walker::real_repository -- --ignored --nocapture -/// ``` #[cfg(test)] -mod real_repository { - use crate::graph::harness::Scene; - use crate::sync::CommitId; - use std::collections::HashSet; - - #[test] - #[ignore = "needs GITUI_GRAPH_PARENTS; see the module docs"] - fn holds_every_invariant() { - let Ok(path) = std::env::var("GITUI_GRAPH_PARENTS") else { - return; - }; - let text = std::fs::read_to_string(&path) - .expect("GITUI_GRAPH_PARENTS names a readable file"); - - let history = text - .lines() - .map(|line| { - let mut ids = line.split_whitespace().map(|id| { - CommitId::from_str_unchecked(id) - .expect("rev-list emits valid object ids") - }); - let commit = - ids.next().expect("a line names its commit"); - (commit, ids.collect()) - }) - .collect::>(); - - let total = history.len(); - Scene::walk_ids( - history, - HashSet::new(), - HashSet::new(), - None, - ); - eprintln!("{total} commits drawn; every invariant holds"); - } -} +mod real_repository; diff --git a/asyncgit/src/graph/walker/real_repository.rs b/asyncgit/src/graph/walker/real_repository.rs new file mode 100644 index 0000000000..a2a4fc07cd --- /dev/null +++ b/asyncgit/src/graph/walker/real_repository.rs @@ -0,0 +1,43 @@ +//! Holds a real repository's history to the same invariants as the +//! tests above. +//! +//! Every bug these invariants have caught so far was reachable from a +//! generated topology once the generator knew to produce the right +//! shape — but two of them were found here first, on a repository +//! busy enough to stumble into states the generator was not reaching. +//! Ignored by default, because it needs a checkout to point at: +//! +//! ```text +//! git -C rev-list --parents --topo-order --max-count=20000 HEAD > /tmp/parents +//! GITUI_GRAPH_PARENTS=/tmp/parents cargo test -p asyncgit \ +//! graph::walker::real_repository -- --ignored --nocapture +//! ``` +use crate::graph::harness::Scene; +use crate::sync::CommitId; +use std::collections::HashSet; + +#[test] +#[ignore = "needs GITUI_GRAPH_PARENTS; see the module docs"] +fn holds_every_invariant() { + let Ok(path) = std::env::var("GITUI_GRAPH_PARENTS") else { + return; + }; + let text = std::fs::read_to_string(&path) + .expect("GITUI_GRAPH_PARENTS names a readable file"); + + let history = text + .lines() + .map(|line| { + let mut ids = line.split_whitespace().map(|id| { + CommitId::from_str_unchecked(id) + .expect("rev-list emits valid object ids") + }); + let commit = ids.next().expect("a line names its commit"); + (commit, ids.collect()) + }) + .collect::>(); + + let total = history.len(); + Scene::walk_ids(history, HashSet::new(), HashSet::new(), None); + eprintln!("{total} commits drawn; every invariant holds"); +} diff --git a/asyncgit/src/graph/walker/tests.rs b/asyncgit/src/graph/walker/tests.rs new file mode 100644 index 0000000000..25832f22aa --- /dev/null +++ b/asyncgit/src/graph/walker/tests.rs @@ -0,0 +1,470 @@ +use super::*; +use crate::graph::harness::{ + assert_lane_index_exact, oid, walk_generated, Scene, Shape, +}; + +#[test] +fn linear_history() { + Scene::walk(&[(1, &[2]), (2, &[3]), (3, &[])]) + .assert_drawing(&["o", "o", "o"]); +} + +#[test] +fn history_with_multiple_roots() { + // Two independent commit chains, each terminating in its own + // root (a commit with no parents), STAY IN THEIR LANE! A root + // draws a marker and no line below it, so handing its column + // to the next history would stack two commit markers in one + // column — which is exactly how a lane draws two consecutive + // commits, and would read as joining the two histories. + let scene = + Scene::walk(&[(1, &[2]), (2, &[]), (3, &[4]), (4, &[])]); + scene.assert_drawing(&["o", "o", " o", " o"]); + scene.assert_fully_settled(); +} + +#[test] +fn simple_merge() { + // 1 merges 3 into the line 1 → 2 → 4, 3 → 4 + Scene::walk(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]) + .assert_drawing(&["M━┓", "o ┃", "┃ o", "o━┛"]); +} + +#[test] +fn merge_into_tracked_lane_continues_through_corner() { + // 3's merge line joins lane 1 which keeps flowing to 5, + // so the corner must be a junction (┫), not a dead end (┓) + Scene::walk(&[ + (1, &[3]), + (2, &[5]), + (3, &[4, 5]), + (4, &[6]), + (5, &[6]), + (6, &[]), + ]) + .assert_drawing(&["o", "┃ o", "M━┫", "o ┃", "┃ o", "o━┛"]); +} + +#[test] +fn merge_into_a_lane_that_stops_owing_a_parent_keeps_flowing() { + // Commit 2 is a merge that found a lane already heading for + // its second parent, so its own lane keeps that debt open. + // Commit 4 settles it — commit 4 *is* that second parent — and + // in the same row merges the commit that lane is waiting for, + // so its bridge ends there. That lane runs through the row, + // which makes the bridge end a junction; it is only the slot's + // shape that changed, not the lane. + Scene::walk(&[ + (1, &[4]), + (2, &[6, 4]), + (3, &[7]), + (4, &[5, 6]), + (5, &[8]), + (6, &[8]), + (7, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┣━M", + "┃ ┃ o", + "M━┫ ┃", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", + ]); +} + +#[test] +fn merge_bridge_crosses_unrelated_lane() { + // 3 (lane 2) merges into 1's line (lane 0) while 2's line + // (lane 1) passes through: the crossed lane keeps its + // vertical instead of being cut by the bridge + Scene::walk(&[ + (1, &[4]), + (2, &[5]), + (3, &[6, 4]), + (4, &[7]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┣━┃━M", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", + ]); +} + +#[test] +fn overlapping_branch_bridges_keep_inner_corner() { + // lanes 1 and 2 both close into the commit on lane 0; the + // outer bridge passes through the inner corner (┻) instead + // of erasing it + Scene::walk(&[(1, &[4]), (2, &[4]), (3, &[4]), (4, &[])]) + .assert_drawing(&["o", "┃ o", "┃ ┃ o", "o━┻━┛"]); +} + +#[test] +fn nested_merges_keep_every_lane_in_its_column() { + // Commit 3 closes a branch while opening a merge of its own. + // The lane reserved for commit 1's second parent is already + // open in column 1, and has to stay there: moving it along to + // make room next to commit 3 would leave its line with + // nothing above it in the column it moved to, and would take + // commit 2's closing bridge with it. The merge's own lane goes + // at the right-hand end instead, and the bridge reaches it + // across the lanes in between. + Scene::walk(&[ + (1, &[3, 4]), + (2, &[3]), + (3, &[5, 6]), + (4, &[5]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]) + .assert_drawing(&[ + "M━┓", + "┃ ┃ o", + "M━┃━┻━┓", + "┃ o ┃", + "o━┛ ┃", + "┃ o", + "o━━━━━┛", + ]); +} + +#[test] +fn second_parent_lane_takes_a_column_left_blank_above() { + // Commit 4 closes the lane in column 1, so by the time commit + // 5 merges, the row above it draws nothing there. Starting the + // second parent's lane in that column keeps the bridge one + // step long instead of reaching past every lane still open. + Scene::walk(&[ + (1, &[4]), + (2, &[4]), + (3, &[9]), + (4, &[5]), + (5, &[6, 7]), + (6, &[9]), + (7, &[9]), + (9, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "o━┛ ┃", + "M━┓ ┃", + "o ┃ ┃", + "┃ o ┃", + "o━┻━┛", + ]); +} + +#[test] +fn second_parent_lane_skips_a_column_this_commit_freed() { + // Commit 4 both closes the lane in column 1 and opens a merge. + // Column 1 is vacant by the end of the step, but the row above + // still draws commit 2's lane there and this row draws its + // closing corner, so the merge's second parent cannot have it + // — taking it would put a new lane's corner where that edge + // ends and lose the edge. + Scene::walk(&[ + (1, &[4]), + (2, &[4]), + (3, &[7]), + (4, &[5, 6]), + (5, &[8]), + (6, &[8]), + (7, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "M━┻━┃━┓", + "o ┃ ┃", + "┃ ┃ o", + "┃ o ┃", + "o━━━┻━┛", + ]); +} + +#[test] +fn merge_bridges_to_the_lane_its_second_parent_already_has() { + // Commit 2 is a merge that found a lane already heading for + // its second parent, so it owes that parent nothing and keeps + // a lane of its own open for it. Commit 3 then merges the + // same commit 6 that commit 2's lane is waiting for: it has to + // bridge into that lane, not reserve a second column for a + // parent only one lane can receive. A second column would be + // left drawing a line that starts out of nothing, because the + // bridge is always drawn to the first lane waiting. + Scene::walk(&[ + (1, &[5]), + (2, &[6, 5]), + (3, &[7, 6]), + (5, &[8]), + (6, &[8]), + (7, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┣━M", + "┃ ┣━M", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", + ]); +} + +#[test] +fn a_bridge_crossing_another_bridges_junction_keeps_it() { + // Commit 4 does two things at once: it merges commit 6, whose + // lane is already open in column 1, and it closes the branch + // in column 2. The branch's bridge has to reach across column + // 1, which is where the merge's bridge ends. That cell joins + // up, down, left and right, and there is no way to draw it as + // a lane with something passing behind — drawing it as a plain + // vertical would take the merge edge out of the picture. + Scene::walk(&[ + (1, &[4]), + (2, &[6]), + (3, &[4]), + (4, &[5, 6]), + (5, &[8]), + (6, &[8]), + (8, &[]), + ]) + .assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "M━╋━┛", + "o ┃", + "┃ o", + "o━┛", + ]); +} + +#[test] +fn skewed_parent_before_child_leaves_no_phantom_lane() { + // Commit 2 is walked before commit 1, which lists it as a + // parent. The edge up to it cannot be drawn, and must not open + // a lane that waits for a commit the walk has gone past. The + // marker still says merge, because commit 1 is one. + Scene::walk(&[(2, &[3]), (1, &[2, 3]), (3, &[])]) + .assert_drawing(&["o", "┃ M", "o━┛"]); +} + +#[test] +fn complete_walk_settles_all_lanes() { + // Both parents of 1 appear before it in the walk (clock + // skew). After a complete walk no lane may still wait on a + // parent, creating the evil phantom lines + let scene = + Scene::walk(&[(2, &[4]), (3, &[4]), (1, &[2, 3]), (4, &[])]); + scene.assert_drawing(&["o", "┃ o", "┃ ┃ M", "o━┛"]); + scene.assert_fully_settled(); +} + +#[test] +fn duplicate_parent_entries_draw_one_edge() { + // A commit object may name the same parent twice. There is one + // edge to draw, and the second entry must not reserve a lane + // for a commit that the first entry's lane already receives. + Scene::walk(&[(1, &[2, 2]), (2, &[3]), (3, &[])]) + .assert_drawing(&["M", "o", "o"]); +} + +#[test] +fn octopus_merge_draws_its_first_two_parents() { + // Support stops at two parents. The third parent's commit + // still appears, on a lane of its own with no edge into it, + // which is the honest picture of an edge this graph cannot + // show — not a lane left waiting for something. + let scene = Scene::walk(&[ + (1, &[2, 3, 4]), + (2, &[5]), + (3, &[5]), + (4, &[5]), + (5, &[]), + ]); + scene.assert_drawing(&["M━┓", "o ┃", "┃ o", "┃ ┃ o", "o━┻━┛"]); + scene.assert_fully_settled(); +} + +#[test] +fn a_commit_naming_itself_as_a_parent_opens_no_lane() { + let scene = Scene::walk(&[(1, &[1, 2]), (2, &[])]); + scene.assert_drawing(&["M", "o"]); + scene.assert_fully_settled(); +} + +#[test] +fn unwalked_parents_keep_their_lanes_open() { + // The log view shows a window onto a longer history, so a lane + // heading for a commit below the window stays open and draws + // off the bottom of the picture. + let scene = Scene::walk(&[(1, &[8]), (2, &[9])]); + scene.assert_drawing(&["o", "┃ o"]); + assert_eq!(scene.open_lane_count(), 2); +} + +#[test] +fn head_lane_is_dotted_in_any_column() { + // HEAD is not guaranteed to sit on the leftmost lane, so the + // dotted segment has to follow the lane heading for it. + Scene::walk_marked( + &[(1, &[3]), (2, &[4]), (3, &[5]), (4, &[5]), (5, &[])], + &[], + &[], + Some(4), + ) + .assert_drawing(&["o", "┃ o", "o ╏", "┃ o", "o━┛"]); +} + +#[test] +fn branch_tip_and_stash_markers_leave_the_lines_alone() { + Scene::walk_marked( + &[(1, &[2]), (2, &[3]), (3, &[])], + &[1], + &[3], + None, + ) + .assert_drawing(&["*", "o", "*"]); +} + +#[test] +fn crossed_lane_keeps_own_color() { + let scene = Scene::walk(&[ + (1, &[4]), + (2, &[5]), + (3, &[6, 4]), + (4, &[7]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]); + + // row of commit 3: lane 1 is crossed by the merge bridge but + // keeps both its vertical glyph and its own lane color + let crossed = scene.rows[2].lanes[1] + .expect("crossed lane should not be empty"); + assert_eq!(crossed.0, ConnectionType::Vertical); + assert_eq!(crossed.1, lane_color(1)); +} + +#[test] +fn branch_bridge_crosses_unrelated_lane_keeps_own_color() { + // 5 is the fork point for both 1 and 3 + let scene = Scene::walk(&[ + (1, &[5]), + (2, &[6]), + (3, &[5]), + (5, &[8]), + (6, &[8]), + (8, &[]), + ]); + scene.assert_drawing(&[ + "o", + "┃ o", + "┃ ┃ o", + "o━┃━┛", + "┃ o", + "o━┛", + ]); + + // row of commit 5: lane 1 is crossed by the branch bridge but + // keeps both its vertical glyph and its own lane color (visual identity mans) + let crossed = scene.rows[3].lanes[1] + .expect("crossed lane should not be empty"); + assert_eq!(crossed.0, ConnectionType::Vertical); + assert_eq!(crossed.1, lane_color(1)); +} + +#[test] +fn windows_decompress_across_checkpoint_boundaries() { + // Long enough to take several lane-state checkpoints, which is + // the path the log view uses for every scroll. The window + // comparison itself is part of every scene's invariants. + let scene = walk_generated(7, 250, Shape::MergeHeavy); + assert!( + scene.walker.buffer.checkpoints.len() >= 2, + "expected the walk to take checkpoints" + ); +} + +#[test] +fn a_wide_history_reports_every_column() { + // One lane per commit, none of them ever closing. A column + // index has to survive being reported: clamping it would + // relabel every lane past the clamp as the same column and + // put their edges in the wrong place. + const LANES: usize = 300; + + let history = (1..=LANES) + .map(|commit| (commit, vec![10_000 + commit])) + .collect::>(); + let borrowed = history + .iter() + .map(|(commit, parents)| (*commit, parents.as_slice())) + .collect::>(); + + let scene = Scene::walk(&borrowed); + let last = &scene.rows[LANES - 1]; + + assert_eq!(usize::from(last.lane_count), LANES); + assert_eq!(usize::from(last.commit_lane), LANES - 1); + assert_eq!(scene.open_lane_count(), LANES); + assert_lane_index_exact(LANES - 1); +} + +#[test] +fn rows_requested_beyond_the_walk_draw_nothing() { + let mut walker = GraphWalker::new(); + walker.process(oid(1), &[oid(2)]); + + let rows = walker.compute_rows( + &[oid(2), oid(3)], + 1, + &HashSet::new(), + &HashSet::new(), + None, + ); + + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|row| row.lanes.is_empty())); +} + +#[test] +fn generated_histories_hold_every_invariant() { + // Scene::walk holds each of these to every invariant, so this + // is the broad sweep behind the drawings above: the shapes + // cover long-lived branches, short topics, disjoint roots, + // mainlines that skip over the branches they merge, and the + // parent lists Git permits but a naive walk mishandles. + // + // Long enough that each history crosses a lane-state + // checkpoint, and that the shapes reach the states a short + // history never gets into. + const COMMITS: usize = 120; + const SEEDS: u64 = 10; + + for shape in Shape::ALL { + for seed in 0..SEEDS { + eprintln!("graph topology: {shape:?} seed {seed}"); + walk_generated(seed, COMMITS, shape); + } + } +} From ac982a8ebf88fe65f1822f381ff9a0da7b6585f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 06:16:20 +0000 Subject: [PATCH 79/84] graph: simplify lane updates and delta replay Resolve each merge's second-parent lane inside Buffer::update using one shared lane lookup. Reserve before closing incoming lanes, eliminating deferred merge state and blank-column tracking while preserving closing corners. Share delta execution between recording and replay, bound decompression to recorded rows, and cover oversized requests. Add a regression proving that merging HEAD dots only the lane actually heading for HEAD. --- asyncgit/src/graph/buffer.rs | 290 ++++++++---------------- asyncgit/src/graph/chunk.rs | 59 ++--- asyncgit/src/graph/harness/generated.rs | 5 +- asyncgit/src/graph/walker.rs | 108 ++------- asyncgit/src/graph/walker/tests.rs | 52 +++-- 5 files changed, 160 insertions(+), 354 deletions(-) diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs index a242e04c29..3430b1cd30 100644 --- a/asyncgit/src/graph/buffer.rs +++ b/asyncgit/src/graph/buffer.rs @@ -1,19 +1,12 @@ -use super::chunk::LaneSlot; +use super::chunk::{lane_awaiting_parent, LaneSlot}; use super::{CommitAlias, UnwalkedAlias}; use std::collections::BTreeMap; /// A single mutation of the lane state, recorded while processing one /// commit. /// -/// A lane's column index *is* its visual identity. Rows are drawn one -/// at a time and joined to their neighbours by column, and the glyph -/// set has no diagonal, so a lane that moved sideways between two rows -/// could only be drawn as two segments that do not meet. These three -/// operations are therefore the only edits the walk may make: -/// [`Self::Set`] leaves a column where it is, and [`Self::Push`] and -/// [`Self::Pop`] only ever touch the right-hand end. No sequence of -/// them can shift a lane that is still open, which is why there is no -/// operation here for inserting or removing a column in the middle. +/// Columns stay fixed while a lane is open. Only the right-hand end +/// may grow or shrink; an interior column can only be replaced in place. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DeltaOp { /// Put `slot` into the existing column `index`. @@ -27,6 +20,19 @@ pub enum DeltaOp { Pop, } +impl DeltaOp { + fn apply(self, state: &mut Vec>) { + match self { + Self::Set { index, slot } => state[index] = slot, + Self::Push { slot } => state.push(Some(slot)), + Self::Pop => { + let removed = state.pop(); + debug_assert_eq!(removed, Some(None)); + } + } + } +} + /// All lane-state mutations caused by processing a single commit. /// Applying a `Delta` to the lane state of row `n` yields the lane /// state of row `n + 1`. @@ -37,12 +43,9 @@ const CHECKPOINT_INTERVAL: usize = 100; /// Delta-compressed history of the graph's lane state. /// -/// While walking the log top-down, every commit mutates the set of -/// active lanes (a `Vec>`, one slot per lane). So, storign a -/// full copy of that state for each commit is a waste. This -/// buffer preserves ONLY the latest state PLUS the list of [`Delta`]s that -/// produced it. -/// Use [`Buffer::decompress`] to get the complete version. +/// Stores the latest lane state and the deltas that produced it. +/// Periodic checkpoints bound the cost of reconstructing a window +/// with [`Self::decompress`]. #[derive(Default)] pub struct Buffer { /// Lane state after the most recently processed commit. @@ -55,109 +58,67 @@ pub struct Buffer { /// commits, keyed by delta index, for reducing decompression cost. pub checkpoints: BTreeMap>>, - /// Aliases of merge commits whose second parent still needs a new - /// lane. - merge_commits: Vec, - /// The [`DeltaOp`]s recorded so far for the commit in flight. pending_delta: Vec, - - /// Which columns the row above the commit in flight leaves blank, - /// so a lane may start in one of them without a line above it to - /// join. Rebuilt at the start of every step. - blank_above: Vec, } impl Buffer { - /// Remember `alias` as a merge commit whose second parent must be - /// given its own lane. - pub fn track_merge_commit(&mut self, alias: CommitAlias) { - self.merge_commits.push(alias); - } - - pub fn update(&mut self, new_chunk: &LaneSlot) { - self.start_step(); - - let placement_index = self.place_chunk(new_chunk); - - if let Some(alias) = new_chunk.alias() { - self.consume_alias_in_other_chunks( - alias, - placement_index, - ); + /// Place a commit and route its optional second parent, reusing + /// an existing lane before reserving another column. + pub fn update( + &mut self, + alias: CommitAlias, + first_parent: Option, + second_parent: Option, + ) { + let slot = first_parent + .map_or(LaneSlot::Settled { alias }, |parent| { + LaneSlot::Flowing { alias, parent } + }); + let placement = self.place_chunk(slot, alias); + // Reserve before closing lanes: their closing corners still + // occupy this row, so their columns cannot host a new lane yet. + if let Some(parent) = second_parent { + if lane_awaiting_parent(&self.current, *parent).is_none() + { + self.reserve_lane_right_of(placement, parent); + } } - - self.flush_merge_commits(); + self.consume_alias_in_other_chunks(alias, placement); self.commit_delta(); } - /// Note which columns the row above draws nothing in. - /// - /// A column that is vacant *now* is not the same thing: the ones - /// emptied while this very commit is being processed still have a - /// lane drawn in the row above, and are the reason - /// [`Self::reserve_lane_right_of`] cannot simply look for a hole. - /// - /// A root commit's column is not blank. [`LaneSlot::Settled`] - /// draws a marker and no line below it, so the column is dead for - /// the rest of the walk, and it stays occupied on purpose: a - /// commit marker drawn directly beneath another one, with no line - /// between them, is how a lane draws two consecutive commits, so - /// handing the column to an unrelated history would read as - /// joining the two. - fn start_step(&mut self) { - self.blank_above.clear(); - self.blank_above - .extend(self.current.iter().map(Option::is_none)); - } - - fn place_chunk(&mut self, new_chunk: &LaneSlot) -> usize { + fn place_chunk( + &mut self, + new_chunk: LaneSlot, + alias: CommitAlias, + ) -> usize { // Prefer a lane whose current occupant awaits this chunk's // commit as its primary parent, then the leftmost column the // row above leaves blank, and only then a new column. All // three land the chunk somewhere no open lane is drawn, so - // nothing already on screen has to move. - let target = self - .find_lane_awaiting_parent(new_chunk.alias()) + // nothing already on screen has to move. Settled root columns + // remain occupied: stacking an unrelated commit directly below + // a root would look like a parent edge. + let target = lane_awaiting_parent(&self.current, alias) .or_else(|| self.first_empty_lane()) .unwrap_or(self.current.len()); if target < self.current.len() { - self.record_set(target, Some(*new_chunk)); + self.record(DeltaOp::Set { + index: target, + slot: Some(new_chunk), + }); } else { - self.record_push(*new_chunk); + self.record(DeltaOp::Push { slot: new_chunk }); } target } - fn find_lane_awaiting_parent( - &self, - alias: Option, - ) -> Option { - alias.and_then(|alias| { - self.current.iter().position(|slot| { - slot.as_ref().is_some_and(|chunk| { - chunk.awaits() == Some(alias) - }) - }) - }) - } - fn first_empty_lane(&self) -> Option { self.current.iter().position(Option::is_none) } - /// The column holding `alias`, with its slot. - fn lane_of( - &self, - alias: CommitAlias, - ) -> Option<(usize, LaneSlot)> { - self.current.iter().enumerate().find_map(|(index, slot)| { - let chunk = (*slot)?; - (chunk.alias() == Some(alias)).then_some((index, chunk)) - }) - } - fn consume_alias_in_other_chunks( &mut self, alias: CommitAlias, @@ -168,64 +129,11 @@ impl Buffer { continue; } - // `LaneSlot` is `Copy`; matching by value lets us read - // the slot out before `record_set` mutates `current`. - let new_chunk = match self.current[index] { - Some( - LaneSlot::Flowing { parent, .. } - | LaneSlot::FlowingMerge { parent, .. } - | LaneSlot::Reserved { parent }, - ) if *parent == alias => { - // The awaited parent was JUST placed. Close the - // lane. The pending second parent is dropped - // with it. - None - } - Some(LaneSlot::FlowingMerge { - alias: merge_alias, - parent, - second, - }) if *second == alias => { - // The second parent was just placed - // bridge resolves and the lane flows - Some(LaneSlot::Flowing { - alias: merge_alias, - parent, - }) - } - _ => continue, - }; - - self.record_set(index, new_chunk); - } - } - - fn flush_merge_commits(&mut self) { - while let Some(alias) = self.merge_commits.pop() { - let Some((index, chunk)) = self.lane_of(alias) else { - continue; - }; - - // Only a merge still owing its second parent needs a - // lane split off; anything else is a no-op. - let LaneSlot::FlowingMerge { - alias: merge_alias, - parent, - second, - } = chunk - else { - continue; - }; - - self.record_set( - index, - Some(LaneSlot::Flowing { - alias: merge_alias, - parent, - }), - ); - - self.reserve_lane_right_of(index, second); + if self.current[index] + .is_some_and(|slot| slot.awaits() == Some(alias)) + { + self.record(DeltaOp::Set { index, slot: None }); + } } } @@ -234,10 +142,8 @@ impl Buffer { /// /// The nearest column the row above leaves blank wins, which keeps /// the bridge short; failing that the lane goes on the right-hand - /// end. A column that only *became* blank while this very commit - /// was processed is not a candidate: the row above still draws a - /// lane there, and taking the column over would replace that - /// lane's closing corner with this one's, erasing an edge. + /// end. This runs before closing incoming lanes, so a column being + /// closed on this row cannot be reused for the merge's corner. /// /// Columns to the left are left alone even when they are free. /// Reusing them would be just as sound, but it would put a merged @@ -249,25 +155,21 @@ impl Buffer { parent: UnwalkedAlias, ) { let slot = LaneSlot::Reserved { parent }; - let reusable = - (merge_lane + 1..self.current.len()).find(|&index| { - self.current[index].is_none() - && self - .blank_above - .get(index) - .copied() - .unwrap_or(false) - }); + let reusable = (merge_lane + 1..self.current.len()) + .find(|&index| self.current[index].is_none()); match reusable { - Some(index) => self.record_set(index, Some(slot)), - None => self.record_push(slot), + Some(index) => self.record(DeltaOp::Set { + index, + slot: Some(slot), + }), + None => self.record(DeltaOp::Push { slot }), } } fn commit_delta(&mut self) { while matches!(self.current.last(), Some(None)) { - self.record_pop(); + self.record(DeltaOp::Pop); } self.deltas @@ -279,19 +181,9 @@ impl Buffer { } } - fn record_set(&mut self, index: usize, slot: Option) { - self.pending_delta.push(DeltaOp::Set { index, slot }); - self.current[index] = slot; - } - - fn record_push(&mut self, slot: LaneSlot) { - self.pending_delta.push(DeltaOp::Push { slot }); - self.current.push(Some(slot)); - } - - fn record_pop(&mut self) { - self.pending_delta.push(DeltaOp::Pop); - self.current.pop(); + fn record(&mut self, op: DeltaOp) { + op.apply(&mut self.current); + self.pending_delta.push(op); } pub fn decompress( @@ -299,6 +191,10 @@ impl Buffer { start: usize, end: usize, ) -> Vec>> { + let end = end.min(self.deltas.len().saturating_sub(1)); + if self.deltas.is_empty() || start > end { + return Vec::new(); + } let (current_index, mut state) = self.checkpoints.range(..=start).next_back().map_or_else( || (None, Vec::new()), @@ -318,7 +214,9 @@ impl Buffer { for delta_index in loop_start..=end { if let Some(delta) = self.deltas.get(delta_index) { - Self::apply_delta_to_state(&mut state, delta); + for &op in &delta.0 { + op.apply(&mut state); + } if delta_index >= start { history.push(state.clone()); @@ -330,19 +228,23 @@ impl Buffer { history } +} - fn apply_delta_to_state( - state: &mut Vec>, - delta: &Delta, - ) { - for op in &delta.0 { - match *op { - DeltaOp::Set { index, slot } => state[index] = slot, - DeltaOp::Push { slot } => state.push(Some(slot)), - DeltaOp::Pop => { - state.pop(); - } - } - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decompression_clips_requests_to_recorded_rows() { + let mut buffer = Buffer::default(); + let empty: Vec>> = Vec::new(); + assert_eq!(buffer.decompress(0, usize::MAX), empty); + buffer.update(0.into(), None, None); + assert_eq!( + buffer.decompress(0, usize::MAX), + vec![buffer.current.clone()] + ); + assert_eq!(buffer.decompress(1, usize::MAX), empty); + assert_eq!(buffer.decompress(1, 0), empty); } } diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs index 78c305090a..2a790ae811 100644 --- a/asyncgit/src/graph/chunk.rs +++ b/asyncgit/src/graph/chunk.rs @@ -9,13 +9,6 @@ pub enum LaneSlot { parent: UnwalkedAlias, }, - /// A flowing lane that still owes a bridge to a second parent - FlowingMerge { - alias: CommitAlias, - parent: UnwalkedAlias, - second: UnwalkedAlias, - }, - /// A root commit: draws its node, nothing continues below. Settled { alias: CommitAlias }, @@ -30,9 +23,9 @@ impl LaneSlot { /// reserved placeholder. pub const fn alias(&self) -> Option { match self { - Self::Flowing { alias, .. } - | Self::FlowingMerge { alias, .. } - | Self::Settled { alias } => Some(*alias), + Self::Flowing { alias, .. } | Self::Settled { alias } => { + Some(*alias) + } Self::Reserved { .. } => None, } } @@ -42,45 +35,19 @@ impl LaneSlot { pub fn awaits(&self) -> Option { match self { Self::Flowing { parent, .. } - | Self::FlowingMerge { parent, .. } | Self::Reserved { parent } => Some(**parent), Self::Settled { .. } => None, } } +} - /// The pending second parent of a merge that hasn't been given - /// its own lane. - pub fn second(&self) -> Option { - match self { - Self::FlowingMerge { second, .. } => Some(**second), - Self::Flowing { .. } - | Self::Settled { .. } - | Self::Reserved { .. } => None, - } - } - - /// What makes this the same lane from one row to the next: the - /// commit it belongs to, together with the parent it waits for. - /// - /// Whole slots cannot answer that question. A merge that still - /// owes a second parent keeps its lane when that parent is - /// finally placed, but stops being a [`Self::FlowingMerge`] in - /// the process, and comparing slots by value calls the result a - /// different lane — which cuts the lane's line at exactly the row - /// where a bridge is reaching it. - pub fn identity( - &self, - ) -> (Option, Option) { - (self.alias(), self.awaits()) - } - - /// Whether this lane's [`Self::awaits`] alias is `alias` — the - /// parent of a `Flowing`/`FlowingMerge` lane, or the reservation - /// held by a `Reserved` lane. - pub fn waits_for_second_parent( - &self, - alias: CommitAlias, - ) -> bool { - self.awaits() == Some(alias) - } +/// The first lane already heading for `parent`. Placement, reservation, +/// and bridge routing must all choose the same lane. +pub(super) fn lane_awaiting_parent( + lanes: &[Option], + parent: CommitAlias, +) -> Option { + lanes.iter().position(|slot| { + slot.is_some_and(|slot| slot.awaits() == Some(parent)) + }) } diff --git a/asyncgit/src/graph/harness/generated.rs b/asyncgit/src/graph/harness/generated.rs index aa5e6dd93d..de4f7ba6cf 100644 --- a/asyncgit/src/graph/harness/generated.rs +++ b/asyncgit/src/graph/harness/generated.rs @@ -48,9 +48,8 @@ pub enum Shape { /// Both parents are drawn from the same spread, so a mainline can /// skip over a side branch the way a real one does. Shapes whose /// first parent is always the very next commit cannot produce - /// that, and so cannot produce a lane that is left waiting for a - /// commit while another merge still owes it — which is the state a - /// merge bridge has to reach into. + /// that, and miss merges whose second-parent lanes are shared + /// across several rows. Entangled, /// Parent lists Git permits and a walk has to survive: duplicated /// entries, octopus merges, and parents a clock-skewed walk diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index be33192265..cf1c474fe3 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -1,5 +1,5 @@ use super::buffer::Buffer; -use super::chunk::LaneSlot; +use super::chunk::{lane_awaiting_parent, LaneSlot}; use super::oids::GraphOids; use super::{ CommitAlias, ConnectionType, GraphRow, LaneIndex, UnwalkedAlias, @@ -192,26 +192,14 @@ fn overlay_cell( /// flat vector instead of a map. #[derive(Clone, Copy, Debug, Default)] struct CommitFacts { - /// Whether the commit has already been folded into the buffer. - /// - /// [`GraphWalker::mint_drawable_parent`] refuses to mint an - /// [`UnwalkedAlias`] for one of these: a lane waiting on a commit - /// the walk has already passed would stay open to the end of the - /// log, drawing a line that never reaches anything. + /// Already folded into the buffer; cannot be awaited by a new lane. processed: bool, - /// Whether Git reports more than one parent for this commit, which - /// is what makes it draw a merge marker. - /// - /// A merge can legitimately end up with a single drawn edge — an - /// octopus merge past its second parent, a duplicated parent - /// entry, or a second parent the walk reached first — and keeping - /// this apart from the lanes lets the marker stay honest about - /// what the commit is in all three cases. + /// Derived from Git's parent count, independently of drawable edges. is_merge: bool, - /// The second parent that was given a lane of its own, which is - /// the far end of the row's merge bridge. + /// The drawable second parent at the far end of the merge bridge. + /// It may share a lane that already leads to the same parent. second_parent: Option, } @@ -254,13 +242,8 @@ impl GraphWalker { /// The parents this commit draws an edge to: the first two /// distinct parents the walk has not already passed. /// - /// Every parent left out is one that no lane will wait for, and - /// that is the point. Git lets a commit name the same parent twice - /// and lets an octopus merge name more than two, and a walk - /// ordered by commit time can reach a parent before its child. A - /// lane opened for any of those could never be closed by a later - /// row, so it would hang off the bottom of the log as a line that - /// goes nowhere. + /// A row has at most one merge bridge. Duplicated, self-naming, + /// and already-walked parents must not leave uncloseable lanes. fn drawable_parents( &mut self, parents: &[CommitId], @@ -309,35 +292,13 @@ impl GraphWalker { let [first_parent, second_parent] = self.drawable_parents(parents); - let chunk = match (first_parent, second_parent) { - (Some(parent), Some(second)) => LaneSlot::FlowingMerge { - alias: commit_alias, - parent, - second, - }, - (Some(parent), None) => LaneSlot::Flowing { - alias: commit_alias, - parent, - }, - // `drawable_parents` fills its slots left to right, so - // there is no second parent without a first. - (None, _) => LaneSlot::Settled { - alias: commit_alias, - }, - }; - let second = second_parent.map(|second| *second); let facts = self.facts_mut(commit_alias); facts.is_merge = parents.len() > 1; facts.second_parent = second; - if let Some(second) = second { - if !self.has_lane_to_parent(second) { - self.buffer.track_merge_commit(commit_alias); - } - } - - self.buffer.update(&chunk); + self.buffer + .update(commit_alias, first_parent, second_parent); } /// Number of commits already folded into the graph buffer. @@ -357,15 +318,8 @@ impl GraphWalker { return Vec::new(); } - // Decompress one row before the range to establish predecessor - // state. `snapshot_start_index` is usually `global_start_index - // - 1`, which also makes `snapshot_end_index` equal to - // `snapshot_start_index + commit_range.len()`. But at the very - // start of the walk `saturating_sub` clamps to `0` instead of - // going negative, since there is no predecessor row to fetch - // there; `index_offset` below (`0` in that case, `1` - // otherwise) accounts for the difference between the two - // cases when indexing into `snapshots`. + // Include a predecessor for bridge routing. At the beginning + // of the walk there is no predecessor, so the offset is zero. let snapshot_start_index = global_start_index.saturating_sub(1); let snapshot_end_index = @@ -499,25 +453,6 @@ impl GraphWalker { .collect() } - /// Whether a lane is already waiting for `target_parent`, making a - /// second lane for it both redundant and wrong. - /// - /// This has to agree exactly with the two other places that go - /// looking for a lane by the parent it awaits: the one that places - /// a commit, and the one that picks the far end of a merge bridge. - /// Both of them take the *first* such lane. So if this said no - /// while a lane was in fact waiting, the reservation opened on the - /// strength of that answer would be left over — the bridge would - /// be drawn to the lane that was already there, and the reserved - /// column would draw a line with nothing above it. - fn has_lane_to_parent(&self, target_parent: CommitAlias) -> bool { - self.buffer - .current - .iter() - .flatten() - .any(|slot| slot.awaits() == Some(target_parent)) - } - /// Whether the lane in `target_lane` is the same lane the row above /// had there, which is what lets a bridge end draw a junction into /// it instead of a corner. @@ -531,18 +466,14 @@ impl GraphWalker { }; let occupant = |snapshot: &[Option]| { - snapshot - .get(target_lane) - .copied() - .flatten() - .map(|slot| slot.identity()) + snapshot.get(target_lane).copied().flatten() }; let current = occupant(current_snapshot); current.is_some() && occupant(previous_snapshot) == current } - /// Uses `Ordering` to elegantly map spatial relationships to visual bitmasks. + /// Directions at the receiving end of a merge bridge. fn calculate_merge_directions( commit_lane: usize, target_lane: usize, @@ -710,13 +641,7 @@ impl GraphWalker { commit_lane: usize, second_parent_alias: CommitAlias, ) -> Option<(usize, usize)> { - current_snapshot - .iter() - .position(|chunk_option| { - chunk_option.as_ref().is_some_and(|chunk| { - chunk.waits_for_second_parent(second_parent_alias) - }) - }) + lane_awaiting_parent(current_snapshot, second_parent_alias) .map(|target_lane| { ( commit_lane.min(target_lane), @@ -784,9 +709,8 @@ impl GraphWalker { // heading toward HEAD: HEAD is not guaranteed to sit on the // leftmost lane, e.g. once earlier lanes have closed and a // later one has taken their place. - let is_dotted = head_alias.is_some() - && (chunk.awaits() == head_alias - || chunk.second() == head_alias); + let is_dotted = + head_alias.is_some() && chunk.awaits() == head_alias; if is_dotted { Some(ConnectionType::VerticalDotted) diff --git a/asyncgit/src/graph/walker/tests.rs b/asyncgit/src/graph/walker/tests.rs index 25832f22aa..b01d0ee3fe 100644 --- a/asyncgit/src/graph/walker/tests.rs +++ b/asyncgit/src/graph/walker/tests.rs @@ -12,7 +12,7 @@ fn linear_history() { #[test] fn history_with_multiple_roots() { // Two independent commit chains, each terminating in its own - // root (a commit with no parents), STAY IN THEIR LANE! A root + // root (a commit with no parents), keep separate lanes. A root // draws a marker and no line below it, so handing its column // to the next history would stack two commit markers in one // column — which is exactly how a lane draws two consecutive @@ -46,14 +46,9 @@ fn merge_into_tracked_lane_continues_through_corner() { } #[test] -fn merge_into_a_lane_that_stops_owing_a_parent_keeps_flowing() { - // Commit 2 is a merge that found a lane already heading for - // its second parent, so its own lane keeps that debt open. - // Commit 4 settles it — commit 4 *is* that second parent — and - // in the same row merges the commit that lane is waiting for, - // so its bridge ends there. That lane runs through the row, - // which makes the bridge end a junction; it is only the slot's - // shape that changed, not the lane. +fn merging_a_shared_second_parent_preserves_the_other_lane() { + // Commit 2 reuses commit 1's lane for parent 4. When 4 merges + // parent 6, its bridge must join commit 2's continuing lane. Scene::walk(&[ (1, &[4]), (2, &[6, 4]), @@ -200,14 +195,8 @@ fn second_parent_lane_skips_a_column_this_commit_freed() { #[test] fn merge_bridges_to_the_lane_its_second_parent_already_has() { - // Commit 2 is a merge that found a lane already heading for - // its second parent, so it owes that parent nothing and keeps - // a lane of its own open for it. Commit 3 then merges the - // same commit 6 that commit 2's lane is waiting for: it has to - // bridge into that lane, not reserve a second column for a - // parent only one lane can receive. A second column would be - // left drawing a line that starts out of nothing, because the - // bridge is always drawn to the first lane waiting. + // Commit 2 reuses the lane already heading for 5. Commit 3 must + // likewise reuse commit 2's lane for 6, even though 2 is a merge. Scene::walk(&[ (1, &[5]), (2, &[6, 5]), @@ -271,7 +260,7 @@ fn skewed_parent_before_child_leaves_no_phantom_lane() { fn complete_walk_settles_all_lanes() { // Both parents of 1 appear before it in the walk (clock // skew). After a complete walk no lane may still wait on a - // parent, creating the evil phantom lines + // parent. let scene = Scene::walk(&[(2, &[4]), (3, &[4]), (1, &[2, 3]), (4, &[])]); scene.assert_drawing(&["o", "┃ o", "┃ ┃ M", "o━┛"]); @@ -334,6 +323,31 @@ fn head_lane_is_dotted_in_any_column() { .assert_drawing(&["o", "┃ o", "o ╏", "┃ o", "o━┛"]); } +#[test] +fn merging_head_dots_only_the_lane_heading_for_head() { + Scene::walk_marked( + &[ + (1, &[4]), + (2, &[5, 4]), + (3, &[6]), + (4, &[6]), + (5, &[6]), + (6, &[]), + ], + &[], + &[], + Some(4), + ) + .assert_drawing(&[ + "o", + "┣━M", + "╏ ┃ o", + "o ┃ ┃", + "┃ o ┃", + "o━┻━┛", + ]); +} + #[test] fn branch_tip_and_stash_markers_leave_the_lines_alone() { Scene::walk_marked( @@ -386,7 +400,7 @@ fn branch_bridge_crosses_unrelated_lane_keeps_own_color() { ]); // row of commit 5: lane 1 is crossed by the branch bridge but - // keeps both its vertical glyph and its own lane color (visual identity mans) + // keeps both its vertical glyph and its own lane color (its visual identity) let crossed = scene.rows[3].lanes[1] .expect("crossed lane should not be empty"); assert_eq!(crossed.0, ConnectionType::Vertical); From fedfea4ae8a5258070d7b0f6840730c7a93677b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 06:16:45 +0000 Subject: [PATCH 80/84] graph: retain cell geometry until overlays are complete Accumulate crossings and joins in a row cell before choosing its final glyph. This removes the lossy glyph-to-directions conversion that made junctions depend on overlay order. Keep commit markers, vertical lane colors, and dotted crossings intact, with focused tests at the cell abstraction. --- asyncgit/src/graph/cell.rs | 202 +++++++++++++++++++++++++ asyncgit/src/graph/mod.rs | 4 + asyncgit/src/graph/walker.rs | 278 +++++++---------------------------- 3 files changed, 256 insertions(+), 228 deletions(-) create mode 100644 asyncgit/src/graph/cell.rs diff --git a/asyncgit/src/graph/cell.rs b/asyncgit/src/graph/cell.rs new file mode 100644 index 0000000000..65bb15e3ab --- /dev/null +++ b/asyncgit/src/graph/cell.rs @@ -0,0 +1,202 @@ +use super::{ConnectionType, GraphCell, LaneIndex}; + +use bitflags::bitflags; + +bitflags! { + /// The neighboring cells a lane joins to. Overlapping lines + /// are merged through this representation rather than + /// a naive overwrite for readability. + /// + /// [`Self::LEFT`] and [`Self::RIGHT`] are a horizontal run that + /// *joins* this cell; [`Self::CROSS`] is one that runs past it + /// without joining. Keeping the two apart is what lets a bridge be + /// drawn behind an unrelated lane, while a bridge that really ends + /// on a lane still gets its junction even when a third line + /// crosses the same cell. + #[derive(Clone, Copy, Default)] + pub(super) struct Directions: u8 { + const UP = 0b0_0001; + const DOWN = 0b0_0010; + const LEFT = 0b0_0100; + const RIGHT = 0b0_1000; + const CROSS = 0b1_0000; + } +} + +impl Directions { + fn vertical(self) -> bool { + self.intersects(Self::UP | Self::DOWN) + } +} + +/// Accumulate geometry before choosing a glyph: a vertical glyph hides +/// a crossing, so converting it back to directions would lose that fact +/// before a later overlay could turn the cell into a junction. +#[derive(Clone, Copy, Default)] +pub(super) struct Cell { + directions: Directions, + color: LaneIndex, + marker: Option, + dotted: bool, +} + +impl Cell { + pub const fn line( + directions: Directions, + color: LaneIndex, + dotted: bool, + ) -> Self { + Self { + directions, + color, + marker: None, + dotted, + } + } + + pub const fn commit( + marker: ConnectionType, + color: LaneIndex, + ) -> Self { + Self { + directions: Directions::empty(), + color, + marker: Some(marker), + dotted: false, + } + } + + /// Preserve commit markers and the color of the vertical lane. + pub fn overlay( + &mut self, + directions: Directions, + color: LaneIndex, + ) { + if self.marker.is_some() { + return; + } + if self.directions.is_empty() + || (!self.directions.vertical() && directions.vertical()) + { + self.color = color; + } + self.directions |= directions; + } + + pub const fn finish(self) -> GraphCell { + let connection = match self.marker { + Some(marker) => marker, + None if self.directions.is_empty() => return None, + None => { + directions_to_connection(self.directions, self.dotted) + } + }; + Some((connection, self.color)) + } +} + +const fn vertical_connection(dotted: bool) -> ConnectionType { + if dotted { + ConnectionType::VerticalDotted + } else { + ConnectionType::Vertical + } +} + +/// Synthesize a connection glyph from direction components. +const fn directions_to_connection( + dirs: Directions, + dotted: bool, +) -> ConnectionType { + let up = dirs.contains(Directions::UP); + let down = dirs.contains(Directions::DOWN); + let joins_left = dirs.contains(Directions::LEFT); + let joins_right = dirs.contains(Directions::RIGHT); + let crossed = dirs.contains(Directions::CROSS); + + // A lane running straight through the cell. A bridge that merely + // crosses it is drawn behind it, which keeps the lane readable and + // is the whole reason a crossing is tracked apart from a join. A + // bridge that ends on the lane needs a junction instead, and when + // the cell is both joined and crossed there is nothing left to + // hide the join behind, so it takes the full cross. Drawing that + // case as a plain vertical, which is all a single horizontal bit + // could do, loses the merge edge out of the picture entirely. + if up && down { + return match (joins_left, joins_right, crossed) { + (false, false, _) => vertical_connection(dotted), + (true, false, false) => ConnectionType::TeeLeft, + (false, true, false) => ConnectionType::TeeRight, + _ => ConnectionType::Cross, + }; + } + + // Below here the cell has at most one vertical end, so a crossing + // and a join on the same side are drawn alike. + let left = joins_left || crossed; + let right = joins_right || crossed; + + match (up, down, left, right) { + (true, _, true, true) => ConnectionType::TeeUp, + (_, true, true, true) => ConnectionType::TeeDown, + (true, _, true, false) => ConnectionType::BranchUp, + (true, _, false, true) => ConnectionType::BranchUpRight, + (_, true, true, false) => ConnectionType::MergeBridgeStart, + (_, true, false, true) => ConnectionType::MergeBridgeEnd, + (false, false, _, _) => ConnectionType::MergeBridgeMid, + (true | false, _, false, false) => { + vertical_connection(dotted) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_join_and_crossing_preserve_the_junction_in_either_order() { + for overlays in [ + [Directions::CROSS, Directions::LEFT], + [Directions::LEFT, Directions::CROSS], + ] { + let mut cell = Cell::line( + Directions::UP | Directions::DOWN, + 0.into(), + false, + ); + for directions in overlays { + cell.overlay(directions, 1.into()); + } + assert_eq!( + cell.finish(), + Some((ConnectionType::Cross, 0.into())) + ); + } + } + + #[test] + fn a_crossing_preserves_a_dotted_lane_and_its_color() { + let mut cell = Cell::line( + Directions::UP | Directions::DOWN, + 2.into(), + true, + ); + cell.overlay(Directions::CROSS, 1.into()); + assert_eq!( + cell.finish(), + Some((ConnectionType::VerticalDotted, 2.into())) + ); + } + + #[test] + fn overlays_preserve_commit_markers() { + let mut cell = + Cell::commit(ConnectionType::CommitMerge, 2.into()); + cell.overlay(Directions::CROSS | Directions::UP, 1.into()); + assert_eq!( + cell.finish(), + Some((ConnectionType::CommitMerge, 2.into())) + ); + } +} diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index ce5293ebdb..d9bd08bc65 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -1,4 +1,5 @@ pub mod buffer; +mod cell; pub mod chunk; pub mod oids; pub mod walker; @@ -159,3 +160,6 @@ pub struct GraphRow { /// spans to draw ─ ╭ ╮ across pub branches: Vec<(LaneIndex, LaneIndex)>, } + +/// A glyph and its lane color, or an empty terminal cell. +pub type GraphCell = Option<(ConnectionType, LaneIndex)>; diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index cf1c474fe3..96e2226171 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -1,4 +1,5 @@ use super::buffer::Buffer; +use super::cell::{Cell, Directions}; use super::chunk::{lane_awaiting_parent, LaneSlot}; use super::oids::GraphOids; use super::{ @@ -14,179 +15,6 @@ fn lane_color(lane: usize) -> LaneIndex { LaneIndex::from(lane % MAX_LANE_COLORS) } -use bitflags::bitflags; - -bitflags! { - /// The neighboring cells a lane joins to. Overlapping lines - /// are merged through this representation rather than - /// a naive overwrite for readability. - /// - /// [`Self::LEFT`] and [`Self::RIGHT`] are a horizontal run that - /// *joins* this cell; [`Self::CROSS`] is one that runs past it - /// without joining. Keeping the two apart is what lets a bridge be - /// drawn behind an unrelated lane, while a bridge that really ends - /// on a lane still gets its junction even when a third line - /// crosses the same cell. - #[derive(Clone, Copy, Default)] - struct Directions: u8 { - const UP = 0b0_0001; - const DOWN = 0b0_0010; - const LEFT = 0b0_0100; - const RIGHT = 0b0_1000; - const CROSS = 0b1_0000; - } -} - -impl Directions { - #[allow(clippy::missing_const_for_fn)] - fn merge(self, other: Self) -> Self { - Self::from_bits_retain(self.bits() | other.bits()) - } - - #[allow(clippy::missing_const_for_fn)] - fn vertical(self) -> bool { - self.intersects(Self::UP | Self::DOWN) - } -} - -/// Extract direction components from a connection glyph. -/// Returns `None` for commit markers, which are never drawn over. -fn connection_to_directions( - conn: ConnectionType, -) -> Option { - Some(match conn { - ConnectionType::Vertical | ConnectionType::VerticalDotted => { - Directions::UP | Directions::DOWN - } - ConnectionType::MergeBridgeMid => { - Directions::LEFT | Directions::RIGHT - } - ConnectionType::MergeBridgeStart => { - Directions::DOWN | Directions::LEFT - } - ConnectionType::MergeBridgeEnd => { - Directions::DOWN | Directions::RIGHT - } - ConnectionType::BranchUp => Directions::UP | Directions::LEFT, - ConnectionType::BranchUpRight => { - Directions::UP | Directions::RIGHT - } - ConnectionType::TeeLeft => { - Directions::UP | Directions::DOWN | Directions::LEFT - } - ConnectionType::TeeRight => { - Directions::UP | Directions::DOWN | Directions::RIGHT - } - ConnectionType::TeeUp => { - Directions::UP | Directions::LEFT | Directions::RIGHT - } - ConnectionType::TeeDown => { - Directions::DOWN | Directions::LEFT | Directions::RIGHT - } - ConnectionType::Cross => { - Directions::UP - | Directions::DOWN - | Directions::LEFT - | Directions::RIGHT - } - ConnectionType::CommitNormal - | ConnectionType::CommitBranch - | ConnectionType::CommitMerge - | ConnectionType::CommitStash - | ConnectionType::CommitUncommitted => return None, - }) -} - -const fn vertical_connection(dotted: bool) -> ConnectionType { - if dotted { - ConnectionType::VerticalDotted - } else { - ConnectionType::Vertical - } -} - -/// Synthesize a connection glyph from direction components. -const fn directions_to_connection( - dirs: Directions, - dotted: bool, -) -> ConnectionType { - let up = dirs.contains(Directions::UP); - let down = dirs.contains(Directions::DOWN); - let joins_left = dirs.contains(Directions::LEFT); - let joins_right = dirs.contains(Directions::RIGHT); - let crossed = dirs.contains(Directions::CROSS); - - // A lane running straight through the cell. A bridge that merely - // crosses it is drawn behind it, which keeps the lane readable and - // is the whole reason a crossing is tracked apart from a join. A - // bridge that ends on the lane needs a junction instead, and when - // the cell is both joined and crossed there is nothing left to - // hide the join behind, so it takes the full cross. Drawing that - // case as a plain vertical, which is all a single horizontal bit - // could do, loses the merge edge out of the picture entirely. - if up && down { - return match (joins_left, joins_right, crossed) { - (false, false, _) => vertical_connection(dotted), - (true, false, false) => ConnectionType::TeeLeft, - (false, true, false) => ConnectionType::TeeRight, - _ => ConnectionType::Cross, - }; - } - - // Below here the cell has at most one vertical end, so a crossing - // and a join on the same side are drawn alike. - let left = joins_left || crossed; - let right = joins_right || crossed; - - match (up, down, left, right) { - (true, _, true, true) => ConnectionType::TeeUp, - (_, true, true, true) => ConnectionType::TeeDown, - (true, _, true, false) => ConnectionType::BranchUp, - (true, _, false, true) => ConnectionType::BranchUpRight, - (_, true, true, false) => ConnectionType::MergeBridgeStart, - (_, true, false, true) => ConnectionType::MergeBridgeEnd, - (false, false, _, _) => ConnectionType::MergeBridgeMid, - (true | false, _, false, false) => { - vertical_connection(dotted) - } - } -} - -/// Draw `add` into a cell, merging with whatever is already there. -/// The line with a vertical component is the chosen way with color -/// ensuring lanes stay visually continuous -fn overlay_cell( - cell: &mut Option<(ConnectionType, LaneIndex)>, - add: Directions, - color: LaneIndex, -) { - if let Some((conn, existing_color)) = cell { - // `connection_to_directions` returns `None` for commit markers, which must - // never be drawn over; when that happens this branch is - // skipped and `cell` is left untouched. - if let Some(existing) = connection_to_directions(*conn) { - let is_dotted = conn.is_dotted(); - - let resolved_color = - if existing.vertical() || !add.vertical() { - *existing_color - } else { - color - }; - - *cell = Some(( - directions_to_connection( - existing.merge(add), - is_dotted, - ), - resolved_color, - )); - } - } else { - *cell = Some((directions_to_connection(add, false), color)); - } -} - /// What the walk learned about one commit, indexed by its /// [`CommitAlias`]. Aliases are dense `0..n`, so the facts live in a /// flat vector instead of a map. @@ -358,7 +186,7 @@ impl GraphWalker { } fn draw_merge_bridge( - lanes: &mut [Option<(ConnectionType, LaneIndex)>], + lanes: &mut [Cell], merge_bridge: Option<(usize, usize)>, commit_lane: usize, current_snapshot: &[Option], @@ -395,12 +223,8 @@ impl GraphWalker { ); // Replace the plain vertical fill with the precise corner/junction - lanes[destination_lane] = None; - overlay_cell( - &mut lanes[destination_lane], - target_directions, - connection_color, - ); + lanes[destination_lane] = + Cell::line(target_directions, connection_color, false); Self::draw_bridge_span( lanes, @@ -411,7 +235,7 @@ impl GraphWalker { } fn draw_branching_lanes( - lanes: &mut Vec>, + lanes: &mut Vec, branching_lanes: &[usize], commit_lane: usize, ) -> Vec<(LaneIndex, LaneIndex)> { @@ -439,11 +263,8 @@ impl GraphWalker { start_lane, end_lane, ); - overlay_cell( - &mut lanes[branch_lane], - branch_directions, - connection_color, - ); + lanes[branch_lane] + .overlay(branch_directions, connection_color); ( LaneIndex::from(start_lane), @@ -512,11 +333,11 @@ impl GraphWalker { } fn ensure_lane_capacity( - lanes: &mut Vec>, + lanes: &mut Vec, required_index: usize, ) { if lanes.len() <= required_index { - lanes.resize(required_index + 1, None); + lanes.resize(required_index + 1, Cell::default()); } } @@ -558,36 +379,36 @@ impl GraphWalker { }) }); - let mut lanes: Vec> = - current_snapshot - .iter() - .enumerate() - .map(|(lane_index, chunk_option)| { - let chunk = chunk_option.as_ref()?; - - if commit_alias.is_some() - && chunk.alias() == commit_alias - { - let connection = - Self::determine_commit_connection( - is_stash, - is_merge, - is_branch_tip, - ); - return Some(( - connection, - lane_color(lane_index), - )); - } - - Self::determine_passthrough_connection( - chunk, head_alias, - ) - .map(|connection| { - (connection, lane_color(lane_index)) - }) - }) - .collect(); + let mut lanes: Vec = current_snapshot + .iter() + .enumerate() + .map(|(lane_index, chunk_option)| { + let Some(chunk) = chunk_option else { + return Cell::default(); + }; + + if commit_alias.is_some() + && chunk.alias() == commit_alias + { + let connection = + Self::determine_commit_connection( + is_stash, + is_merge, + is_branch_tip, + ); + return Cell::commit( + connection, + lane_color(lane_index), + ); + } + + Self::determine_passthrough_connection( + chunk, + head_alias, + lane_color(lane_index), + ) + }) + .collect(); let branches = commit_lane.map_or_else(Vec::new, |cl| { Self::draw_merge_bridge( @@ -611,7 +432,7 @@ impl GraphWalker { is_merge, is_branch_tip, is_stash, - lanes, + lanes: lanes.into_iter().map(Cell::finish).collect(), merge_bridge: merge_bridge.map(|(source, target)| { (LaneIndex::from(source), LaneIndex::from(target)) }), @@ -699,10 +520,11 @@ impl GraphWalker { fn determine_passthrough_connection( chunk: &LaneSlot, head_alias: Option, - ) -> Option { + color: LaneIndex, + ) -> Cell { // A settled lane draws nothing below its commit. if matches!(chunk, LaneSlot::Settled { .. }) { - return None; + return Cell::default(); } // Dot the segment of any lane, not just lane 0, that is @@ -712,11 +534,11 @@ impl GraphWalker { let is_dotted = head_alias.is_some() && chunk.awaits() == head_alias; - if is_dotted { - Some(ConnectionType::VerticalDotted) - } else { - Some(ConnectionType::Vertical) - } + Cell::line( + Directions::UP | Directions::DOWN, + color, + is_dotted, + ) } /// Lay the horizontal run of a bridge over the lanes strictly @@ -729,13 +551,13 @@ impl GraphWalker { /// underneath it, and what stops it from swallowing a junction /// another bridge left in the same cell. fn draw_bridge_span( - lanes: &mut [Option<(ConnectionType, LaneIndex)>], + lanes: &mut [Cell], from: usize, to: usize, color: LaneIndex, ) { for lane in lanes.iter_mut().take(to).skip(from + 1) { - overlay_cell(lane, Directions::CROSS, color); + lane.overlay(Directions::CROSS, color); } } } From 563d03864ae7a7a60f54544cb4cdeeac8753535e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 06:17:56 +0000 Subject: [PATCH 81/84] graph: share terminal layout between the UI and invariant tests Move glyph selection and bridge spacers onto the graph types so the readback tests examine the same picture the log view draws. Preserve spacer colors when empty columns are hidden. Derive row width from its columns and make lane indices lossless. Retain single-glyph validation and add coverage for column indices beyond u16. --- asyncgit/src/graph/harness/mod.rs | 74 +++--------------- asyncgit/src/graph/harness/readback.rs | 51 +++++++++++++ asyncgit/src/graph/mod.rs | 102 ++++++++++++++++++------- asyncgit/src/graph/walker.rs | 1 - asyncgit/src/graph/walker/tests.rs | 7 +- src/components/commitlist.rs | 93 ++++++++++------------ src/components/utils/graphrow.rs | 82 -------------------- src/components/utils/logitems.rs | 2 +- src/components/utils/mod.rs | 1 - 9 files changed, 179 insertions(+), 234 deletions(-) delete mode 100644 src/components/utils/graphrow.rs diff --git a/asyncgit/src/graph/harness/mod.rs b/asyncgit/src/graph/harness/mod.rs index 5291d44281..621242098a 100644 --- a/asyncgit/src/graph/harness/mod.rs +++ b/asyncgit/src/graph/harness/mod.rs @@ -29,9 +29,7 @@ pub use generated::{walk_generated, Shape}; use super::buffer::DeltaOp; use super::chunk::LaneSlot; -use super::{ - CommitAlias, ConnectionType, GraphRow, GraphWalker, LaneIndex, -}; +use super::{CommitAlias, GraphRow, GraphWalker}; use crate::sync::CommitId; use readback::{side_name, Cursor, Picture}; use std::collections::{BTreeSet, HashMap, HashSet}; @@ -49,36 +47,6 @@ pub fn oid(n: usize) -> CommitId { .expect("valid oid") } -/// The character the log view draws for a connection. -/// -/// This mirrors the symbol table in -/// `gitui::components::utils::graphrow`, including the fact that a -/// stash and a branch tip share one marker, so the drawings in these -/// tests are the picture a terminal shows rather than a prettier -/// stand-in. Which marker a row drew is pinned separately, by -/// [`Scene::assert_rows_are_well_formed`]. -pub fn glyph(conn: ConnectionType) -> char { - match conn { - ConnectionType::Vertical => '┃', - ConnectionType::VerticalDotted => '╏', - ConnectionType::CommitNormal => 'o', - ConnectionType::CommitBranch - | ConnectionType::CommitStash => '*', - ConnectionType::CommitMerge => 'M', - ConnectionType::CommitUncommitted => '+', - ConnectionType::MergeBridgeStart => '┓', - ConnectionType::MergeBridgeMid => '━', - ConnectionType::MergeBridgeEnd => '┏', - ConnectionType::BranchUp => '┛', - ConnectionType::BranchUpRight => '┗', - ConnectionType::TeeLeft => '┫', - ConnectionType::TeeRight => '┣', - ConnectionType::TeeUp => '┻', - ConnectionType::TeeDown => '┳', - ConnectionType::Cross => '╋', - } -} - /// One walked history: the rows it draws, and the lane state behind /// them. pub struct Scene { @@ -173,22 +141,15 @@ impl Scene { self.rows .iter() .map(|row| { - let mut cells = - Vec::with_capacity(row.lanes.len() * 2); - for (lane, conn) in row.lanes.iter().enumerate() { - cells.push(conn.map_or(' ', |(c, _)| glyph(c))); - - let in_bridge = row - .merge_bridge - .into_iter() - .chain(row.branches.iter().copied()) - .any(|(from, to)| { - lane >= usize::from(from) - && lane < usize::from(to) - }); - cells.push(if in_bridge { '━' } else { ' ' }); - } - cells + row.columns() + .flatten() + .flat_map(|cell| { + cell.map_or(" ", |(connection, _)| { + connection.symbol() + }) + .chars() + }) + .collect() }) .collect() } @@ -436,11 +397,6 @@ impl Scene { fn assert_rows_are_well_formed(&self) { for (index, row) in self.rows.iter().enumerate() { let width = row.lanes.len(); - assert_eq!( - usize::from(row.lane_count), - width, - "row {index} reports a width it did not draw" - ); let markers = row .lanes @@ -698,13 +654,3 @@ impl Union { } } } - -/// Assert a `LaneIndex` round-trips the column it was built from, so a -/// wide graph is reported honestly rather than clamped. -pub fn assert_lane_index_exact(column: usize) { - assert_eq!( - usize::from(LaneIndex::from(column)), - column, - "column {column} does not survive a LaneIndex round trip" - ); -} diff --git a/asyncgit/src/graph/harness/readback.rs b/asyncgit/src/graph/harness/readback.rs index 616a92206d..0d1486b8d1 100644 --- a/asyncgit/src/graph/harness/readback.rs +++ b/asyncgit/src/graph/harness/readback.rs @@ -238,3 +238,54 @@ impl Picture { dangling } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::ConnectionType; + + #[test] + fn renderer_symbols_are_single_recognized_cells() { + use ConnectionType::{ + BranchUp, BranchUpRight, CommitBranch, CommitMerge, + CommitNormal, CommitStash, CommitUncommitted, Cross, + MergeBridgeEnd, MergeBridgeMid, MergeBridgeStart, + TeeDown, TeeLeft, TeeRight, TeeUp, Vertical, + VerticalDotted, + }; + for connection in [ + Vertical, + VerticalDotted, + CommitNormal, + CommitBranch, + CommitMerge, + CommitStash, + CommitUncommitted, + MergeBridgeStart, + MergeBridgeMid, + MergeBridgeEnd, + BranchUp, + BranchUpRight, + TeeLeft, + TeeRight, + TeeUp, + TeeDown, + Cross, + ] { + let glyphs = + connection.symbol().chars().collect::>(); + assert_eq!( + glyphs.len(), + 1, + "{connection:?} occupies more than one cell" + ); + let cell = classify(glyphs[0]); + assert_ne!( + cell, + Cell::Blank, + "unrecognized glyph for {connection:?}" + ); + assert_eq!(cell == Cell::Node, connection.is_commit()); + } + } +} diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs index d9bd08bc65..21ce349c17 100644 --- a/asyncgit/src/graph/mod.rs +++ b/asyncgit/src/graph/mod.rs @@ -13,16 +13,9 @@ pub use walker::GraphWalker; pub const MAX_LANE_COLORS: usize = 16; /// A lane's column within a row, or its slot in the color palette. -/// -/// Repositories that open more lanes than this can hold do exist, and -/// no terminal can show that many columns usefully, so the conversion -/// from a column index saturates rather than wrapping. It is sized so -/// that the clamp is out of reach of any history a person scrolls -/// through, because past the clamp every lane reports the same column: -/// a width that is merely too large is cosmetic, while a column index -/// that is wrong misplaces an edge. +/// Column indices are lossless even when the graph is wider than a terminal. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct LaneIndex(u16); +pub struct LaneIndex(usize); /// Numeric alias assigned to each commit in the graph. /// @@ -58,13 +51,13 @@ impl From for CommitAlias { impl From for LaneIndex { fn from(lane: usize) -> Self { - Self(u16::try_from(lane).unwrap_or(u16::MAX)) + Self(lane) } } impl From for usize { fn from(lane: LaneIndex) -> Self { - lane.0 as Self + lane.0 } } @@ -101,9 +94,26 @@ pub enum ConnectionType { } impl ConnectionType { - /// Whether this connection is a dotted vertical line. - pub const fn is_dotted(self) -> bool { - matches!(self, Self::VerticalDotted) + /// The terminal glyph used by both the log view and graph readback tests. + pub const fn symbol(self) -> &'static str { + match self { + Self::Vertical => "┃", + Self::VerticalDotted => "╏", + Self::CommitNormal => "o", + Self::CommitBranch | Self::CommitStash => "*", + Self::CommitMerge => "M", + Self::CommitUncommitted => "+", + Self::MergeBridgeStart => "┓", + Self::MergeBridgeMid => "━", + Self::MergeBridgeEnd => "┏", + Self::BranchUp => "┛", + Self::BranchUpRight => "┗", + Self::TeeLeft => "┫", + Self::TeeRight => "┣", + Self::TeeUp => "┻", + Self::TeeDown => "┳", + Self::Cross => "╋", + } } /// Whether this is a commit marker rather than a line. @@ -125,20 +135,10 @@ impl ConnectionType { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct GraphRow { - /// How many lane columns this row spans, counting any column it - /// leaves blank between two lanes. - /// - /// This is the width the row needs, which is what the log view - /// sizes its graph column from. It is deliberately not a count of - /// live lanes: a row whose middle lanes have closed still has to - /// reserve the columns to the right of the gap, and sizing from a - /// count would hide them behind the truncation marker. - pub lane_count: LaneIndex, - /// Which lane index this commit sits on pub commit_lane: LaneIndex, - /// Whether this is a merge commit (two parents) + /// Whether Git reports more than one parent for this commit pub is_merge: bool, /// Whether this commit is a branch tip @@ -150,7 +150,7 @@ pub struct GraphRow { /// Connections emitted per lane: /// None = empty space /// Some((ConnectionType, `color_index`)) = draw this connector in this color - pub lanes: Vec>, + pub lanes: Vec, /// Horizontal merge bridge: if this commit merges rightward, /// (`from_lane`, `to_lane`) — the span to draw ─ ╭ ╮ across @@ -163,3 +163,53 @@ pub struct GraphRow { /// A glyph and its lane color, or an empty terminal cell. pub type GraphCell = Option<(ConnectionType, LaneIndex)>; + +impl GraphRow { + /// Number of lane columns, including gaps between open lanes. + pub const fn lane_count(&self) -> usize { + self.lanes.len() + } + + /// Two terminal cells per lane: its connector and the horizontal + /// bridge segment (or blank) immediately to its right. + pub fn columns( + &self, + ) -> impl Iterator + '_ { + self.lanes.iter().enumerate().map(|(lane, &cell)| { + let spacer = self + .merge_bridge + .into_iter() + .chain(self.branches.iter().copied()) + .rfind(|&(from, to)| { + (usize::from(from)..usize::from(to)) + .contains(&lane) + }) + .map(|(from, to)| { + let target = if from == self.commit_lane { + to + } else { + from + }; + ( + ConnectionType::MergeBridgeMid, + LaneIndex::from( + usize::from(target) % MAX_LANE_COLORS, + ), + ) + }); + [cell, spacer] + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lane_indices_preserve_columns_beyond_u16() { + for column in [usize::from(u16::MAX) + 1, usize::MAX] { + assert_eq!(usize::from(LaneIndex::from(column)), column); + } + } +} diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs index 96e2226171..4620ff33c6 100644 --- a/asyncgit/src/graph/walker.rs +++ b/asyncgit/src/graph/walker.rs @@ -427,7 +427,6 @@ impl GraphWalker { }); GraphRow { - lane_count: LaneIndex::from(lanes.len()), commit_lane: LaneIndex::from(commit_lane.unwrap_or(0)), is_merge, is_branch_tip, diff --git a/asyncgit/src/graph/walker/tests.rs b/asyncgit/src/graph/walker/tests.rs index b01d0ee3fe..b39e44089c 100644 --- a/asyncgit/src/graph/walker/tests.rs +++ b/asyncgit/src/graph/walker/tests.rs @@ -1,7 +1,5 @@ use super::*; -use crate::graph::harness::{ - assert_lane_index_exact, oid, walk_generated, Scene, Shape, -}; +use crate::graph::harness::{oid, walk_generated, Scene, Shape}; #[test] fn linear_history() { @@ -438,10 +436,9 @@ fn a_wide_history_reports_every_column() { let scene = Scene::walk(&borrowed); let last = &scene.rows[LANES - 1]; - assert_eq!(usize::from(last.lane_count), LANES); + assert_eq!(last.lane_count(), LANES); assert_eq!(usize::from(last.commit_lane), LANES - 1); assert_eq!(scene.open_lane_count(), LANES); - assert_lane_index_exact(LANES - 1); } #[test] diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 5c880c7361..78cfdab2dd 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,4 +1,3 @@ -use super::utils::graphrow::{symbol, SYM_HORIZONTAL, SYM_SPACE}; use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ app::Environment, @@ -506,63 +505,26 @@ impl CommitList { ) -> Vec> { let mut spans = Vec::new(); - for (lane_index, conn) in row.lanes.iter().enumerate() { + for (lane_index, column) in row.columns().enumerate() { if empty_lanes.contains(&lane_index) { continue; } - let (sym, graph_color) = match conn { - None => (SYM_SPACE, Color::Reset), - Some((conn_type, color_idx)) => ( - symbol(*conn_type), - GRAPH_COLORS[usize::from(*color_idx) - % GRAPH_COLORS.len()], - ), - }; - spans.push(Span::styled( - sym, - Style::default().fg(graph_color), - )); - - // Spacer - let mut is_bridge_lane = false; - let mut spacer_color = graph_color; - - let commit_lane = usize::from(row.commit_lane); - - let edges = row - .merge_bridge - .into_iter() - .chain(row.branches.iter().copied()); - - for (st, fin) in edges { - let (start, finish) = - (usize::from(st), usize::from(fin)); - - if (start..finish).contains(&lane_index) { - is_bridge_lane = true; - let target_lane = if commit_lane == start { - finish - } else { - start - }; - spacer_color = GRAPH_COLORS - [target_lane % GRAPH_COLORS.len()]; - } - } - - if is_bridge_lane { - spans.push(Span::styled( - SYM_HORIZONTAL, - Style::default().fg(spacer_color), - )); - continue; + for cell in column { + spans.push(match cell { + Some((connection, color)) => Span::styled( + connection.symbol(), + Style::default() + .fg(GRAPH_COLORS[usize::from(color) + % GRAPH_COLORS.len()]), + ), + None => Span::raw(" "), + }); } - spans.push(Span::raw(SYM_SPACE)); } let current_width = spans.len(); for _ in current_width..graph_col_width { - spans.push(Span::raw(SYM_SPACE)); + spans.push(Span::raw(" ")); } spans @@ -1131,7 +1093,6 @@ impl Component for CommitList { #[cfg(test)] mod tests { - use super::super::utils::graphrow::SYM_COMMIT; use asyncgit::sync::CommitInfo; use super::*; @@ -1312,7 +1273,6 @@ mod tests { #[test] fn test_build_graph_spans() { let row = GraphRow { - lane_count: 1.into(), commit_lane: 0.into(), is_merge: false, is_branch_tip: false, @@ -1331,7 +1291,32 @@ mod tests { ); assert_eq!(spans.len(), 2); - assert_eq!(spans[0].content, Cow::from(SYM_COMMIT)); - assert_eq!(spans[1].content, Cow::from(SYM_SPACE)); + assert_eq!(spans[0].content, Cow::from("o")); + assert_eq!(spans[1].content, Cow::from(" ")); + } + + #[test] + fn graph_spans_keep_bridge_color_when_empty_columns_are_hidden() { + let row = GraphRow { + commit_lane: 0.into(), + lanes: vec![ + Some((ConnectionType::CommitMerge, 0.into())), + Some((ConnectionType::MergeBridgeMid, 2.into())), + Some((ConnectionType::MergeBridgeStart, 2.into())), + ], + merge_bridge: Some((0.into(), 2.into())), + ..GraphRow::default() + }; + let spans = CommitList::build_graph_spans( + &row, + 6, + &std::collections::HashSet::from([1]), + ); + let text: String = + spans.iter().map(|span| span.content.as_ref()).collect(); + assert_eq!(text, "M━┓ "); + assert_eq!(spans[0].style.fg, Some(GRAPH_COLORS[0])); + assert_eq!(spans[1].style.fg, Some(GRAPH_COLORS[2])); + assert_eq!(spans[2].style.fg, Some(GRAPH_COLORS[2])); } } diff --git a/src/components/utils/graphrow.rs b/src/components/utils/graphrow.rs deleted file mode 100644 index a83fb41ed4..0000000000 --- a/src/components/utils/graphrow.rs +++ /dev/null @@ -1,82 +0,0 @@ -pub const SYM_COMMIT: &str = "o"; -pub const SYM_COMMIT_BRANCH: &str = "*"; -pub const SYM_COMMIT_MERGE: &str = "M"; -pub const SYM_COMMIT_STASH: &str = "*"; -pub const SYM_COMMIT_UNCOMMITTED: &str = "+"; -pub const SYM_VERTICAL: &str = "┃"; -pub const SYM_VERTICAL_DOTTED: &str = "╏"; -pub const SYM_HORIZONTAL: &str = "━"; -pub const SYM_MERGE_BRIDGE_START: &str = "┓"; -pub const SYM_MERGE_BRIDGE_MID: &str = "━"; -pub const SYM_MERGE_BRIDGE_END: &str = "┏"; -pub const SYM_BRANCH_UP: &str = "┛"; -pub const SYM_BRANCH_UP_RIGHT: &str = "┗"; -pub const SYM_TEE_LEFT: &str = "┫"; -pub const SYM_TEE_RIGHT: &str = "┣"; -pub const SYM_TEE_UP: &str = "┻"; -pub const SYM_TEE_DOWN: &str = "┳"; -pub const SYM_CROSS: &str = "╋"; -pub const SYM_SPACE: &str = " "; - -/// The glyph the graph column draws for `conn`. -pub const fn symbol(conn: ConnectionType) -> &'static str { - match conn { - ConnectionType::Vertical => SYM_VERTICAL, - ConnectionType::VerticalDotted => SYM_VERTICAL_DOTTED, - ConnectionType::CommitNormal => SYM_COMMIT, - ConnectionType::CommitBranch => SYM_COMMIT_BRANCH, - ConnectionType::CommitMerge => SYM_COMMIT_MERGE, - ConnectionType::CommitStash => SYM_COMMIT_STASH, - ConnectionType::CommitUncommitted => SYM_COMMIT_UNCOMMITTED, - ConnectionType::MergeBridgeStart => SYM_MERGE_BRIDGE_START, - ConnectionType::MergeBridgeMid => SYM_MERGE_BRIDGE_MID, - ConnectionType::MergeBridgeEnd => SYM_MERGE_BRIDGE_END, - ConnectionType::BranchUp => SYM_BRANCH_UP, - ConnectionType::BranchUpRight => SYM_BRANCH_UP_RIGHT, - ConnectionType::TeeLeft => SYM_TEE_LEFT, - ConnectionType::TeeRight => SYM_TEE_RIGHT, - ConnectionType::TeeUp => SYM_TEE_UP, - ConnectionType::TeeDown => SYM_TEE_DOWN, - ConnectionType::Cross => SYM_CROSS, - } -} - -use asyncgit::graph::ConnectionType; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn symbols_are_single_char_and_distinct() { - let symbols = [ - SYM_COMMIT, - SYM_COMMIT_BRANCH, - SYM_COMMIT_MERGE, - SYM_COMMIT_STASH, - SYM_COMMIT_UNCOMMITTED, - SYM_VERTICAL, - SYM_VERTICAL_DOTTED, - SYM_HORIZONTAL, - SYM_MERGE_BRIDGE_START, - SYM_MERGE_BRIDGE_MID, - SYM_MERGE_BRIDGE_END, - SYM_BRANCH_UP, - SYM_BRANCH_UP_RIGHT, - SYM_TEE_LEFT, - SYM_TEE_RIGHT, - SYM_TEE_UP, - SYM_TEE_DOWN, - SYM_CROSS, - SYM_SPACE, - ]; - - for symbol in symbols { - assert_eq!( - symbol.chars().count(), - 1, - "{symbol:?} should be a single glyph" - ); - } - } -} diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs index 1025667d5c..ad1a7060ec 100644 --- a/src/components/utils/logitems.rs +++ b/src/components/utils/logitems.rs @@ -156,7 +156,7 @@ impl ItemBatch { pub fn set_graph_rows(&mut self, rows: Vec) { let mut max: usize = 0; for (entry, row) in self.items.iter_mut().zip(rows) { - max = max.max(row.lane_count.into()); + max = max.max(row.lane_count()); entry.graph = Some(row); } self.max_lane = max; diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index 746b2787bf..29485be1e4 100644 --- a/src/components/utils/mod.rs +++ b/src/components/utils/mod.rs @@ -4,7 +4,6 @@ use unicode_width::UnicodeWidthStr; #[cfg(feature = "ghemoji")] pub mod emoji; pub mod filetree; -pub mod graphrow; pub mod logitems; pub mod scroll_horizontal; pub mod scroll_vertical; From 9582b9d4b9d36098f37b90546eb09794b4e9f238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 06:18:28 +0000 Subject: [PATCH 82/84] graph: cross-check lane history and exercise invariant failures Capture live lane snapshots during the walk and independently derive each row's pending parents from the input history. Compare replayed windows with those snapshots at checkpoint boundaries and validate commit marker selection. Exercise the fixture with six deliberately corrupted pictures and states, each required to fail at its intended invariant. Check the character decoder directly, reject horizontal lines escaping the picture, and make an explicitly invoked repository test fail on missing or empty input. --- asyncgit/src/graph/harness/mod.rs | 106 ++++++++++++++++--- asyncgit/src/graph/harness/readback.rs | 70 +++++++++++- asyncgit/src/graph/harness/tests.rs | 65 ++++++++++++ asyncgit/src/graph/walker/real_repository.rs | 7 +- 4 files changed, 224 insertions(+), 24 deletions(-) create mode 100644 asyncgit/src/graph/harness/tests.rs diff --git a/asyncgit/src/graph/harness/mod.rs b/asyncgit/src/graph/harness/mod.rs index 621242098a..85bb19a8f4 100644 --- a/asyncgit/src/graph/harness/mod.rs +++ b/asyncgit/src/graph/harness/mod.rs @@ -24,12 +24,13 @@ mod generated; mod readback; +mod tests; pub use generated::{walk_generated, Shape}; use super::buffer::DeltaOp; use super::chunk::LaneSlot; -use super::{CommitAlias, GraphRow, GraphWalker}; +use super::{CommitAlias, ConnectionType, GraphRow, GraphWalker}; use crate::sync::CommitId; use readback::{side_name, Cursor, Picture}; use std::collections::{BTreeSet, HashMap, HashSet}; @@ -99,9 +100,11 @@ impl Scene { let mut ids = Vec::new(); let mut parents = Vec::new(); let mut row_of = HashMap::new(); + let mut snapshots = Vec::new(); for (commit, commit_parents) in history { walker.process(commit, &commit_parents); + snapshots.push(walker.buffer.current.clone()); row_of.entry(commit).or_insert(ids.len()); ids.push(commit); parents.push(commit_parents); @@ -114,11 +117,6 @@ impl Scene { &stashes, head.as_ref(), ); - let snapshots = ids - .len() - .checked_sub(1) - .map(|last| walker.buffer.decompress(0, last)) - .unwrap_or_default(); let scene = Self { walker, @@ -227,11 +225,9 @@ impl Scene { /// The parents this row was entitled to draw an edge to: the first /// two distinct parents the walk had not already passed. /// - /// Stating the rule here, from the parent lists alone, is what - /// makes it testable. Anything left out could only be drawn as a - /// lane no later row can close — a duplicated parent entry, an - /// octopus merge's third parent and up, or a parent a clock-skewed - /// walk reached before its child. + /// This policy is stated independently of the walker's parent + /// selection. Each row supports at most one merge bridge; repeated, + /// self-naming, and already-walked parents must not open lanes. fn drawn_parents(&self, index: usize) -> Vec { let id = &self.ids[index]; let mut drawn: Vec = Vec::new(); @@ -269,19 +265,25 @@ impl Scene { impl Scene { fn assert_invariants(&self) { self.assert_snapshots_track_the_walk(); + self.assert_frontier_matches_the_walk(); self.assert_delta_stream_is_well_formed(); self.assert_columns_hold_still(); self.assert_rows_are_well_formed(); self.assert_windows_decompress_exactly(); - let picture = Picture::new(&self.cells()); + self.assert_picture_preserves_history(&Picture::new( + &self.cells(), + )); + } + + fn assert_picture_preserves_history(&self, picture: &Picture) { let joined = (0..self.rows.len()) .map(|index| { picture.commits_joined_to(self.marker(index)) }) .collect::>(); - self.assert_no_dangling_lines(&picture); + self.assert_no_dangling_lines(picture); self.assert_every_parent_edge_is_drawn(&joined); self.assert_unrelated_histories_stay_apart(&joined); self.assert_stacked_commits_are_parent_and_child(); @@ -292,17 +294,53 @@ impl Scene { assert_eq!( self.snapshots.len(), self.ids.len(), - "decompressing the whole walk lost rows" + "the walk lost snapshots" ); assert_eq!( self.walker.processed_commit_count(), self.ids.len(), "the buffer holds a delta count the walk did not produce" ); + if let Some(last) = self.ids.len().checked_sub(1) { + assert_eq!( + self.walker.buffer.decompress(0, last), self.snapshots, + "delta replay differs from the states recorded during the walk" + ); + } + if let Some(last) = self.snapshots.last() { assert_eq!( last, &self.walker.buffer.current, - "the last decompressed row is not the live state" + "the last recorded snapshot is not the live state" + ); + } + } + + /// The raw history determines which parents are still ahead of + /// each row. Every one needs a lane, and no lane may await anything + /// else, including a parent the walk has already passed. + fn assert_frontier_matches_the_walk(&self) { + let mut expected = HashSet::new(); + for (index, snapshot) in self.snapshots.iter().enumerate() { + expected.remove(&self.alias(index)); + for parent in self.drawn_parents(index) { + expected.insert( + self.walker + .oids + .get(&parent) + .expect("a drawable parent has an alias"), + ); + } + let actual = snapshot + .iter() + .flatten() + .filter_map(LaneSlot::awaits) + .collect::>(); + assert_eq!( + actual, + expected, + "row {index} awaits the wrong parents\n{}", + self.picture() ); } } @@ -430,6 +468,23 @@ impl Scene { self.stashes.contains(&self.ids[index]), "row {index} disagrees about being a stash" ); + let expected_marker = + if self.stashes.contains(&self.ids[index]) { + ConnectionType::CommitStash + } else if self.parents[index].len() > 1 { + ConnectionType::CommitMerge + } else if self.branch_tips.contains(&self.ids[index]) + { + ConnectionType::CommitBranch + } else { + ConnectionType::CommitNormal + }; + assert_eq!( + row.lanes[usize::from(row.commit_lane)] + .map(|(connection, _)| connection), + Some(expected_marker), + "row {index} draws the wrong commit marker" + ); for (from, to) in row .merge_bridge @@ -570,8 +625,27 @@ impl Scene { continue; } - for start in (0..=total - length).step_by(stride) { + let mut starts = (0..=total - length) + .step_by(stride) + .collect::>(); + starts.insert(total - length); + for &checkpoint in self.walker.buffer.checkpoints.keys() { + for start in + checkpoint.saturating_sub(1)..=checkpoint + 2 + { + if start + length <= total { + starts.insert(start); + } + } + } + + for start in starts { let end = start + length; + assert_eq!( + self.walker.buffer.decompress(start, end - 1), + self.snapshots[start..end], + "lane states {start}..{end} differ from the recorded walk" + ); let window = self.walker.compute_rows( &self.ids[start..end], start, diff --git a/asyncgit/src/graph/harness/readback.rs b/asyncgit/src/graph/harness/readback.rs index 0d1486b8d1..cf5ff3597e 100644 --- a/asyncgit/src/graph/harness/readback.rs +++ b/asyncgit/src/graph/harness/readback.rs @@ -205,9 +205,8 @@ impl Picture { /// Every line end that points at a cell with nothing to take it. /// - /// A stub pointing off the edge of the picture is left alone: the - /// log view shows a window onto a longer history, so lines running - /// off the top and bottom are the normal case. + /// Lines may continue off the top and bottom of a history window. + /// A horizontal line must end inside the picture. pub fn dangling_ends(&self) -> Vec<(Cursor, u8)> { let mut dangling = Vec::new(); @@ -226,9 +225,12 @@ impl Picture { continue; } let cursor = (row, column); - if self.neighbour(cursor, side).is_some() - && self.step(cursor, side).is_none() + if matches!(side, UP | DOWN) + && self.neighbour(cursor, side).is_none() { + continue; + } + if self.step(cursor, side).is_none() { dangling.push((cursor, side)); } } @@ -244,6 +246,15 @@ mod tests { use super::*; use crate::graph::ConnectionType; + fn picture(rows: &[&str]) -> Picture { + Picture::new( + &rows + .iter() + .map(|row| row.chars().collect()) + .collect::>(), + ) + } + #[test] fn renderer_symbols_are_single_recognized_cells() { use ConnectionType::{ @@ -288,4 +299,53 @@ mod tests { assert_eq!(cell == Cell::Node, connection.is_commit()); } } + + #[test] + fn crossing_preserves_two_separate_paths() { + let graph = picture(&[" o", "o━┃━o", " o"]); + assert_eq!( + graph.commits_joined_to((0, 2)), + BTreeSet::from([(2, 2)]) + ); + assert_eq!( + graph.commits_joined_to((1, 0)), + BTreeSet::from([(1, 4)]) + ); + assert_eq!(graph.dangling_ends(), Vec::<(Cursor, u8)>::new()); + } + + #[test] + fn junction_joins_all_four_paths() { + let graph = picture(&[" o", "o━╋━o", " o"]); + assert_eq!( + graph.commits_joined_to((0, 2)), + BTreeSet::from([(1, 0), (1, 4), (2, 2)]) + ); + assert_eq!(graph.dangling_ends(), Vec::<(Cursor, u8)>::new()); + } + + #[test] + fn commits_stop_paths() { + let graph = picture(&["o", "o", "o"]); + assert_eq!( + graph.commits_joined_to((0, 0)), + BTreeSet::from([(1, 0)]) + ); + } + + #[test] + fn only_vertical_paths_can_leave_the_window() { + assert_eq!( + picture(&["┃"]).dangling_ends(), + Vec::<(Cursor, u8)>::new() + ); + assert_eq!( + picture(&["━"]).dangling_ends(), + vec![((0, 0), LEFT), ((0, 0), RIGHT)] + ); + assert_eq!( + picture(&["o", " ┃"]).dangling_ends(), + vec![((1, 2), UP)] + ); + } } diff --git a/asyncgit/src/graph/harness/tests.rs b/asyncgit/src/graph/harness/tests.rs new file mode 100644 index 0000000000..843c27f98c --- /dev/null +++ b/asyncgit/src/graph/harness/tests.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +#[should_panic(expected = "cannot be followed to its parent")] +fn readback_rejects_a_missing_merge_junction() { + let scene = + Scene::walk(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]); + let mut cells = scene.cells(); + cells[0][2] = '┃'; + scene.assert_picture_preserves_history(&Picture::new(&cells)); +} + +#[test] +#[should_panic(expected = "which share no ancestry")] +fn readback_rejects_a_join_between_independent_histories() { + let scene = + Scene::walk(&[(1, &[3]), (2, &[4]), (3, &[]), (4, &[])]); + let mut cells = scene.cells(); + cells[1][0] = '┣'; + cells[1][1] = '━'; + scene.assert_picture_preserves_history(&Picture::new(&cells)); +} + +#[test] +#[should_panic(expected = "row 0 awaits the wrong parents")] +fn frontier_rejects_a_missing_parent_outside_the_window() { + let mut scene = Scene::walk(&[(1, &[2])]); + scene.snapshots[0][0] = Some(LaneSlot::Settled { + alias: scene.alias(0), + }); + scene.assert_frontier_matches_the_walk(); +} + +#[test] +#[should_panic(expected = "row 1 awaits the wrong parents")] +fn frontier_rejects_a_lane_waiting_for_a_walked_parent() { + let mut scene = Scene::walk(&[(1, &[2]), (2, &[])]); + scene.snapshots[1] = scene.snapshots[0].clone(); + scene.assert_frontier_matches_the_walk(); +} + +#[test] +#[should_panic( + expected = "lane states 99..100 differ from the recorded walk" +)] +fn windows_reject_a_corrupted_checkpoint() { + let mut scene = walk_generated(7, 120, Shape::Entangled); + scene + .walker + .buffer + .checkpoints + .get_mut(&99) + .expect("the first checkpoint") + .clear(); + scene.assert_windows_decompress_exactly(); +} + +#[test] +#[should_panic(expected = "row 0 draws the wrong commit marker")] +fn rows_reject_a_merge_drawn_as_an_ordinary_commit() { + let mut scene = Scene::walk(&[(1, &[2, 3])]); + scene.rows[0].lanes[0] = + Some((ConnectionType::CommitNormal, 0.into())); + scene.assert_rows_are_well_formed(); +} diff --git a/asyncgit/src/graph/walker/real_repository.rs b/asyncgit/src/graph/walker/real_repository.rs index a2a4fc07cd..17877c4c96 100644 --- a/asyncgit/src/graph/walker/real_repository.rs +++ b/asyncgit/src/graph/walker/real_repository.rs @@ -19,9 +19,9 @@ use std::collections::HashSet; #[test] #[ignore = "needs GITUI_GRAPH_PARENTS; see the module docs"] fn holds_every_invariant() { - let Ok(path) = std::env::var("GITUI_GRAPH_PARENTS") else { - return; - }; + let path = std::env::var("GITUI_GRAPH_PARENTS").expect( + "set GITUI_GRAPH_PARENTS to a rev-list --parents output file", + ); let text = std::fs::read_to_string(&path) .expect("GITUI_GRAPH_PARENTS names a readable file"); @@ -38,6 +38,7 @@ fn holds_every_invariant() { .collect::>(); let total = history.len(); + assert!(total > 0, "the repository history is empty"); Scene::walk_ids(history, HashSet::new(), HashSet::new(), None); eprintln!("{total} commits drawn; every invariant holds"); } From 159dbb73d53f5b2bb2889d16cdbb3bd2d8726dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 06:28:07 +0000 Subject: [PATCH 83/84] test: generate an explicit CA certificate for X.509 signing The fixture relied on the TLS tool's default certificate extensions. LibreSSL generated a certificate that gpgsm could import but could not validate for signing. Declare the self-signed root's CA extension in a temporary configuration instead of relying on those defaults. The existing signing and signature-verification assertions now pass with LibreSSL 3.3.6 and OpenSSL 3.5.8. The full make check target also passes, including all 363 nextest tests, without excluding the signing test. --- asyncgit/src/sync/sign.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs index fd46376fe7..778c9784ae 100644 --- a/asyncgit/src/sync/sign.rs +++ b/asyncgit/src/sync/sign.rs @@ -772,6 +772,14 @@ mod tests { let key = home.join("key.pem"); let cert = home.join("cert.pem"); + let cert_config = home.join("openssl.cnf"); + // LibreSSL and OpenSSL ship different default extensions. Make + // this self-signed trust root a CA explicitly so gpgsm can + // validate it with either tool's default configuration. + std::fs::write( + &cert_config, + "[req]\ndistinguished_name=dn\n[dn]\n[extensions]\nbasicConstraints=critical,CA:TRUE\n", + )?; run( "openssl", &[ @@ -788,6 +796,10 @@ mod tests { "3650", "-subj", &format!("/CN=gitui test/emailAddress={email}"), + "-config", + cert_config.to_str().unwrap(), + "-extensions", + "extensions", ], ); // gpgsm's PKCS#12 reader accepts different ciphers across versions, so From 6fbef7a395a009184e0a877ebfd92e1ff1bf2535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Fri, 16 Oct 2026 06:28:07 +0000 Subject: [PATCH 84/84] ci: sort asyncgit dependencies with the pinned formatter Place foldhash in dependency-name order so tombi 0.9.0's format check passes. Verified with the formatter version used by the CI workflow. --- asyncgit/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index 01c6c44d07..a0130871a4 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -16,11 +16,11 @@ bitflags = "2" crossbeam-channel = "0.5" dirs = "6.0" easy-cast = "0.5" +foldhash = "0.2" fuzzy-matcher = "0.3" git2 = { version = "0.21", features = ["https", "ssh", "unstable-sha256"] } git2-hooks = { path = "../git2-hooks", version = "0.7" } gix = { version = "0.84.0", default-features = false, features = ["mailmap", "max-performance", "revision", "sha1", "sha256", "status"] } -foldhash = "0.2" log = "0.4" # git2 = { path = "../../extern/git2-rs", features = ["vendored-openssl"]} # git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]}