From 80c7915910b6466549f9f97413ac783da48650b1 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sat, 11 Jul 2026 09:30:29 +0200 Subject: [PATCH 01/21] Test if topology order is violated This is preparation for a log graph implementation. In case of a diamond graph where the common ancestor has a newer date than some of its children, the walk order of the current code will violate topology order. If commits are not in topology order, a graph may have to draw a parent before its child. This is confusing to the user and require extra memory for the graph render algorithm. --- asyncgit/src/sync/branch/merge_commit.rs | 109 ++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/asyncgit/src/sync/branch/merge_commit.rs b/asyncgit/src/sync/branch/merge_commit.rs index ec1ea498bb..4733d37023 100644 --- a/asyncgit/src/sync/branch/merge_commit.rs +++ b/asyncgit/src/sync/branch/merge_commit.rs @@ -101,7 +101,7 @@ mod test { branch_compare_upstream, remotes::{fetch, push::push_branch}, tests::{ - debug_cmd_print, get_commit_ids, repo_clone, + debug_cmd_print, get_commit_ids, repo_clone, repo_init, repo_init_bare, write_commit_file, write_commit_file_at, }, RepoState, @@ -278,4 +278,111 @@ mod test { let commits = get_commit_ids(&clone1, 10); assert_eq!(commits.len(), 1); } + + /// Verify that the walker preserves topology order. This means that + /// no parent is visited before any of its children. + #[test] + fn test_topology_order() { + /* Test case + a diamon shaped graph, where the common ancestor is younger than + one of its children. + + GP--P1--M + \ / + --P2 + + */ + + let (_repo_dir, repo) = repo_init().unwrap(); + + // 1. Grandparent (GP) - Newest + let gp = write_commit_file_at( + &repo, + "gp.txt", + "gp", + "gp", + Time::new(1000, 0), + ); + + // 2. Parent 1 (P1) - Older + let p1 = write_commit_file_at( + &repo, + "p1.txt", + "p1", + "p1", + Time::new(500, 0), + ); + + // 3. Parent 2 (P2) - Older (diverging from GP) + // Reset HEAD to GP so P2 becomes a child of GP + repo.reset( + repo.find_object(gp.into(), None) + .unwrap() + .as_commit() + .unwrap() + .as_object(), + git2::ResetType::Hard, + None, + ) + .unwrap(); + let p2 = write_commit_file_at( + &repo, + "p2.txt", + "p2", + "p2", + Time::new(400, 0), + ); + + // 4. Merge commit (M) - The starting point of our walk + // The heap now contains [p1, p2]. + // If we pop p1, we add gp. The heap is [gp, p2]. + // Because gp(1000) > p2(400), the walker returns gp before p2. + // This is a violation: p2 is a child of gp and must be visited first. + let p1_commit = repo.find_commit(p1.into()).unwrap(); + let p2_commit = repo.find_commit(p2.into()).unwrap(); + let tree = repo + .find_tree(repo.index().unwrap().write_tree().unwrap()) + .unwrap(); + let sig = repo.signature().unwrap(); + let m = repo + .commit( + Some("HEAD"), + &sig, + &sig, + "Merge p1 into p2", + &tree, + &[&p2_commit, &p1_commit], + ) + .unwrap(); + let m = CommitId::new(m); + + // Expected Topological Order: [M, P1, P2, GP] or [M, P2, P1, GP] + // Actual Defective Order: [M, P1, GP, P2] + // (GP jumps ahead of P2 because 1000 > 400) + + let commits = get_commit_ids(&repo, 14); + for (i, id) in commits.iter().enumerate() { + println!("DEBUG: commits[{}] = {:?}", i, id); + // Print the message of the commit to identify it + let repo_path = &repo.path().to_path_buf().into(); + let details = + crate::sync::get_commit_details(repo_path, *id) + .unwrap(); + println!( + "DEBUG: Message: {:?}", + details.message.map(|m| m.combine()) + ); + } + println!("DEBUG: Expected M is {:?}", m); + println!("DEBUG: P1 is {:?}", &p1); + println!("DEBUG: P2 is {:?}", &p2); + println!("DEBUG: GP is {:?}", &gp); + assert_eq!(commits[0], m); + assert!(commits.contains(&p1)); + assert!(commits.contains(&p2)); + assert_eq!( + commits[3], gp, + "Violation: Grandparent must be the last commit" + ); + } } From 19e4cfc2e2d7194144220f6bfa919988ca8d9b5b Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Tue, 28 Jul 2026 07:32:23 +0200 Subject: [PATCH 02/21] Fix topology order for LogWalker LogWalker uses git2, due to SharedCommitFilterFn --- asyncgit/src/sync/logwalker.rs | 82 ++++++++++++---------------------- 1 file changed, 29 insertions(+), 53 deletions(-) diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index 81a6fd321e..875c19d9ef 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -1,66 +1,50 @@ use super::{CommitId, SharedCommitFilterFn}; use crate::error::Result; -use git2::{Commit, Oid, Repository}; +use git2::{Repository, Revwalk, Sort}; use gix::revision::Walk; -use std::{ - cmp::Ordering, - collections::{BinaryHeap, HashSet}, -}; -struct TimeOrderedCommit<'a>(Commit<'a>); - -impl Eq for TimeOrderedCommit<'_> {} - -impl PartialEq for TimeOrderedCommit<'_> { - fn eq(&self, other: &Self) -> bool { - self.0.time().eq(&other.0.time()) - } -} - -impl PartialOrd for TimeOrderedCommit<'_> { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for TimeOrderedCommit<'_> { - fn cmp(&self, other: &Self) -> Ordering { - self.0.time().cmp(&other.0.time()) - } -} - -/// +/// Visit commits in topological order, and date order where possible. +/// If a filter is provided, only commits that pass the filter are returned. pub struct LogWalker<'a> { - commits: BinaryHeap>, - visited: HashSet, + /// Revision walk engine + walk: Revwalk<'a>, + /// Total number of commits that have been visited + visited_count: usize, + /// Upper limit on buffer size limit: usize, + /// The source of commits repo: &'a Repository, + /// Filter on which commits will be returned when reading filter: Option, } impl<'a> LogWalker<'a> { - /// + /// Create a new log walker + /// with an upper limit on number of commits visited in one batch. pub fn new(repo: &'a Repository, limit: usize) -> Result { - let c = repo.head()?.peel_to_commit()?; + let head = repo.head()?.peel_to_commit()?; - let mut commits = BinaryHeap::with_capacity(10); - commits.push(TimeOrderedCommit(c)); + let mut walk = repo.revwalk()?; + // TOPOLOGICAL + TIME guarantees parents come after children, + // and ties/independent branches are ordered by timestamp (--date-order). + walk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?; + walk.push(head.id())?; Ok(Self { - commits, + walk, + visited_count: 0, limit, - visited: HashSet::with_capacity(1000), repo, filter: None, }) } - /// - pub fn visited(&self) -> usize { - self.visited.len() + /// Number of visited commits + pub const fn visited(&self) -> usize { + self.visited_count } - /// + /// Add a filter to use when reading commits #[must_use] pub fn filter( self, @@ -69,16 +53,14 @@ impl<'a> LogWalker<'a> { Self { filter, ..self } } - /// + /// Get a batch of commits pub fn read(&mut self, out: &mut Vec) -> Result { let mut count = 0_usize; - while let Some(c) = self.commits.pop() { - for p in c.0.parents() { - self.visit(p); - } + for oid_result in self.walk.by_ref() { + let oid = oid_result?; + let id: CommitId = oid.into(); - let id: CommitId = c.0.id().into(); let commit_should_be_included = if let Some(ref filter) = self.filter { filter(self.repo, &id)? @@ -90,6 +72,7 @@ impl<'a> LogWalker<'a> { out.push(id); } + self.visited_count += 1; count += 1; if count == self.limit { break; @@ -98,13 +81,6 @@ impl<'a> LogWalker<'a> { Ok(count) } - - // - fn visit(&mut self, c: Commit<'a>) { - if self.visited.insert(c.id()) { - self.commits.push(TimeOrderedCommit(c)); - } - } } /// This is separate from `LogWalker` because filtering currently (June 2024) works through From 9a49c5e03f9844e88702afae4b5dcabed6dc103f Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Thu, 30 Jul 2026 02:23:09 +0200 Subject: [PATCH 03/21] Fix topology order for LogWalkerWithoutFilter LogWalkerWithoutFilter is based on gix, so it touches a different part of the code. --- asyncgit/src/error.rs | 10 ++++++++++ asyncgit/src/sync/logwalker.rs | 17 +++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/asyncgit/src/error.rs b/asyncgit/src/error.rs index e89e4a56f8..c764a0b934 100644 --- a/asyncgit/src/error.rs +++ b/asyncgit/src/error.rs @@ -73,6 +73,10 @@ pub enum GixError { #[error("gix::status::tree_index::Error error: {0}")] StatusTreeIndex(#[from] Box), + /// + #[error("gix::traverse::commit::topo error: {0}")] + Topo(#[from] gix::traverse::commit::topo::Error), + /// #[error("gix::worktree::open_index::Error error: {0}")] WorktreeOpenIndex(#[from] Box), @@ -333,6 +337,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: gix::traverse::commit::topo::Error) -> Self { + Self::Gix(GixError::from(error)) + } +} + impl From for GixError { fn from(error: gix::worktree::open_index::Error) -> Self { Self::WorktreeOpenIndex(Box::new(error)) diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index 875c19d9ef..39ee77a58d 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -1,7 +1,8 @@ use super::{CommitId, SharedCommitFilterFn}; use crate::error::Result; use git2::{Repository, Revwalk, Sort}; -use gix::revision::Walk; +use gix::traverse::commit::topo; +use gix::traverse::commit::Topo; /// Visit commits in topological order, and date order where possible. /// If a filter is provided, only commits that pass the filter are returned. @@ -94,7 +95,7 @@ impl<'a> LogWalker<'a> { /// A more long-term option is to refactor filtering to work with a `gix::Repository` and to remove /// `LogWalker` once this is done, but this is a larger effort. pub struct LogWalkerWithoutFilter<'a> { - walk: Walk<'a>, + walk: Topo<&'a gix::Repository, fn(&gix::hash::oid) -> bool>, limit: usize, visited: usize, } @@ -113,12 +114,12 @@ impl<'a> LogWalkerWithoutFilter<'a> { let tips = [commit.id]; - let platform = repo - .rev_walk(tips) - .sorting(gix::revision::walk::Sorting::ByCommitTime(gix::traverse::commit::simple::CommitTimeOrder::NewestFirst)) - .use_commit_graph(false); - - let walk = platform.all()?; + let walk = topo::Builder::new(&*repo) + // Show no parents before all of its children are shown, + // but otherwise show commits in the commit timestamp order. + .sorting(topo::Sorting::DateOrder) + .with_tips(tips) + .build()?; Ok(Self { walk, From 6ee51d88bcc581fd862d26dd2d92fec8b67b5eff Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Thu, 30 Jul 2026 05:54:30 +0200 Subject: [PATCH 04/21] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ca0eaac2..4a09c38431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * open the external editor from the status diff view [[@WaterWhisperer](https://github.com/WaterWhisperer)] ([#2805](https://github.com/gitui-org/gitui/issues/2805)) * automatically convert spaces to dashes when creating or renaming a branch [[@pbouillon]](https//pbouillon.github.io)] ([#2916](https://github.com/gitui-org/gitui/pull/2916)) * support rewording non-HEAD commits when `commit.gpgsign` is enabled (gpg format only) [[@guerinoni](https://github.com/guerinoni)] ([#2959](https://github.com/gitui-org/gitui/pull/2959)) +* always preserve topology order before sorting commits by date ### Fixes * crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895)) From 011efb08160c478c34689c849884c2802aeae23c Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Tue, 28 Jul 2026 22:41:25 +0200 Subject: [PATCH 05/21] Walk every commit, not just ancestors to HEAD When a commit graph can be shown, this allows us to see multiple branches next to each other. --- asyncgit/src/sync/logwalker.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index 39ee77a58d..f19f62eb75 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -23,13 +23,14 @@ impl<'a> LogWalker<'a> { /// Create a new log walker /// with an upper limit on number of commits visited in one batch. pub fn new(repo: &'a Repository, limit: usize) -> Result { - let head = repo.head()?.peel_to_commit()?; - let mut walk = repo.revwalk()?; // TOPOLOGICAL + TIME guarantees parents come after children, // and ties/independent branches are ordered by timestamp (--date-order). walk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?; - walk.push(head.id())?; + + // Push all references (heads, tags, remotes, etc.) into the revision walker. + // This corresponds to running "git log --all" + walk.push_glob("*")?; Ok(Self { walk, From ee43cf94b3487e47b224df18cb94ffccc3c658d9 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Tue, 17 Feb 2026 21:31:57 +0100 Subject: [PATCH 06/21] BEGIN PATCH - show graph From 193da487dc17283a5da24e1dbec8ad6805ccb413 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 07/21] Change gitui version to reflect alpha status --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a699e228d1..ae03862dc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1130,7 +1130,7 @@ dependencies = [ [[package]] name = "gitui" -version = "0.28.1" +version = "0.28.1-alpha" dependencies = [ "anyhow", "asyncgit", diff --git a/Cargo.toml b/Cargo.toml index 585f94a147..dbf795a3d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gitui" -version = "0.28.1" +version = "0.28.1-alpha" authors = ["extrawurst "] description = "blazing fast terminal-ui for git" edition = "2021" From b0c822530e32373f1bf7cf679f63b0d3a8cc9533 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 30 Apr 2025 06:12:47 +0200 Subject: [PATCH 08/21] Design of branch visualization --- docs/design/log-graph.md | 104 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/design/log-graph.md diff --git a/docs/design/log-graph.md b/docs/design/log-graph.md new file mode 100644 index 0000000000..2b7e120c24 --- /dev/null +++ b/docs/design/log-graph.md @@ -0,0 +1,104 @@ +# Title + +Visualization of branch structure + +Author: [Peer Sommerlund](mailto:peer.sommerlund@gmail.com) + +**Summary:** +Parent-child relations between commits form a graph that is important +for many operations like push, rebase, etc. Gitui can list commits, but +does not show how they are related. This design shows how to integrate +the crate gleisbau so the log tab shows the branch graph. + +## State of the Feature as of `gitui 0.26.3` + +Gitui shows all commits in a repository without regard for their parent +commit relations. This may cause commits on multiple branches to +be shown interleaved in the log tab. + +## Prior work + +`git log --graph` is well known, less known is gleisbau which has a +cleaner visual approach. + +## Goals and non-goals + +The project aims to implement a graphical representation of commits. +As as secondary goal, it adds the branch sorting feature of gleisbau. + +It does not implement a language for filtering commits dependent on +their branch. + +## Overview + +The project adds the cargo gleisbau and provides an alternative view +for the CommitList component, which is used in the revlog tab. + +### Detailed Design + +* A lazy cache of rendered text + gleisbau is used to render lines surrounding the cursor/selection + on screen. Doing this for a large repository may use several GB of + memory and be very slow. To get fast response and low memory usage + only a fixed number of commits is rendered. As the user moves around + a new set will be rendered. For very fast scrolling, the old + rendering will be used, and replaced async when gleisbau + catches up. + +* A new system for tracking location + In order to handle multi-line commits larger than the screen, we + track single lines. This is done with a new `struct DocLine` which + points at a commit, and optionally a line in the commit rendering. + + TODO: Consider if this should be replaced by the old system. + I need to show single lines, but what benefit does the user get + from being able to address single lines? It will make sense if a commit + has more lines than can be shown, as a way to control scrolling. + +* gleisbau patches + The changes to make gleisbau work inside gitui will be contributed + upstream to gleisbau. When that happens, the local copy should be + removed. Another option is to defer merge of this PR until gleisbau + PR is merged. + +* asyncgit patches + There are a few features in asyncgit which were private. These have + been published. + +#### asyncgit patches + +gitui loads commits via asyncgit. The full lifecycle of commit information +is: + +- Init: main somehwere +- Load-Thread: Asyncgit sends AsyncGitNotification + which is handled by Revlog.update_git() +- UI-delivery: CommitList.fetch_commits read commits into ItemBatch + it calls asyncgit::sync::get_commits_info + It always grows CommitList.commits so it includes the selection and some. +- UI-rendering: CommitList.draw() draw the loaded data found in + The interesting part is that self.commits.len() is used to count + commit in the repository. That means that CommitList.commits eventually + will hold the full repository. + draw calls CommitList.get_text which renders the visible commits as text. + +## Alternatives considered + +- Show output from `git log --graph`. This would make it easy for users +to understand what is going on and how to change the format, an any +future features of git would be automatically included. The downside is +dependency on the git binary, and a slower UI. It might be difficult and +brittle to parse the output. + +- Implement branch visualization from scratch. Upside is full control +over memory and UI responsiveness. Downside is a larger effort needed. + +## Issues addressed (optional) + +- [#81]() + + +## Future Possibilities + + The section for things which could be added to it or deemed out of scope during + the discussion. From 16ac2cc8294f714a35ba86bf478de6416a59f254 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sun, 22 Jun 2025 08:34:21 +0200 Subject: [PATCH 09/21] Add gleisbau 0.7.5 dependency --- Cargo.lock | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ae03862dc7..c634a7b47d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,17 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -347,7 +358,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-link", ] @@ -1151,6 +1164,7 @@ dependencies = [ "fuzzy-matcher", "gh-emoji", "git2-testing", + "gleisbau", "indexmap", "insta", "itertools", @@ -1528,7 +1542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b" dependencies = [ "bstr", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1966,6 +1980,27 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "gleisbau" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac933c4eb1ee130b83108cd6b750f2092e96fd0c65883c40451933af5975c48e" +dependencies = [ + "atty", + "chrono", + "crossterm 0.29.0", + "git2", + "itertools", + "lazy_static", + "log", + "regex", + "serde", + "serde_derive", + "textwrap", + "toml", + "yansi", +] + [[package]] name = "hash32" version = "0.3.1" @@ -2028,6 +2063,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hex" version = "0.4.3" @@ -3376,6 +3420,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serial_test" version = "3.3.1" @@ -3754,6 +3807,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-width 0.2.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3862,6 +3924,45 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "two-face" version = "0.4.5" @@ -4481,6 +4582,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index dbf795a3d9..a741173c44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ easy-cast = "0.5" filetreelist = { path = "./filetreelist", version = ">=0.6" } fuzzy-matcher = "0.3" gh-emoji = { version = "1.0", optional = true } +gleisbau = { version = "0.7.5", default-features = false } indexmap = "2" itertools = "0.14" log = "0.4" From 799d4d043c148a8e18814342c75b00c89a5f3b53 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Fri, 27 Jun 2025 12:51:15 +0200 Subject: [PATCH 10/21] Document gleisbau as dependency in module main --- src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main.rs b/src/main.rs index fd662950a2..65b85797e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,8 +13,10 @@ //! - [components] for visual elements used on tabs //! - [popups] for temporary dialogs //! - [ui] for tooling like scrollbars +//! - [gleisbau] (crate) for rendering the branch graph //! - Git Interface //! - [asyncgit] (crate) for async operations on repository +//! - [gleisbau] (crate) repo traits //! - Distribution and Documentation //! - Project files //! - Github CI @@ -27,6 +29,7 @@ //! - git2-hooks (used by asyncgit). //! - git2-testing (used by git2-hooks). //! - invalidstring used by asyncgit for testing with invalid strings. +//! - [gleisbau] for rendering the branch graph //! - [filetreelist] for a tree view of files. //! - [scopetime] for measuring execution time. //! From 4acea9b8d2064ee1c2b5f6f5ea0971cdf5ffa1cf Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Thu, 21 May 2026 06:30:02 +0200 Subject: [PATCH 11/21] doc: asyncgit describe functions that load git data These functions are relevant to gleisbau --- asyncgit/src/sync/commits_info.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 772b73a040..13e24f9472 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -124,7 +124,7 @@ pub struct CommitInfo { pub id: CommitId, } -/// +/// Load information about the requested commits from repository pub fn get_commits_info( repo_path: &RepoPath, ids: &[CommitId], @@ -163,7 +163,7 @@ pub fn get_commits_info( Ok(res) } -/// +/// Load information about a single commit from repository pub fn get_commit_info( repo_path: &RepoPath, commit_id: &CommitId, From fbd049ec991b66856ba3b119ae5367421dc9f9a7 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Tue, 1 Apr 2025 12:12:41 +0200 Subject: [PATCH 12/21] doc: Module documentation for components::commitlist --- src/components/commitlist.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index e97754ecb4..bcd0db86c5 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,3 +1,8 @@ +/*! +The [`CommitList`] shows a list of commits. It is used by +the [revlog](crate::tabs::Revlog) tab +and the [stashlist](crate::tabs::StashList) tab. +*/ use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ app::Environment, From ad84497105a646a6d028d4f47ee676485d529b40 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 20 May 2026 21:21:54 +0200 Subject: [PATCH 13/21] doc: Reorder fields in CommitList By separating model and view data it is easier to see what is going on. --- src/components/commitlist.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index bcd0db86c5..b9bd30befe 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -43,13 +43,29 @@ const SLICE_SIZE: usize = 1200; /// pub struct CommitList { + // + // Information about the repository + // + + // ---- rustfmt, please preserve blank line above ---- + /// Location of repository repo: RepoPathRef, + + /// Sequence of commit id for commits loaded from git + commits: IndexSet, + + /// Commit information loaded from git + items: ItemBatch, + + // + // User interface + // + + // ---- rustfmt, please preserve blank line above ---- title: Box, selection: usize, highlighted_selection: Option, - items: ItemBatch, highlights: Option>>, - commits: IndexSet, /// The marked commits. /// `self.marked[].0` holds the commit index into `self.items.items` - used for ordering the list. /// `self.marked[].1` is the commit id of the marked commit. From 0b320fc49ef0c7b704dc088f44bfcfc90fb669be Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 20 May 2026 21:21:54 +0200 Subject: [PATCH 14/21] asyncgit: Extract parent info from git Adding parent information to CommitInfo will increase memory usage significantly, but makes it possible to build the branch graph. This is a prototype. When a PR is made to gitui it could reduce the memory impact. --- asyncgit/src/sync/commits_info.rs | 11 ++++++++++- src/components/commitlist.rs | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 13e24f9472..a7be87de8f 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -111,7 +111,8 @@ impl From for gix::ObjectId { } } -/// +/// The data gitui stores in memory about a commit. Large repositories +/// may hold millions and have a major impact on gitui memory usage. #[derive(Debug, Clone)] pub struct CommitInfo { /// @@ -122,6 +123,8 @@ pub struct CommitInfo { pub author: String, /// pub id: CommitId, + /// List of parent ids + pub parents: Vec, } /// Load information about the requested commits from repository @@ -151,11 +154,13 @@ pub fn get_commits_info( || String::from(""), String::from, ); + let parents = c.parent_ids().map(CommitId).collect(); CommitInfo { message, author, time: c.time().seconds(), id: CommitId(c.id()), + parents, } }) .collect::>(); @@ -185,11 +190,15 @@ pub fn get_commit_info( |signature| signature.name, ); + let parents = + commit.parent_ids().map(|id| id.detach().into()).collect(); + Ok(CommitInfo { message, author: author.to_string(), time: commit_ref.time()?.seconds, id: commit.id().detach().into(), + parents, }) } diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index b9bd30befe..a2061c3aa8 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -977,6 +977,7 @@ mod tests { time: 0, author: String::default(), id: CommitId::default(), + parents: vec![], }; // This just creates a sequence of fake ordered ids // 0000000000000000000000000000000000000000 From cd82b910c68a72dad7c684392724414742872c0d Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 20 May 2026 21:21:54 +0200 Subject: [PATCH 15/21] Add Gleisbau data to CommitList CommitList is the view that shows the log graph. It stores commits and their data, therefore TrackMap belongs there. It is already updated by a thread found in asyncgit. This thread will be expanded to include walking the graph. Use layout_track_range to layout a subset of the graph. --- src/app.rs | 2 +- src/components/commitlist.rs | 62 ++++++++++-- src/components/mod.rs | 6 +- src/components/utils/graph_cache.rs | 152 ++++++++++++++++++++++++++++ src/components/utils/mod.rs | 1 + 5 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 src/components/utils/graph_cache.rs diff --git a/src/app.rs b/src/app.rs index ddb157af3f..86fab3d2c5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -416,7 +416,7 @@ impl App { Ok(()) } - /// + /// Distribute async notification to all children pub fn update_async( &mut self, ev: AsyncNotification, diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index a2061c3aa8..24cb2d1988 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -8,7 +8,8 @@ use crate::{ app::Environment, components::{ utils::string_width_align, CommandBlocking, CommandInfo, - Component, DrawableComponent, EventState, ScrollType, + Component, DrawableComponent, EventState, GraphCache, + ScrollType, }, keys::{key_match, SharedKeyConfig}, queue::{InternalEvent, Queue}, @@ -34,7 +35,11 @@ use ratatui::{ Frame, }; use std::{ - borrow::Cow, cell::Cell, cmp, collections::BTreeMap, rc::Rc, + borrow::Cow, + cell::{Cell, RefCell}, + cmp, + collections::BTreeMap, + rc::Rc, time::Instant, }; @@ -57,6 +62,9 @@ pub struct CommitList { /// Commit information loaded from git items: ItemBatch, + /// Commit graph related to [items] + graph_cache: RefCell, + // // User interface // @@ -85,6 +93,9 @@ impl CommitList { /// pub fn new(env: &Environment, title: &str) -> Self { Self { + graph_cache: RefCell::new(GraphCache::new( + env.repo.clone(), + )), repo: env.repo.clone(), items: ItemBatch::default(), marked: Vec::with_capacity(2), @@ -226,7 +237,7 @@ impl CommitList { } } - /// + /// Clear self.items and fetch the commits indicated pub fn set_commits(&mut self, commits: IndexSet) { if commits != self.commits { self.items.clear(); @@ -235,7 +246,7 @@ impl CommitList { } } - /// + /// Extend self.commits with the provided commits. Fetch commit info pub fn refresh_extend_data(&mut self, commits: Vec) { let new_commits = !commits.is_empty(); self.commits.extend(commits); @@ -460,6 +471,7 @@ impl CommitList { } } + /// Format one commit for drawing #[allow(clippy::too_many_arguments)] fn get_entry_to_add<'a>( &self, @@ -586,6 +598,7 @@ impl CommitList { Line::from(txt) } + /// Format commits visible inside the component, at the current scroll fn get_text(&self, height: usize, width: usize) -> Vec> { let selection = self.relative_selection(); @@ -627,7 +640,9 @@ impl CommitList { None }; - txt.push(self.get_entry_to_add( + let graph_column: Line = + self.graph_cache.borrow().get_graph_line(idx); + let text_column: Line = self.get_entry_to_add( e, idx + self.scroll_top.get() == selection, tags, @@ -637,7 +652,13 @@ impl CommitList { width, now, marked, - )); + ); + txt.push( + graph_column + .into_iter() + .chain(text_column) + .collect::(), + ); } txt @@ -781,6 +802,7 @@ impl CommitList { ); if let Ok(commits) = commits { + self.graph_cache.borrow_mut().add_commits(&commits); self.items.set_items( want_min, commits, @@ -808,6 +830,24 @@ impl DrawableComponent for CommitList { selection, )); + // Update commit graph + { + // TODO Current code assumes line = commit index + // This is wrong as soon as a commit can have multiple lines. + // To fix this, we need to rethink scroll_top and line + // Maybe a variable scroll_top_ref = (commit inx, offset) + // that is updated when the top is calculated and set + // The hack would be to only update offset if top commit is multi line + // and you scroll up/down one line. For lines on screen we can compute + // the accorate commit+offset, for lines outside the screen, assume + // every commit is one line. + let top = self.scroll_top.get(); + let visible_range = top..top + height_in_lines; + self.graph_cache + .borrow_mut() + .compute_layout(visible_range); + } + let title = format!( "{} {}/{}", self.title, @@ -923,6 +963,11 @@ mod tests { impl Default for CommitList { fn default() -> Self { + let repo = RepoPathRef::new(sync::RepoPath::Path( + std::path::PathBuf::default(), + )); + let graph_cache = + RefCell::new(GraphCache::new(repo.clone())); Self { title: String::new().into_boxed_str(), selection: 0, @@ -930,6 +975,7 @@ mod tests { highlights: Option::None, tags: Option::None, items: ItemBatch::default(), + graph_cache, commits: IndexSet::default(), marked: Vec::default(), scroll_top: Cell::default(), @@ -939,9 +985,7 @@ mod tests { key_config: SharedKeyConfig::default(), scroll_state: (Instant::now(), 0.0), current_size: Cell::default(), - repo: RepoPathRef::new(sync::RepoPath::Path( - std::path::PathBuf::default(), - )), + repo, queue: Queue::default(), } } diff --git a/src/components/mod.rs b/src/components/mod.rs index e4801a31b5..e67a49ae75 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -58,9 +58,9 @@ pub use revision_files::RevisionFilesComponent; pub use syntax_text::SyntaxTextComponent; pub use textinput::{InputType, TextInputComponent}; pub use utils::{ - filetree::FileTreeItemKind, logitems::ItemBatch, - scroll_vertical::VerticalScroll, string_width_align, - time_to_string, + filetree::FileTreeItemKind, graph_cache::GraphCache, + logitems::ItemBatch, scroll_vertical::VerticalScroll, + string_width_align, time_to_string, }; use crate::ui::style::Theme; diff --git a/src/components/utils/graph_cache.rs b/src/components/utils/graph_cache.rs new file mode 100644 index 0000000000..9d6dad68c8 --- /dev/null +++ b/src/components/utils/graph_cache.rs @@ -0,0 +1,152 @@ +/*! The `graph_cache` module implements an adaptor of the gleisbau library +to the internals of gitui. */ + +use std::cell::RefCell; +use std::ops::Range; +use std::rc::Rc; + +use gleisbau::backend::git2::Builder; +use gleisbau::backend::git2::TrackMap; +use gleisbau::layout::layout_track_range; +use gleisbau::layout::TrackLayout; +use gleisbau::print::format::CommitFormat; +use gleisbau::print::unicode::print_graph_terminal; +use gleisbau::print::unicode::GraphLines; +use gleisbau::settings::BranchOrder; +use gleisbau::settings::BranchSettings; +use gleisbau::settings::BranchSettingsDef; +use gleisbau::settings::Characters; +use gleisbau::settings::MergePatterns; +use gleisbau::settings::Settings; +use ratatui::text::Line; + +use asyncgit::sync::CommitInfo; +use asyncgit::sync::RepoPath; + +/* +/// Commit index type. Index into [CommitList.commits] +type Cinx = u32; + +/// Branch index type +type Binx = u32; +*/ + +/** Main struct used for branch graph. +*/ +pub struct GraphCache { + /// gleisbau configuration. This is used by the builder + /// as well as when updating layout + settings: Rc, + + /// Topology. + topo: Rc>, + + /// Geometry + geo: Option, + + /// Document + doc: Option, + + /// Builder used to incrementally fill topology data from repository. + /// Discarded when all commits has been processed. + builder: Option, +} + +fn extract_settings(_repo_path: RefCell) -> Settings { + // TODO read gleisbau config file in repository folder + // or remove _repo_path argument + + Settings { + // Reverse the order of commits + reverse_commit_order: false, + // Debug printing and drawing + debug: false, + // Compact text-based graph + compact: false, + // Colored text-based graph + colored: false, + // Include remote branches? + include_remote: false, + // Formatting for commits + format: CommitFormat::OneLine, // TODO eliminate - not needed + // Text wrapping options + wrapping: None, // TODO eliminate - not needed + // Characters to use for text-based graph + characters: Characters::round(), + // Branch column sorting algorithm + branch_order: BranchOrder::ShortestFirst(true), + // Settings for branches + branches: BranchSettings::from(BranchSettingsDef::none()) + .expect("Default settings should never fail"), + // Regex patterns for finding branch names in merge commit summaries + merge_patterns: MergePatterns::default(), + } +} + +impl GraphCache { + pub fn new(repo_path: RefCell) -> Self { + let settings = Rc::new(extract_settings(repo_path)); + let topo = Rc::new(RefCell::new(TrackMap::new())); + let builder = Some(Builder::new(topo.clone())); + Self { + settings, + topo, + geo: None, + doc: None, + builder, + } + } + + /// Expand the branch topology + pub fn add_commits(&mut self, commits: &Vec) { + let builder = self + .builder + .as_mut() + // Initial version expects builder to live forever + // Later version may create and discard as needed + .expect("Builder is never discarded"); + for c in commits { + let id = c.id.into(); + let message = c.message.clone(); + let parents: Vec<_> = + c.parents.iter().map(|&id| id.into()).collect(); + + builder.add_commit(id, message, parents); + } + } + + /// Layout a section of commits + pub fn compute_layout(&mut self, commit_range: Range) { + let track_layout = layout_track_range( + &self.topo.borrow(), + commit_range, + &self.settings, + ) + .expect("Valid Trackmap and range"); + + // All commits are given 1 row for text + let text_height = vec![1; track_layout.commit_count()]; + + let graph_lines = print_graph_terminal( + &self.settings, + &self.topo.borrow(), + &track_layout, + &text_height, + ); + + self.geo = Some(track_layout); + self.doc = Some(graph_lines); + } + + /// Get a graph from the specified offset row in layout + pub fn get_graph_line(&self, row: usize) -> Line<'static> { + let line_ref = self + .doc + .as_ref() + .and_then(|graph_lines| graph_lines.graph_lines.get(row)); + + let string_to_line = |line: &String| Line::from(line.clone()); + let default_line = || Line::from("%% no graph data %%"); + line_ref.map_or_else(default_line, string_to_line) + } +} diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index 29485be1e4..1916f04a23 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 graph_cache; pub mod logitems; pub mod scroll_horizontal; pub mod scroll_vertical; From 26e594692541e8e40e47773fb5985aaa2724b3a3 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Fri, 26 Jun 2026 04:56:48 +0200 Subject: [PATCH 16/21] Make GraphCache handle uneven height commits --- src/components/commitlist.rs | 95 ++++++++++++++++------------- src/components/utils/graph_cache.rs | 60 +++++++++++++++++- 2 files changed, 113 insertions(+), 42 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 24cb2d1988..8f680632e8 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -606,17 +606,12 @@ impl CommitList { let now = Local::now(); - let any_marked = !self.marked.is_empty(); - - for (idx, e) in self - .items - .iter() - .skip(self.scroll_top.get()) - .take(height) - .enumerate() - { + fn tags_branches_marked( + this: &CommitList, + e: &LogEntry, + ) -> (Option, Option, Option) { let tags = - self.tags.as_ref().and_then(|t| t.get(&e.id)).map( + this.tags.as_ref().and_then(|t| t.get(&e.id)).map( |tags| { tags.iter() .map(|t| format!("<{}>", t.name)) @@ -625,7 +620,7 @@ impl CommitList { ); let local_branches = - self.local_branches.get(&e.id).map(|local_branch| { + this.local_branches.get(&e.id).map(|local_branch| { local_branch .iter() .map(|local_branch| { @@ -634,32 +629,59 @@ impl CommitList { .join(" ") }); + let any_marked = !this.marked.is_empty(); let marked = if any_marked { - self.is_marked(&e.id) + this.is_marked(&e.id) } else { None }; - let graph_column: Line = - self.graph_cache.borrow().get_graph_line(idx); - let text_column: Line = self.get_entry_to_add( - e, - idx + self.scroll_top.get() == selection, - tags, - local_branches, - self.remote_branches_string(e), - &self.theme, - width, - now, - marked, - ); - txt.push( - graph_column - .into_iter() - .chain(text_column) - .collect::(), - ); + (tags, local_branches, marked) + } + + for (idx, e) in self + .items + .iter() + .skip(self.scroll_top.get()) + .take(height) + .enumerate() + { + // Loop over graph lines pr commit. There may be more than one + let commit_height = + self.graph_cache.borrow().layout_commit_height(idx); + for ofs in 0..commit_height { + let current_line = txt.len(); + let graph_column: Line = self + .graph_cache + .borrow() + .get_graph_line(current_line); + let text_column: Line = if ofs == 0 { + let (tags, local_branches, marked) = + tags_branches_marked(self, e); + self.get_entry_to_add( + e, + idx + self.scroll_top.get() == selection, + tags, + local_branches, + self.remote_branches_string(e), + &self.theme, + width, + now, + marked, + ) + } else { + Line::from("") + }; + txt.push( + graph_column + .into_iter() + .chain(text_column) + .collect::(), + ); + } } + // Apply internal scroll to get selection into the visible area + txt.drain(0..self.graph_cache.borrow().row_scroll()); txt } @@ -832,20 +854,11 @@ impl DrawableComponent for CommitList { // Update commit graph { - // TODO Current code assumes line = commit index - // This is wrong as soon as a commit can have multiple lines. - // To fix this, we need to rethink scroll_top and line - // Maybe a variable scroll_top_ref = (commit inx, offset) - // that is updated when the top is calculated and set - // The hack would be to only update offset if top commit is multi line - // and you scroll up/down one line. For lines on screen we can compute - // the accorate commit+offset, for lines outside the screen, assume - // every commit is one line. let top = self.scroll_top.get(); let visible_range = top..top + height_in_lines; self.graph_cache .borrow_mut() - .compute_layout(visible_range); + .compute_layout(visible_range, self.selection); } let title = format!( diff --git a/src/components/utils/graph_cache.rs b/src/components/utils/graph_cache.rs index 9d6dad68c8..231e9ae291 100644 --- a/src/components/utils/graph_cache.rs +++ b/src/components/utils/graph_cache.rs @@ -47,6 +47,10 @@ pub struct GraphCache { /// Document doc: Option, + /// Internal line scroll adjustment so selection is visible. + /// This is necessary when some commits use more than one row. + row_scroll: usize, + /// Builder used to incrementally fill topology data from repository. /// Discarded when all commits has been processed. builder: Option, @@ -93,6 +97,7 @@ impl GraphCache { topo, geo: None, doc: None, + row_scroll: 0, builder, } } @@ -116,7 +121,13 @@ impl GraphCache { } /// Layout a section of commits - pub fn compute_layout(&mut self, commit_range: Range) { + pub fn compute_layout( + &mut self, + commit_range: Range, + selection: usize, + ) { + let first_commit = commit_range.start; + let height_in_lines = commit_range.len(); // Assume caller did top..top+height let track_layout = layout_track_range( &self.topo.borrow(), commit_range, @@ -136,6 +147,24 @@ impl GraphCache { self.geo = Some(track_layout); self.doc = Some(graph_lines); + + // If a commit takes more than one row, then line count and commit count + // no longer match. If selection is at the last commit, this will be + // off screen. Adjust layout scroll so selection is always visible. + // + // NOTE: This implementation has the strange effect that an arrow up + // will auto-scroll which is probably not what the user expects. + let select_layout_commit = + selection.saturating_sub(first_commit); + self.row_scroll = self + .doc + .as_ref() + .unwrap() + .commit2line + .get(select_layout_commit) + .unwrap_or(&0) + .saturating_add(1) + .saturating_sub(height_in_lines); } /// Get a graph from the specified offset row in layout @@ -149,4 +178,33 @@ impl GraphCache { let default_line = || Line::from("%% no graph data %%"); line_ref.map_or_else(default_line, string_to_line) } + + /// First line that should be displayed, if you want the selection + /// to be visible. + pub fn row_scroll(&self) -> usize { + self.row_scroll + } + + /// Get the height of a commit in the layout + pub fn layout_commit_height( + &self, + layout_commit: usize, + ) -> usize { + let this_line = self.doc.as_ref().and_then(|graph_lines| { + graph_lines.commit2line.get(layout_commit) + }); + let next_line = self.doc.as_ref().and_then(|graph_lines| { + graph_lines.commit2line.get(layout_commit + 1) + }); + match (this_line, next_line) { + (Some(a), Some(b)) => b - a, + (Some(a), None) => { + self.doc + .as_ref() + .map(|gl| gl.graph_lines.len()) + .unwrap() - a + } + (None, _) => 0, + } + } } From 79ab4257111ebfb73c131e73e74b723605e0e454 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sun, 9 Aug 2026 21:55:58 +0200 Subject: [PATCH 17/21] Make LogWalkerWithoutFilter use all heads --- asyncgit/src/sync/logwalker.rs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index f19f62eb75..143248d6c2 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -111,9 +111,35 @@ impl<'a> LogWalkerWithoutFilter<'a> { // reason this is 2^14, so benchmarking might reveal that there’s better values. repo.object_cache_size_if_unset(2_usize.pow(14)); - let commit = repo.head()?.peel_to_commit()?; - - let tips = [commit.id]; + // Walk every local branch + let mut tips = Vec::new(); + for ref_result in repo.references()?.local_branches()? { + let mut reference = match ref_result { + Ok(reference) => reference, + Err(err) => { + log::warn!("failed to read local branch reference: {err}"); + continue; + } + }; + + match reference.peel_to_commit() { + Ok(commit) => tips.push(commit.id), + Err(err) => { + log::warn!("failed to resolve local branch {} to a commit: {}", + reference.name().as_bstr(), + err, + ); + } + } + } + // .. and HEAD, in case it is detached + match repo.head()?.try_peel_to_id() { + Ok(Some(id)) => tips.push(id.detach()), + Ok(None) => {} + Err(err) => { + log::warn!("failed to resolve HEAD: {err}"); + } + } let walk = topo::Builder::new(&*repo) // Show no parents before all of its children are shown, From b1064497c4f1676711dfd3495b7442dcc31bbfc9 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Tue, 11 Aug 2026 06:38:57 +0200 Subject: [PATCH 18/21] Do not give topo walker identical starting points --- asyncgit/src/sync/logwalker.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index 143248d6c2..c19e95c03c 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -140,6 +140,11 @@ impl<'a> LogWalkerWithoutFilter<'a> { log::warn!("failed to resolve HEAD: {err}"); } } + // Avoid bug in gitoxide that triggers when adding two identical + // starting points for the walk. + // It is valid for multiple refs to point to the same commit. + tips.sort_unstable(); + tips.dedup(); let walk = topo::Builder::new(&*repo) // Show no parents before all of its children are shown, From 463e610294f55a645265431a9b7aaca35d7a2e65 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Mon, 10 Aug 2026 22:13:57 +0200 Subject: [PATCH 19/21] Do not squash error inside read Return walk error if one occurrs. --- asyncgit/src/sync/logwalker.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index c19e95c03c..b524998bf7 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -169,7 +169,8 @@ impl<'a> LogWalkerWithoutFilter<'a> { pub fn read(&mut self, out: &mut Vec) -> Result { let mut count = 0_usize; - while let Some(Ok(info)) = self.walk.next() { + while let Some(info) = self.walk.next() { + let info = info?; out.push(info.id.into()); count += 1; From 808c4ff7c9854fb5f94785a77f93de22f78dbd06 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sat, 27 Jun 2026 18:24:27 +0200 Subject: [PATCH 20/21] TODO insert graph after marker The graph should be rendered in a different location in code, so the marker can be on the left side of the graph. From f1e12b26ddca307d88054787bb4572c4ed7ea639 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sat, 27 Jun 2026 20:57:06 +0200 Subject: [PATCH 21/21] TODO Add a layout window around the visible window. To make branch column more stable, add a window around what is visible. Only update the window when trying to show something outside.