From bd32e393fedd01e27a4f6984281bcc3182c63b67 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 11 Jun 2023 17:56:15 +0200 Subject: [PATCH] feat: `bit revision list --svg` to create a visual graph of commits. It's mainly a test of how well `layout-rs` performs. --- Cargo.lock | 43 +++++ Cargo.toml | 2 +- gitoxide-core/Cargo.toml | 4 + gitoxide-core/src/repository/revision/list.rs | 170 ++++++++++++++---- gitoxide-core/src/repository/revision/mod.rs | 4 +- src/plumbing/main.rs | 22 ++- src/plumbing/options/mod.rs | 6 + 7 files changed, 207 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 954f648095b..929e69d586f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1269,6 +1269,8 @@ dependencies = [ "gix-url", "itertools", "jwalk", + "layout-rs", + "open", "rusqlite", "serde", "serde_json", @@ -2789,6 +2791,15 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + [[package]] name = "is-terminal" version = "0.4.7" @@ -2801,6 +2812,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_ci" version = "1.1.1" @@ -2869,6 +2890,12 @@ dependencies = [ "log", ] +[[package]] +name = "layout-rs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1164ef87cb9607c2d887216eca79f0fc92895affe1789bba805dd38d829584e0" + [[package]] name = "lazy_static" version = "1.4.0" @@ -3163,6 +3190,16 @@ version = "11.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575" +[[package]] +name = "open" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16814a067484415fda653868c9be0ac5f2abd2ef5d951082a5f2fe1b3662944" +dependencies = [ + "is-wsl", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.51" @@ -3278,6 +3315,12 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + [[package]] name = "percent-encoding" version = "2.2.0" diff --git a/Cargo.toml b/Cargo.toml index 53cb0f39d4f..60d6c274abc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -182,7 +182,7 @@ sha1_smol = { opt-level = 3 } [profile.release] overflow-checks = false -lto = "fat" +#lto = "fat" # this bloats files but assures destructors are called, important for tempfiles. One day I hope we # can wire up the 'abrt' signal handler so tempfiles will be removed in case of panics. panic = 'unwind' diff --git a/gitoxide-core/Cargo.toml b/gitoxide-core/Cargo.toml index 5f26cdc2a7a..3dc0dd5ed63 100644 --- a/gitoxide-core/Cargo.toml +++ b/gitoxide-core/Cargo.toml @@ -69,6 +69,10 @@ smallvec = { version = "1.10.0", optional = true } # for 'query' rusqlite = { version = "0.29.0", optional = true, features = ["bundled"] } +# for svg graph output +layout-rs = "0.1.1" +open = "4.1.0" + document-features = { version = "0.2.0", optional = true } [package.metadata.docs.rs] diff --git a/gitoxide-core/src/repository/revision/list.rs b/gitoxide-core/src/repository/revision/list.rs index 91949503845..9cca8b3f4c3 100644 --- a/gitoxide-core/src/repository/revision/list.rs +++ b/gitoxide-core/src/repository/revision/list.rs @@ -1,42 +1,140 @@ +use crate::OutputFormat; use std::ffi::OsString; +use std::path::PathBuf; -use anyhow::{bail, Context}; -use gix::traverse::commit::Sorting; +pub struct Context { + pub limit: Option, + pub spec: OsString, + pub format: OutputFormat, + pub text: Format, +} -use crate::OutputFormat; +pub enum Format { + Text, + Svg { path: PathBuf }, +} +pub const PROGRESS_RANGE: std::ops::RangeInclusive = 0..=2; -pub fn list( - mut repo: gix::Repository, - spec: OsString, - mut out: impl std::io::Write, - format: OutputFormat, -) -> anyhow::Result<()> { - if format != OutputFormat::Human { - bail!("Only human output is currently supported"); - } - repo.object_cache_size_if_unset(4 * 1024 * 1024); - - let spec = gix::path::os_str_into_bstr(&spec)?; - let id = repo - .rev_parse_single(spec) - .context("Only single revisions are currently supported")?; - let commits = id - .object()? - .peel_to_kind(gix::object::Kind::Commit) - .context("Need commitish as starting point")? - .id() - .ancestors() - .sorting(Sorting::ByCommitTimeNewestFirst) - .all()?; - for commit in commits { - let commit = commit?; - writeln!( - out, - "{} {} {}", - commit.id().shorten_or_id(), - commit.commit_time.expect("traversal with date"), - commit.parent_ids.len() - )?; +pub(crate) mod function { + use anyhow::{bail, Context}; + use gix::traverse::commit::Sorting; + use std::collections::HashMap; + + use gix::Progress; + use layout::backends::svg::SVGWriter; + use layout::core::base::Orientation; + use layout::core::geometry::Point; + use layout::core::style::StyleAttr; + use layout::std_shapes::shapes::{Arrow, Element, ShapeKind}; + + use crate::repository::revision::list::Format; + use crate::OutputFormat; + + pub fn list( + mut repo: gix::Repository, + mut progress: impl Progress, + mut out: impl std::io::Write, + super::Context { + spec, + format, + text, + limit, + }: super::Context, + ) -> anyhow::Result<()> { + if format != OutputFormat::Human { + bail!("Only human output is currently supported"); + } + repo.object_cache_size_if_unset(4 * 1024 * 1024); + + let spec = gix::path::os_str_into_bstr(&spec)?; + let id = repo + .rev_parse_single(spec) + .context("Only single revisions are currently supported")?; + let commits = id + .object()? + .peel_to_kind(gix::object::Kind::Commit) + .context("Need commitish as starting point")? + .id() + .ancestors() + .sorting(Sorting::ByCommitTimeNewestFirst) + .all()?; + + let mut vg = match text { + Format::Svg { path } => ( + layout::topo::layout::VisualGraph::new(Orientation::TopToBottom), + path, + HashMap::new(), + ) + .into(), + Format::Text => None, + }; + progress.init(None, gix::progress::count("commits")); + progress.set_name("traverse"); + + let start = std::time::Instant::now(); + for commit in commits { + if gix::interrupt::is_triggered() { + bail!("interrupted by user"); + } + let commit = commit?; + match vg.as_mut() { + Some((vg, _path, map)) => { + let pt = Point::new(100., 30.); + let source = match map.get(&commit.id) { + Some(handle) => *handle, + None => { + let name = commit.id().shorten_or_id().to_string(); + let shape = ShapeKind::new_box(name.as_str()); + let style = StyleAttr::simple(); + let handle = vg.add_node(Element::create(shape, style, Orientation::LeftToRight, pt)); + map.insert(commit.id, handle); + handle + } + }; + + for parent_id in commit.parent_ids() { + let dest = match map.get(parent_id.as_ref()) { + Some(handle) => *handle, + None => { + let name = parent_id.shorten_or_id().to_string(); + let shape = ShapeKind::new_box(name.as_str()); + let style = StyleAttr::simple(); + let dest = vg.add_node(Element::create(shape, style, Orientation::LeftToRight, pt)); + map.insert(parent_id.detach(), dest); + dest + } + }; + let arrow = Arrow::simple(""); + vg.add_edge(arrow, source, dest); + } + } + None => { + writeln!( + out, + "{} {} {}", + commit.id().shorten_or_id(), + commit.commit_time.expect("traversal with date"), + commit.parent_ids.len() + )?; + } + } + progress.inc(); + if limit.map_or(false, |limit| limit == progress.step()) { + break; + } + } + + progress.show_throughput(start); + if let Some((mut vg, path, _)) = vg { + let start = std::time::Instant::now(); + progress.set_name("computing graph"); + progress.info(format!("writing {path:?}…")); + let mut svg = SVGWriter::new(); + vg.do_it(false, false, false, &mut svg); + std::fs::write(&path, svg.finalize().as_bytes())?; + open::that(path)?; + progress.show_throughput(start); + } + Ok(()) } - Ok(()) } diff --git a/gitoxide-core/src/repository/revision/mod.rs b/gitoxide-core/src/repository/revision/mod.rs index 5e5dda98af5..e6366e88b59 100644 --- a/gitoxide-core/src/repository/revision/mod.rs +++ b/gitoxide-core/src/repository/revision/mod.rs @@ -1,5 +1,5 @@ -mod list; -pub use list::list; +pub mod list; +pub use list::function::list; mod explain; pub use explain::explain; diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index 7896e6e66cd..335144bb23a 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -693,14 +693,26 @@ pub fn main() -> Result<()> { }, ), Subcommands::Revision(cmd) => match cmd { - revision::Subcommands::List { spec } => prepare_and_run( + revision::Subcommands::List { spec, svg, limit } => prepare_and_run( "revision-list", - verbose, + auto_verbose, progress, progress_keep_open, - None, - move |_progress, out, _err| { - core::repository::revision::list(repository(Mode::Lenient)?, spec, out, format) + core::repository::revision::list::PROGRESS_RANGE, + move |progress, out, _err| { + core::repository::revision::list( + repository(Mode::Lenient)?, + progress, + out, + core::repository::revision::list::Context { + limit, + spec, + format, + text: svg.map_or(core::repository::revision::list::Format::Text, |path| { + core::repository::revision::list::Format::Svg { path } + }), + }, + ) }, ), revision::Subcommands::PreviousBranches => prepare_and_run( diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs index 3f0a9a27053..ea0e30a0cd0 100644 --- a/src/plumbing/options/mod.rs +++ b/src/plumbing/options/mod.rs @@ -441,6 +441,12 @@ pub mod revision { /// List all commits reachable from the given rev-spec. #[clap(visible_alias = "l")] List { + /// How many commits to list at most. + #[clap(long, short = 'l')] + limit: Option, + /// Write the graph as SVG file to the given path. + #[clap(long, short = 's')] + svg: Option, /// The rev-spec to list reachable commits from. #[clap(default_value = "@")] spec: std::ffi::OsString,