From 3b3436b89013c6d966d14b83bc15f40c28f84092 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 9 Apr 2025 15:13:25 +0200 Subject: [PATCH 01/34] Copy git-graph v0.6.0 into source --- git-graph/CA-LOG.md | 93 ++++ git-graph/Cargo.lock | 899 ++++++++++++++++++++++++++++++ git-graph/Cargo.toml | 35 ++ git-graph/Justfile | 24 + git-graph/LICENSE | 21 + git-graph/README.md | 111 ++++ git-graph/docs/manual.md | 326 +++++++++++ git-graph/src/config.rs | 153 +++++ git-graph/src/graph.rs | 984 +++++++++++++++++++++++++++++++++ git-graph/src/lib.rs | 13 + git-graph/src/main.rs | 539 ++++++++++++++++++ git-graph/src/print/colors.rs | 45 ++ git-graph/src/print/format.rs | 548 ++++++++++++++++++ git-graph/src/print/mod.rs | 45 ++ git-graph/src/print/svg.rs | 161 ++++++ git-graph/src/print/unicode.rs | 727 ++++++++++++++++++++++++ git-graph/src/settings.rs | 362 ++++++++++++ 17 files changed, 5086 insertions(+) create mode 100644 git-graph/CA-LOG.md create mode 100644 git-graph/Cargo.lock create mode 100644 git-graph/Cargo.toml create mode 100644 git-graph/Justfile create mode 100644 git-graph/LICENSE create mode 100644 git-graph/README.md create mode 100644 git-graph/docs/manual.md create mode 100644 git-graph/src/config.rs create mode 100644 git-graph/src/graph.rs create mode 100644 git-graph/src/lib.rs create mode 100644 git-graph/src/main.rs create mode 100644 git-graph/src/print/colors.rs create mode 100644 git-graph/src/print/format.rs create mode 100644 git-graph/src/print/mod.rs create mode 100644 git-graph/src/print/svg.rs create mode 100644 git-graph/src/print/unicode.rs create mode 100644 git-graph/src/settings.rs diff --git a/git-graph/CA-LOG.md b/git-graph/CA-LOG.md new file mode 100644 index 0000000000..c2fbc9473d --- /dev/null +++ b/git-graph/CA-LOG.md @@ -0,0 +1,93 @@ +# Code Acumen - Analysis Log +This text is a log of what I have found out during analysis of the code. +I start out from knowing nothing about it + +2025-04-03 06:48 + +The program shows a log of a git repository. +It is written in Rust and uses ratatui. +Goal: + Determine if I can use it as a library in gitui to display the log with + a graph. gitui does not have that today. + +Goal: + I want to find the API that allows me to render the log. + Let me start by finding the render code. +Info: + wc-tree.py reports 4k LOC in 18 files of Rust +Q: + Where is the graph rendered? + +7:12 + +main.rs + :41..237 # define CLI parameters + :237..382 # configure from provided CLI arguments + :382..397 # call run() to build output + :399..405 fn run(..) parameters + :407 graph = GitGraph::new + graph.rs:30 fn GitGraph.new + :430 print_unicode(&graph) # will it generate print-data for every commit?? + :434 print_unpaged(g_lines, t_lines) + :535 fn print_unpaged(graph_lines, text_lines) + # iterate graph_lines + +07:27 + +A: + It is generated by graph.rs:GitGraph + it is rendered in gitgraph/print/unicode.rs:print_unicode + +Q: + How do I specify which commit range to show? + +pause til 16:23 + +More info +print/unicode.rs:39 fn print_unicode + :67 for info in graph.commits.iter() + compute text part + :144 for info in graph.commits.iter() + compute branch graph part + :197 lines = print_graph(.. text_lines) + :498 fn print_graph + returns two vec (g_lines, t_lines) + +A: + That would be graph.commits, a Vec<CommitInfo> + GitGraph.new fills it + :99 + using info.branch_trace as a filter? + +struct CommitInfo.branch_trace is Option<usize> + +unicode.rs:544 fn format # format a commit + + +Thought: + Maybe I should look at main to see how it pages? + It looks like it calls print_unicode to print the entire graph to memory + and then show it gradually. + + +graph.rs:30 GitGraph.new + has parameter max_counts that will limit number of commits to generate + + will repository: git2.Repository + or settings: settings.Settings + etermine the first commit? + + reposity.revwalk generates an iterator? + +Unfortunately + graph.rs:29 fn GitGraph.new +will select which commits are extracted from theh repository. +It does not have a starting point, so it always starts at HEAD. +s +Q: + How does gitui manage which revisions to show? + +17:27 -- stop, must take evening walk + +19:46 -- look at gitui + diff --git a/git-graph/Cargo.lock b/git-graph/Cargo.lock new file mode 100644 index 0000000000..c2c27767ea --- /dev/null +++ b/git-graph/Cargo.lock @@ -0,0 +1,899 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bumpalo" +version = "3.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba" + +[[package]] +name = "cc" +version = "1.0.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a284da2e6fe2092f2353e51713435363112dfd60030e22add80be333fb928f" +dependencies = [ + "jobserver", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-integer", + "num-traits", + "time", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "clap" +version = "4.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91b9970d7505127a162fdaa9b96428d28a479ba78c9ec7550a63a5d9863db682" +dependencies = [ + "atty", + "bitflags", + "clap_lex", + "once_cell", + "strsim", + "termcolor", +] + +[[package]] +name = "clap_lex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4198f73e42b4936b35b5bb248d81d2b595ecb170da0bac7655c54eedfa8da8" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +dependencies = [ + "winapi", +] + +[[package]] +name = "cxx" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97abf9f0eca9e52b7f81b945524e76710e6cb2366aead23b7d4fbf72e281f888" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc32cc5fea1d894b77d269ddb9f192110069a8a9c1f1d441195fba90553dea3" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca220e4794c934dc6b1207c3b42856ad4c302f2df1712e9f8d2eec5afaacf1f" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b846f081361125bfc8dc9d3940c84e1fd83ba54bbca7b17cd29483c828be0704" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dirs-next" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf36e65a80337bea855cd4ef9b8401ffce06a7baedf2e85ec467b1ac3f6e82b6" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "either" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" + +[[package]] +name = "form_urlencoded" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "git-graph" +version = "0.6.0" +dependencies = [ + "atty", + "chrono", + "clap", + "crossterm", + "git2", + "itertools", + "lazy_static", + "platform-dirs", + "regex", + "serde", + "serde_derive", + "svg", + "textwrap", + "toml", + "yansi", +] + +[[package]] +name = "git2" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2994bee4a3a6a51eb90c218523be382fd7ea09b16380b9312e9dbe955ff7c7d1" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "jobserver" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "068b1ee6743e4d11fb9c6a1e6064b3693a1b600e7f5f5988047d98b3dc9fb90b" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49409df3e3bf0856b916e2ceaca09ee28e6871cf7d9ce97a692cacfdb2a25a47" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" + +[[package]] +name = "libgit2-sys" +version = "0.14.0+1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a00859c70c8a4f7218e6d1cc32875c4b55f6799445b842b0d8ed5e4c3d959b" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "link-cplusplus" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9272ab7b96c9046fbc5bc56c06c117cb639fe2d509df0c421cad82d2915cf369" +dependencies = [ + "cc", +] + +[[package]] +name = "lock_api" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "mio" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" + +[[package]] +name = "os_str_bytes" +version = "6.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3baf96e39c5359d2eb0dd6ccb42c62b91d9678aa68160d261b9e0ccbf9e9dea9" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc9e0dc2adc1c69d09143aff38d3d30c5c3f0df0dad82e6d25547af174ebec0" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys", +] + +[[package]] +name = "percent-encoding" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" + +[[package]] +name = "pkg-config" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" + +[[package]] +name = "platform-dirs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e188d043c1a692985f78b5464853a263f1a27e5bd6322bad3a4078ee3c998a38" +dependencies = [ + "dirs-next", +] + +[[package]] +name = "proc-macro2" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom", + "redox_syscall", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "scratch" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" + +[[package]] +name = "serde" +version = "1.0.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" + +[[package]] +name = "serde_derive" +version = "1.0.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "signal-hook" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "svg" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e6ff893392e6a1eb94a210562432c6380cebf09d30962a012a655f7dde2ff8" + +[[package]] +name = "syn" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "toml" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" +dependencies = [ + "serde", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" + +[[package]] +name = "unicode-ident" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + +[[package]] +name = "url" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf9f5aceeec8be17c128b2e93e031fb8a4d469bb9c4ae2d7dc1888b26887268" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8ffb332579b0557b52d268b91feab8df3615f265d5270fec2a8c95b17c1142" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052be0f94026e6cbc75cdefc9bae13fd6052cdcaf532fa6c45e7ae33a1e6c810" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" diff --git a/git-graph/Cargo.toml b/git-graph/Cargo.toml new file mode 100644 index 0000000000..a4a3f2cef1 --- /dev/null +++ b/git-graph/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "git-graph" +version = "0.6.0" +authors = ["Martin Lange <martin_lange_@gmx.net>"] +description = "Command line tool to show clear git graphs arranged for your branching model" +repository = "https://github.com/mlange-42/git-graph.git" +keywords = ["git", "graph"] +license = "MIT" +readme = "README.md" +edition = "2021" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +debug = false +debug-assertions = false +overflow-checks = false + +[dependencies] +git2 = {version = "0.15", default-features = false, optional = false} +regex = {version = "1.7", default-features = false, optional = false, features = ["std"]} +serde = "1.0" +serde_derive = {version = "1.0", default-features = false, optional = false} +toml = "0.5" +itertools = "0.10" +svg = "0.12" +clap = {version = "4.0", optional = false, features = ["cargo"]} +lazy_static = "1.4" +yansi = "0.5" +atty = "0.2" +platform-dirs = "0.3" +crossterm = {version = "0.25", optional = false} +chrono = {version = "0.4", optional = false} +textwrap = {version = "0.16", default-features = false, optional = false, features = ["unicode-width"]} diff --git a/git-graph/Justfile b/git-graph/Justfile new file mode 100644 index 0000000000..f57c64c658 --- /dev/null +++ b/git-graph/Justfile @@ -0,0 +1,24 @@ +_default: + @just --choose + +# Shows a list of all available recipes +help: + @just --list + +green := '\033[0;32m' +red := '\033[0;31m' +reset := '\033[0m' + +# Checks if all requirements to work on this project are installed +check-requirements: + @command -v cargo &>/dev/null && echo -e "{{ green }}✓{{ reset}} cargo installed" || echo -e "{{ red }}✖{{ reset }} cargo missing" + +# Runs the same linters as the pipeline with fix option +lint: + cargo fmt --all # Is in fix mode by default + cargo clippy --all --all-targets --allow-dirty --fix + +# Runs the same test as the pipeline but locally +test: + cargo test --all + diff --git a/git-graph/LICENSE b/git-graph/LICENSE new file mode 100644 index 0000000000..033f39d06a --- /dev/null +++ b/git-graph/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Martin Lange + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/git-graph/README.md b/git-graph/README.md new file mode 100644 index 0000000000..53d77f8105 --- /dev/null +++ b/git-graph/README.md @@ -0,0 +1,111 @@ +# git-graph + +[](https://github.com/mlange-42/git-graph/actions/workflows/tests.yml) +[](https://github.com/mlange-42/git-graph) +[](https://crates.io/crates/git-graph) +[](https://github.com/mlange-42/git-graph/blob/master/LICENSE) + +A command line tool to visualize Git history graphs in a comprehensible way, following different branching models. + +The image below shows an example using the [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) branching model for a comparison between graphs generated by git-graph (far left) versus other tools and Git clients. + +> GitFlow was chosen for its complexity, while any other branching model is supported, including user-defined ones. + + + +Decide for yourself which graph is the most comprehensible. :sunglasses: + +If you want an **interactive Git terminal application**, see [**git-igitt**](https://github.com/mlange-42/git-igitt), which is based on git-graph. + +## Features + +* View structured graphs directly in the terminal +* Pre-defined and custom branching models and coloring +* Different styles, including ASCII-only (i.e. no "special characters") +* Custom commit formatting, like with `git log --format="..."` + +## Installation + +**Pre-compiled binaries** + +1. Download the [latest binaries](https://github.com/mlange-42/git-graph/releases) for your platform +2. Unzip somewhere +3. *Optional:* add directory `git-graph` to your `PATH` environmental variable + +**Using `cargo`** + +In case you have [Rust](https://www.rust-lang.org/) installed, you can install with `cargo`: + +``` +cargo install git-graph +``` + +## Usage + +**For detailed information, see the [manual](docs/manual.md)**. + +For basic usage, run the following command inside a Git repository's folder: + +``` +git-graph +``` + +> Note: git-graph needs to be on the PATH, or you need use the full path to git-graph: +> +> ``` +> C:/path/to/git-graph/git-graph +> ``` + +**Branching models** + +Run git-graph with a specific model, e.g. `simple`: + +``` +git-graph --model simple +``` + +Alternatively, set the model for the current repository permanently: + +``` +git-graph model simple +``` + +**Get help** + +For the full CLI help describing all options, use: + +``` +git-graph -h +git-graph --help +``` + +For **styles** and commit **formatting**, see the [manual](docs/manual.md). + +## Custom branching models + +Branching models are configured using the files in `APP_DATA/git-graph/models`. + +* Windows: `C:\Users\<user>\AppData\Roaming\git-graph` +* Linux: `~/.config/git-graph` +* OSX: `~/Library/Application Support/git-graph` + +File names of any `.toml` files in the `models` directory can be used in parameter `--model`, or via sub-command `model`. E.g., to use a branching model defined in `my-model.toml`, use: + +``` +git-graph --model my-model +``` + +**For details on how to create your own branching models see the manual, section [Custom branching models](docs/manual.md#custom-branching-models).** + +## Limitations + +* Summaries of merge commits (i.e. 1st line of message) should not be modified! git-graph needs them to categorize merged branches. +* Supports only the primary remote repository `origin`. +* Does currently not support "octopus merges" (i.e. no more than 2 parents) +* On Windows PowerShell, piping to file output does not work properly (changes encoding), so you may want to use the default Windows console instead + +## Contributing + +Please report any issues and feature requests in the [issue tracker](https://github.com/mlange-42/git-graph/issues). + +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. diff --git a/git-graph/docs/manual.md b/git-graph/docs/manual.md new file mode 100644 index 0000000000..d9c9c5d37c --- /dev/null +++ b/git-graph/docs/manual.md @@ -0,0 +1,326 @@ +# git-graph manual + +**Content** + +* [Overview](#overview) +* [Options](#options) +* [Formatting](#formatting) +* [Custom branching models](#custom-branching-models) + +## Overview + +The most basic usage is to simply call git-graph from inside a Git repository: + +``` +git-graph +``` + +This works also deeper down the directory tree, so no need to be in the repository's root folder. + +Alternatively, the path to the repository to visualize can be specified with option `--path`: + +``` +git-graph --path "path/to/repo" +``` + +**Branching models** + +The above call assumes the GitFlow branching model (the default). Different branching models can be used with the option `--model` or `-m`: + +``` +git-graph --model simple +``` + +To *permanently* set the branching model for a repository, use subcommand `model`, like + +``` +git-graph model simple +``` + +Use the subcommand without argument to view the currently set branching model of a repository: + +``` +git-graph model +``` + +To view all available branching models, use option `--list` or `-l` of the subcommand: + +``` +git-graph model --list +``` + +For **defining your own models**, see section [Custom branching models](#custom-branching-models). + +**Styles** + +Git-graph supports different styles. Besides the default `normal` (alias `thin`), supported styles are `round`, `bold`, `double` and `ascii`. Use a style with option `--style` or `-s`: + +``` +git-graph --style round +``` + + + +Style `ascii` can be used for devices and media that do not support Unicode/UTF-8 characters. + +**Formatting** + +Git-graph supports predefined as well as custom commit formatting through option `--format`. Available presets follow Git: `oneline` (the default), `short`, `medium` and `full`. For details and custom formatting, see section [Formatting](#formatting). + +For a complete list of all available options, see the next section [Options](#options). + +## Options + +All options are explained in the CLI help. View it with `git-graph -h`: + +``` +Structured Git graphs for your branching model. + https://github.com/mlange-42/git-graph + +EXAMPES: + git-graph -> Show graph + git-graph --style round -> Show graph in a different style + git-graph --model <model> -> Show graph using a certain <model> + git-graph model --list -> List available branching models + git-graph model -> Show repo's current branching models + git-graph model <model> -> Permanently set model <model> for this repo + +USAGE: + git-graph [FLAGS] [OPTIONS] [SUBCOMMAND] + +FLAGS: + -d, --debug Additional debug output and graphics. + -h, --help Prints help information + -l, --local Show only local branches, no remotes. + --no-color Print without colors. Missing color support should be detected + automatically (e.g. when piping to a file). + Overrides option '--color' + --no-pager Use no pager (print everything at once without prompt). + -S, --sparse Print a less compact graph: merge lines point to target lines + rather than merge commits. + --svg Render graph as SVG instead of text-based. + -V, --version Prints version information + +OPTIONS: + --color <color> Specify when colors should be used. One of [auto|always|never]. + Default: auto. + -f, --format <format> Commit format. One of [oneline|short|medium|full|"<string>"]. + (First character can be used as abbreviation, e.g. '-f m') + Default: oneline. + For placeholders supported in "<string>", consult 'git-graph --help' + -n, --max-count <n> Maximum number of commits + -m, --model <model> Branching model. Available presets are [simple|git-flow|none]. + Default: git-flow. + Permanently set the model for a repository with + > git-graph model <model> + -p, --path <path> Open repository from this path or above. Default '.' + -s, --style <style> Output style. One of [normal/thin|round|bold|double|ascii]. + (First character can be used as abbreviation, e.g. '-s r') + -w, --wrap <wrap> Line wrapping for formatted commit text. Default: 'auto 0 8' + Argument format: [<width>|auto|none[ <indent1>[ <indent2>]]] + For examples, consult 'git-graph --help' + +SUBCOMMANDS: + help Prints this message or the help of the given subcommand(s) + model Prints or permanently sets the branching model for a repository. +``` + +For longer explanations, use `git-graph --help`. + +## Formatting + +Formatting can be specified with the `--format` option. + +Predefined formats are `oneline` (the default), `short`, `medium` and `full`. They should behave like the Git formatting presets described in the [Git documentation](https://git-scm.com/docs/pretty-formats). + +**oneline** + +``` +<hash> [<refs>] <title line> +``` + +**short** + +``` +commit <hash> [<refs>] +Author: <author> + +<title line> +``` + +**medium** + +``` +commit <hash> [<refs>] +Author: <author> +Date: <author date> + +<title line> + +<full commit message> +``` + +**full** + +``` +commit <hash> [<refs>] +Author: <author> +Commit: <committer> +Date: <author date> + +<title line> + +<full commit message> +``` + +### Custom formatting + +Formatting strings use a subset of the placeholders available in `git log --format="..."`: + +| Placeholder | Replaced with | +| ----------- | ------------------------------------------- | +| %n | newline | +| %H | commit hash | +| %h | abbreviated commit hash | +| %P | parent commit hashes | +| %p | abbreviated parent commit hashes | +| %d | refs (branches, tags) | +| %s | commit summary | +| %b | commit message body | +| %B | raw body (subject and body) | +| %an | author name | +| %ae | author email | +| %ad | author date | +| %as | author date in short format `YYYY-MM-DD` | +| %cn | committer name | +| %ce | committer email | +| %cd | committer date | +| %cs | committer date in short format `YYYY-MM-DD` | + +If you add a '+' (plus sign) after % of a placeholder, a line-feed is inserted immediately before the expansion if and only if the placeholder expands to a non-empty string. + +If you add a '-' (minus sign) after % of a placeholder, all consecutive line-feeds immediately preceding the expansion are deleted if and only if the placeholder expands to an empty string. + +If you add a ' ' (space) after % of a placeholder, a space is inserted immediately before the expansion if and only if the placeholder expands to a non-empty string. + +See also the [Git documentation](https://git-scm.com/docs/pretty-formats). + +More formatting placeholders are planned for later releases. + +**Examples** + +Format recreating `oneline`: + +``` +git-graph --format "%h%d %s" +``` + +Format similar to `short`: + +``` +git-graph --format "commit %H%nAuthor: %an %ae%n%n %s%n" +``` + +## Custom branching models + +Branching models are configured using the files in `APP_DATA/git-graph/models`. + +* Windows: `C:\Users\<user>\AppData\Roaming\git-graph` +* Linux: `~/.config/git-graph` +* OSX: `~/Library/Application Support/git-graph` + +File names of any `.toml` files in the `models` directory can be used in parameter `--model`, or via sub-command `model`. E.g., to use a branching model defined in `my-model.toml`, use: + +``` +git-graph --model my-model +``` + +**Branching model files** are in [TOML](https://toml.io/en/) format and have several sections, relying on Regular Expressions to categorize branches. The listing below shows the `git-flow` model (slightly abbreviated) with explanatory comments. + +```toml +# RegEx patterns for branch groups by persistence, from most persistent +# to most short-leved branches. This is used to back-trace branches. +# Branches not matching any pattern are assumed least persistent. +persistence = [ + '^(master|main|trunk)$', # Matches exactly `master` or `main` or `trunk` + '^(develop|dev)$', + '^feature.*$', # Matches everything starting with `feature` + '^release.*$', + '^hotfix.*$', + '^bugfix.*$', +] + +# RegEx patterns for visual ordering of branches, from left to right. +# Here, `master`, `main` or `trunk` are shown left-most, followed by branches +# starting with `hotfix` or `release`, followed by `develop` or `dev`. +# Branches not matching any pattern (e.g. starting with `feature`) +# are displayed further to the right. +order = [ + '^(master|main|trunk)$', # Matches exactly `master` or `main` or `trunk` + '^(hotfix|release).*$', # Matches everything starting with `hotfix` or `release` + '^(develop|dev)$', # Matches exactly `develop` or `dev` +] + +# Colors of branches in terminal output. +# For supported colors, see section Colors (below this listing). +[terminal_colors] +# Each entry is composed of a RegEx pattern and a list of colors that +# will be used alternating (see e.g. `feature...`). +matches = [ + [ + '^(master|main|trunk)$', + ['bright_blue'], + ], + [ + '^(develop|dev)$', + ['bright_yellow'], + ], + [ # Branches obviously merged in from forks are prefixed with 'fork/'. + # The 'fork/' prefix is only available in order and colors, but not in persistence! + '^(feature|fork/).*$', + ['bright_magenta', 'bright_cyan'], # Multiple colors for alternating use + ], + [ + '^release.*$', + ['bright_green'], + ], + [ + '^(bugfix|hotfix).*$', + ['bright_red'], + ], + [ + '^tags/.*$', + ['bright_green'], + ], +] +# A list of colors that are used (alternating) for all branches +# not matching any of the above pattern. +unknown = ['white'] + +# Colors of branches in SVG output. +# Same structure as terminal_colors. +# For supported colors, see section Colors (below this listing). +[svg_colors] +matches = [ + [ + '^(master|main|trunk)$', + ['blue'], + ], + [ + '...', + ] +] +unknown = ['gray'] +``` + +**Tags** + +Internally, all tags start with `tag/`. To match Git tags, use RegEx patterns like `^tags/.*$`. However, only tags that are not on any branch are ordered and colored separately. + +**Colors** + +**Terminal colors** support the 8 system color names `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan` and `white`, as well as each of them prefixed with `bright_` (e.g. `bright_blue`). + +Further, indices of the 256-color palette are supported. For a full list, see [here](https://jonasjacek.github.io/colors/). Indices must be quoted as strings (e.g. `'16'`) + +**SVG colors** support all named web colors (full list [here](https://htmlcolorcodes.com/color-names/)), as well as RGB colors in hex notation, like `#ffffff`. diff --git a/git-graph/src/config.rs b/git-graph/src/config.rs new file mode 100644 index 0000000000..fced474a09 --- /dev/null +++ b/git-graph/src/config.rs @@ -0,0 +1,153 @@ +use crate::settings::{BranchSettingsDef, RepoSettings}; +use git2::Repository; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +/// Creates the directory `APP_DATA/git-graph/models` if it does not exist, +/// and writes the files for built-in branching models there. +pub fn create_config<P: AsRef<Path> + AsRef<OsStr>>(app_model_path: &P) -> Result<(), String> { + let path: &Path = app_model_path.as_ref(); + if !path.exists() { + std::fs::create_dir_all(app_model_path).map_err(|err| err.to_string())?; + + let models = [ + (BranchSettingsDef::git_flow(), "git-flow.toml"), + (BranchSettingsDef::simple(), "simple.toml"), + (BranchSettingsDef::none(), "none.toml"), + ]; + for (model, file) in &models { + let mut path = PathBuf::from(&app_model_path); + path.push(file); + let str = toml::to_string_pretty(&model).map_err(|err| err.to_string())?; + std::fs::write(&path, str).map_err(|err| err.to_string())?; + } + } + + Ok(()) +} + +/// Get models available in `APP_DATA/git-graph/models`. +pub fn get_available_models<P: AsRef<Path>>(app_model_path: &P) -> Result<Vec<String>, String> { + let models = std::fs::read_dir(app_model_path) + .map_err(|err| err.to_string())? + .filter_map(|e| match e { + Ok(e) => { + if let (Some(name), Some(ext)) = (e.path().file_name(), e.path().extension()) { + if ext == "toml" { + name.to_str() + .map(|name| (name[..(name.len() - 5)]).to_string()) + } else { + None + } + } else { + None + } + } + Err(_) => None, + }) + .collect::<Vec<_>>(); + + Ok(models) +} + +/// Get the currently set branching model for a repo. +pub fn get_model_name(repository: &Repository, file_name: &str) -> Result<Option<String>, String> { + let mut config_path = PathBuf::from(repository.path()); + config_path.push(file_name); + + if config_path.exists() { + let repo_config: RepoSettings = + toml::from_str(&std::fs::read_to_string(config_path).map_err(|err| err.to_string())?) + .map_err(|err| err.to_string())?; + + Ok(Some(repo_config.model)) + } else { + Ok(None) + } +} + +/// Try to get the branch settings for a given model. +/// If no model name is given, returns the branch settings set for the repo, or the default otherwise. +pub fn get_model<P: AsRef<Path> + AsRef<OsStr>>( + repository: &Repository, + model: Option<&str>, + repo_config_file: &str, + app_model_path: &P, +) -> Result<BranchSettingsDef, String> { + match model { + Some(model) => read_model(model, app_model_path), + None => { + let mut config_path = PathBuf::from(repository.path()); + config_path.push(repo_config_file); + + if config_path.exists() { + let repo_config: RepoSettings = toml::from_str( + &std::fs::read_to_string(config_path).map_err(|err| err.to_string())?, + ) + .map_err(|err| err.to_string())?; + + read_model(&repo_config.model, app_model_path) + } else { + Ok(read_model("git-flow", app_model_path) + .unwrap_or_else(|_| BranchSettingsDef::git_flow())) + } + } + } +} + +/// Read a branching model file. +fn read_model<P: AsRef<Path> + AsRef<OsStr>>( + model: &str, + app_model_path: &P, +) -> Result<BranchSettingsDef, String> { + let mut model_file = PathBuf::from(&app_model_path); + model_file.push(format!("{}.toml", model)); + + if model_file.exists() { + toml::from_str::<BranchSettingsDef>( + &std::fs::read_to_string(model_file).map_err(|err| err.to_string())?, + ) + .map_err(|err| err.to_string()) + } else { + let models = get_available_models(&app_model_path)?; + let path: &Path = app_model_path.as_ref(); + Err(format!( + "ERROR: No branching model named '{}' found in {}\n Available models are: {}", + model, + path.display(), + itertools::join(models, ", ") + )) + } +} +/// Permanently sets the branching model for a repository +pub fn set_model<P: AsRef<Path>>( + repository: &Repository, + model: &str, + repo_config_file: &str, + app_model_path: &P, +) -> Result<(), String> { + let models = get_available_models(&app_model_path)?; + + if !models.contains(&model.to_string()) { + return Err(format!( + "ERROR: No branching model named '{}' found in {}\n Available models are: {}", + model, + app_model_path.as_ref().display(), + itertools::join(models, ", ") + )); + } + + let mut config_path = PathBuf::from(repository.path()); + config_path.push(repo_config_file); + + let config = RepoSettings { + model: model.to_string(), + }; + + let str = toml::to_string_pretty(&config).map_err(|err| err.to_string())?; + std::fs::write(&config_path, str).map_err(|err| err.to_string())?; + + eprint!("Branching model set to '{}'", model); + + Ok(()) +} diff --git a/git-graph/src/graph.rs b/git-graph/src/graph.rs new file mode 100644 index 0000000000..c658756f2f --- /dev/null +++ b/git-graph/src/graph.rs @@ -0,0 +1,984 @@ +//! A graph structure representing the history of a Git repository. + +use crate::print::colors::to_terminal_color; +use crate::settings::{BranchOrder, BranchSettings, MergePatterns, Settings}; +use git2::{BranchType, Commit, Error, Oid, Reference, Repository}; +use itertools::Itertools; +use regex::Regex; +use std::collections::{HashMap, HashSet}; + +const ORIGIN: &str = "origin/"; +const FORK: &str = "fork/"; + +/// Represents a git history graph. +pub struct GitGraph { + pub repository: Repository, + pub commits: Vec<CommitInfo>, + /// Mapping from commit id to index in `commits` + pub indices: HashMap<Oid, usize>, + /// All detected branches and tags, including merged and deleted + pub all_branches: Vec<BranchInfo>, + /// Indices of all real (still existing) branches in `all_branches` + pub branches: Vec<usize>, + /// Indices of all tags in `all_branches` + pub tags: Vec<usize>, + /// The current HEAD + pub head: HeadInfo, +} + +impl GitGraph { + pub fn new( + mut repository: Repository, + settings: &Settings, + max_count: Option<usize>, + ) -> Result<Self, String> { + let mut stashes = HashSet::new(); + repository + .stash_foreach(|_, _, oid| { + stashes.insert(*oid); + true + }) + .map_err(|err| err.message().to_string())?; + + let mut walk = repository + .revwalk() + .map_err(|err| err.message().to_string())?; + + walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) + .map_err(|err| err.message().to_string())?; + + walk.push_glob("*") + .map_err(|err| err.message().to_string())?; + + if repository.is_shallow() { + return Err("ERROR: git-graph does not support shallow clones due to a missing feature in the underlying libgit2 library.".to_string()); + } + + let head = HeadInfo::new(&repository.head().map_err(|err| err.message().to_string())?)?; + + let mut commits = Vec::new(); + let mut indices = HashMap::new(); + let mut idx = 0; + for oid in walk { + if let Some(max) = max_count { + if idx >= max { + break; + } + } + if let Ok(oid) = oid { + if !stashes.contains(&oid) { + let commit = repository.find_commit(oid).unwrap(); + + commits.push(CommitInfo::new(&commit)); + indices.insert(oid, idx); + idx += 1; + } + } + } + + assign_children(&mut commits, &indices); + + let mut all_branches = assign_branches(&repository, &mut commits, &indices, settings)?; + correct_fork_merges(&commits, &indices, &mut all_branches, settings)?; + assign_sources_targets(&commits, &indices, &mut all_branches); + + let (shortest_first, forward) = match settings.branch_order { + BranchOrder::ShortestFirst(fwd) => (true, fwd), + BranchOrder::LongestFirst(fwd) => (false, fwd), + }; + + assign_branch_columns( + &commits, + &indices, + &mut all_branches, + &settings.branches, + shortest_first, + forward, + ); + + let filtered_commits: Vec<CommitInfo> = commits + .into_iter() + .filter(|info| info.branch_trace.is_some()) + .collect(); + + let filtered_indices: HashMap<Oid, usize> = filtered_commits + .iter() + .enumerate() + .map(|(idx, info)| (info.oid, idx)) + .collect(); + + let index_map: HashMap<usize, Option<&usize>> = indices + .iter() + .map(|(oid, index)| (*index, filtered_indices.get(oid))) + .collect(); + + for branch in all_branches.iter_mut() { + if let Some(mut start_idx) = branch.range.0 { + let mut idx0 = index_map[&start_idx]; + while idx0.is_none() { + start_idx += 1; + idx0 = index_map[&start_idx]; + } + branch.range.0 = Some(*idx0.unwrap()); + } + if let Some(mut end_idx) = branch.range.1 { + let mut idx0 = index_map[&end_idx]; + while idx0.is_none() { + end_idx -= 1; + idx0 = index_map[&end_idx]; + } + branch.range.1 = Some(*idx0.unwrap()); + } + } + + let branches = all_branches + .iter() + .enumerate() + .filter_map(|(idx, br)| { + if !br.is_merged && !br.is_tag { + Some(idx) + } else { + None + } + }) + .collect(); + + let tags = all_branches + .iter() + .enumerate() + .filter_map(|(idx, br)| { + if !br.is_merged && br.is_tag { + Some(idx) + } else { + None + } + }) + .collect(); + + Ok(GitGraph { + repository, + commits: filtered_commits, + indices: filtered_indices, + all_branches, + branches, + tags, + head, + }) + } + + pub fn take_repository(self) -> Repository { + self.repository + } + + pub fn commit(&self, id: Oid) -> Result<Commit, Error> { + self.repository.find_commit(id) + } +} + +/// Information about the current HEAD +pub struct HeadInfo { + pub oid: Oid, + pub name: String, + pub is_branch: bool, +} +impl HeadInfo { + fn new(head: &Reference) -> Result<Self, String> { + let name = head.name().ok_or_else(|| "No name for HEAD".to_string())?; + let name = if name == "HEAD" { + name.to_string() + } else { + name[11..].to_string() + }; + + let h = HeadInfo { + oid: head.target().ok_or_else(|| "No id for HEAD".to_string())?, + name, + is_branch: head.is_branch(), + }; + Ok(h) + } +} + +/// Represents a commit. +pub struct CommitInfo { + pub oid: Oid, + pub is_merge: bool, + pub parents: [Option<Oid>; 2], + pub children: Vec<Oid>, + pub branches: Vec<usize>, + pub tags: Vec<usize>, + pub branch_trace: Option<usize>, +} + +impl CommitInfo { + fn new(commit: &Commit) -> Self { + CommitInfo { + oid: commit.id(), + is_merge: commit.parent_count() > 1, + parents: [commit.parent_id(0).ok(), commit.parent_id(1).ok()], + children: Vec::new(), + branches: Vec::new(), + tags: Vec::new(), + branch_trace: None, + } + } +} + +/// Represents a branch (real or derived from merge summary). +pub struct BranchInfo { + pub target: Oid, + pub merge_target: Option<Oid>, + pub source_branch: Option<usize>, + pub target_branch: Option<usize>, + pub name: String, + pub persistence: u8, + pub is_remote: bool, + pub is_merged: bool, + pub is_tag: bool, + pub visual: BranchVis, + pub range: (Option<usize>, Option<usize>), +} +impl BranchInfo { + #[allow(clippy::too_many_arguments)] + fn new( + target: Oid, + merge_target: Option<Oid>, + name: String, + persistence: u8, + is_remote: bool, + is_merged: bool, + is_tag: bool, + visual: BranchVis, + end_index: Option<usize>, + ) -> Self { + BranchInfo { + target, + merge_target, + target_branch: None, + source_branch: None, + name, + persistence, + is_remote, + is_merged, + is_tag, + visual, + range: (end_index, None), + } + } +} + +/// Branch properties for visualization. +pub struct BranchVis { + /// The branch's column group (left to right) + pub order_group: usize, + /// The branch's merge target column group (left to right) + pub target_order_group: Option<usize>, + /// The branch's source branch column group (left to right) + pub source_order_group: Option<usize>, + /// The branch's terminal color (index in 256-color palette) + pub term_color: u8, + /// SVG color (name or RGB in hex annotation) + pub svg_color: String, + /// The column the branch is located in + pub column: Option<usize>, +} + +impl BranchVis { + fn new(order_group: usize, term_color: u8, svg_color: String) -> Self { + BranchVis { + order_group, + target_order_group: None, + source_order_group: None, + term_color, + svg_color, + column: None, + } + } +} + +/// Walks through the commits and adds each commit's Oid to the children of its parents. +fn assign_children(commits: &mut [CommitInfo], indices: &HashMap<Oid, usize>) { + for idx in 0..commits.len() { + let (oid, parents) = { + let info = &commits[idx]; + (info.oid, info.parents) + }; + for par_oid in &parents { + if let Some(par_idx) = par_oid.and_then(|oid| indices.get(&oid)) { + commits[*par_idx].children.push(oid); + } + } + } +} + +/// Extracts branches from repository and merge summaries, assigns branches and branch traces to commits. +/// +/// Algorithm: +/// * Find all actual branches (incl. target oid) and all extract branches from merge summaries (incl. parent oid) +/// * Sort all branches by persistence +/// * Iterating over all branches in persistence order, trace back over commit parents until a trace is already assigned +fn assign_branches( + repository: &Repository, + commits: &mut [CommitInfo], + indices: &HashMap<Oid, usize>, + settings: &Settings, +) -> Result<Vec<BranchInfo>, String> { + let mut branch_idx = 0; + + let mut branches = extract_branches(repository, commits, indices, settings)?; + + let mut index_map: Vec<_> = (0..branches.len()) + .map(|old_idx| { + let (target, is_tag, is_merged) = { + let branch = &branches[old_idx]; + (branch.target, branch.is_tag, branch.is_merged) + }; + if let Some(&idx) = &indices.get(&target) { + let info = &mut commits[idx]; + if is_tag { + info.tags.push(old_idx); + } else if !is_merged { + info.branches.push(old_idx); + } + let oid = info.oid; + let any_assigned = + trace_branch(repository, commits, indices, &mut branches, oid, old_idx) + .unwrap_or(false); + + if any_assigned || !is_merged { + branch_idx += 1; + Some(branch_idx - 1) + } else { + None + } + } else { + None + } + }) + .collect(); + + let mut commit_count = vec![0; branches.len()]; + for info in commits.iter_mut() { + if let Some(trace) = info.branch_trace { + commit_count[trace] += 1; + } + } + + let mut count_skipped = 0; + for (idx, branch) in branches.iter().enumerate() { + if let Some(mapped) = index_map[idx] { + if commit_count[idx] == 0 && branch.is_merged && !branch.is_tag { + index_map[idx] = None; + count_skipped += 1; + } else { + index_map[idx] = Some(mapped - count_skipped); + } + } + } + + for info in commits.iter_mut() { + if let Some(trace) = info.branch_trace { + info.branch_trace = index_map[trace]; + for br in info.branches.iter_mut() { + *br = index_map[*br].unwrap(); + } + for tag in info.tags.iter_mut() { + *tag = index_map[*tag].unwrap(); + } + } + } + + let branches: Vec<_> = branches + .into_iter() + .enumerate() + .filter_map(|(arr_index, branch)| { + if index_map[arr_index].is_some() { + Some(branch) + } else { + None + } + }) + .collect(); + + Ok(branches) +} + +fn correct_fork_merges( + commits: &[CommitInfo], + indices: &HashMap<Oid, usize>, + branches: &mut [BranchInfo], + settings: &Settings, +) -> Result<(), String> { + for idx in 0..branches.len() { + if let Some(merge_target) = branches[idx] + .merge_target + .and_then(|oid| indices.get(&oid)) + .and_then(|idx| commits.get(*idx)) + .and_then(|info| info.branch_trace) + .and_then(|trace| branches.get(trace)) + { + if branches[idx].name == merge_target.name { + let name = format!("{}{}", FORK, branches[idx].name); + let term_col = to_terminal_color( + &branch_color( + &name, + &settings.branches.terminal_colors[..], + &settings.branches.terminal_colors_unknown, + idx, + )[..], + )?; + let pos = branch_order(&name, &settings.branches.order); + let svg_col = branch_color( + &name, + &settings.branches.svg_colors, + &settings.branches.svg_colors_unknown, + idx, + ); + + branches[idx].name = format!("{}{}", FORK, branches[idx].name); + branches[idx].visual.order_group = pos; + branches[idx].visual.term_color = term_col; + branches[idx].visual.svg_color = svg_col; + } + } + } + Ok(()) +} +fn assign_sources_targets( + commits: &[CommitInfo], + indices: &HashMap<Oid, usize>, + branches: &mut [BranchInfo], +) { + for idx in 0..branches.len() { + let target_branch_idx = branches[idx] + .merge_target + .and_then(|oid| indices.get(&oid)) + .and_then(|idx| commits.get(*idx)) + .and_then(|info| info.branch_trace); + + branches[idx].target_branch = target_branch_idx; + + let group = target_branch_idx + .and_then(|trace| branches.get(trace)) + .map(|br| br.visual.order_group); + + branches[idx].visual.target_order_group = group; + } + for info in commits { + let mut max_par_order = None; + let mut source_branch_id = None; + for par_oid in info.parents.iter() { + let par_info = par_oid + .and_then(|oid| indices.get(&oid)) + .and_then(|idx| commits.get(*idx)); + if let Some(par_info) = par_info { + if par_info.branch_trace != info.branch_trace { + if let Some(trace) = par_info.branch_trace { + source_branch_id = Some(trace); + } + + let group = par_info + .branch_trace + .and_then(|trace| branches.get(trace)) + .map(|br| br.visual.order_group); + if let Some(gr) = max_par_order { + if let Some(p_group) = group { + if p_group > gr { + max_par_order = group; + } + } + } else { + max_par_order = group; + } + } + } + } + let branch = info.branch_trace.and_then(|trace| branches.get_mut(trace)); + if let Some(branch) = branch { + if let Some(order) = max_par_order { + branch.visual.source_order_group = Some(order); + } + if let Some(source_id) = source_branch_id { + branch.source_branch = Some(source_id); + } + } + } +} + +/// Extracts (real or derived from merge summary) and assigns basic properties. +fn extract_branches( + repository: &Repository, + commits: &[CommitInfo], + indices: &HashMap<Oid, usize>, + settings: &Settings, +) -> Result<Vec<BranchInfo>, String> { + let filter = if settings.include_remote { + None + } else { + Some(BranchType::Local) + }; + let actual_branches = repository + .branches(filter) + .map_err(|err| err.message().to_string())? + .collect::<Result<Vec<_>, Error>>() + .map_err(|err| err.message().to_string())?; + + let mut counter = 0; + + let mut valid_branches = actual_branches + .iter() + .filter_map(|(br, tp)| { + br.get().name().and_then(|n| { + br.get().target().map(|t| { + counter += 1; + let start_index = match tp { + BranchType::Local => 11, + BranchType::Remote => 13, + }; + let name = &n[start_index..]; + let end_index = indices.get(&t).cloned(); + + let term_color = match to_terminal_color( + &branch_color( + name, + &settings.branches.terminal_colors[..], + &settings.branches.terminal_colors_unknown, + counter, + )[..], + ) { + Ok(col) => col, + Err(err) => return Err(err), + }; + + Ok(BranchInfo::new( + t, + None, + name.to_string(), + branch_order(name, &settings.branches.persistence) as u8, + &BranchType::Remote == tp, + false, + false, + BranchVis::new( + branch_order(name, &settings.branches.order), + term_color, + branch_color( + name, + &settings.branches.svg_colors, + &settings.branches.svg_colors_unknown, + counter, + ), + ), + end_index, + )) + }) + }) + }) + .collect::<Result<Vec<_>, String>>()?; + + for (idx, info) in commits.iter().enumerate() { + let commit = repository + .find_commit(info.oid) + .map_err(|err| err.message().to_string())?; + if info.is_merge { + if let Some(summary) = commit.summary() { + counter += 1; + + let parent_oid = commit + .parent_id(1) + .map_err(|err| err.message().to_string())?; + + let branch_name = parse_merge_summary(summary, &settings.merge_patterns) + .unwrap_or_else(|| "unknown".to_string()); + + let persistence = branch_order(&branch_name, &settings.branches.persistence) as u8; + + let pos = branch_order(&branch_name, &settings.branches.order); + + let term_col = to_terminal_color( + &branch_color( + &branch_name, + &settings.branches.terminal_colors[..], + &settings.branches.terminal_colors_unknown, + counter, + )[..], + )?; + let svg_col = branch_color( + &branch_name, + &settings.branches.svg_colors, + &settings.branches.svg_colors_unknown, + counter, + ); + + let branch_info = BranchInfo::new( + parent_oid, + Some(info.oid), + branch_name, + persistence, + false, + true, + false, + BranchVis::new(pos, term_col, svg_col), + Some(idx + 1), + ); + valid_branches.push(branch_info); + } + } + } + + valid_branches.sort_by_cached_key(|branch| (branch.persistence, !branch.is_merged)); + + let mut tags = Vec::new(); + + repository + .tag_foreach(|oid, name| { + tags.push((oid, name.to_vec())); + true + }) + .map_err(|err| err.message().to_string())?; + + for (oid, name) in tags { + let name = std::str::from_utf8(&name[5..]).map_err(|err| err.to_string())?; + + let target = repository + .find_tag(oid) + .map(|tag| tag.target_id()) + .or_else(|_| repository.find_commit(oid).map(|_| oid)); + + if let Ok(target_oid) = target { + if let Some(target_index) = indices.get(&target_oid) { + counter += 1; + let term_col = to_terminal_color( + &branch_color( + name, + &settings.branches.terminal_colors[..], + &settings.branches.terminal_colors_unknown, + counter, + )[..], + )?; + let pos = branch_order(name, &settings.branches.order); + let svg_col = branch_color( + name, + &settings.branches.svg_colors, + &settings.branches.svg_colors_unknown, + counter, + ); + let tag_info = BranchInfo::new( + target_oid, + None, + name.to_string(), + settings.branches.persistence.len() as u8 + 1, + false, + false, + true, + BranchVis::new(pos, term_col, svg_col), + Some(*target_index), + ); + valid_branches.push(tag_info); + } + } + } + + Ok(valid_branches) +} + +/// Traces back branches by following 1st commit parent, +/// until a commit is reached that already has a trace. +fn trace_branch( + repository: &Repository, + commits: &mut [CommitInfo], + indices: &HashMap<Oid, usize>, + branches: &mut [BranchInfo], + oid: Oid, + branch_index: usize, +) -> Result<bool, Error> { + let mut curr_oid = oid; + let mut prev_index: Option<usize> = None; + let mut start_index: Option<i32> = None; + let mut any_assigned = false; + while let Some(index) = indices.get(&curr_oid) { + let info = &mut commits[*index]; + if let Some(old_trace) = info.branch_trace { + let (old_name, old_term, old_svg, old_range) = { + let old_branch = &branches[old_trace]; + ( + &old_branch.name.clone(), + old_branch.visual.term_color, + old_branch.visual.svg_color.clone(), + old_branch.range, + ) + }; + let new_name = &branches[branch_index].name; + let old_end = old_range.0.unwrap_or(0); + let new_end = branches[branch_index].range.0.unwrap_or(0); + if new_name == old_name && old_end >= new_end { + let old_branch = &mut branches[old_trace]; + if let Some(old_end) = old_range.1 { + if index > &old_end { + old_branch.range = (None, None); + } else { + old_branch.range = (Some(*index), old_branch.range.1); + } + } else { + old_branch.range = (Some(*index), old_branch.range.1); + } + } else { + let branch = &mut branches[branch_index]; + if branch.name.starts_with(ORIGIN) && branch.name[7..] == old_name[..] { + branch.visual.term_color = old_term; + branch.visual.svg_color = old_svg; + } + match prev_index { + None => start_index = Some(*index as i32 - 1), + Some(prev_index) => { + // TODO: in cases where no crossings occur, the rule for merge commits can also be applied to normal commits + // see also print::get_deviate_index() + if commits[prev_index].is_merge { + let mut temp_index = prev_index; + for sibling_oid in &commits[*index].children { + if sibling_oid != &curr_oid { + let sibling_index = indices[sibling_oid]; + if sibling_index > temp_index { + temp_index = sibling_index; + } + } + } + start_index = Some(temp_index as i32); + } else { + start_index = Some(*index as i32 - 1); + } + } + } + break; + } + } + + info.branch_trace = Some(branch_index); + any_assigned = true; + + let commit = repository.find_commit(curr_oid)?; + match commit.parent_count() { + 0 => { + start_index = Some(*index as i32); + break; + } + _ => { + prev_index = Some(*index); + curr_oid = commit.parent_id(0)?; + } + } + } + + let branch = &mut branches[branch_index]; + if let Some(end) = branch.range.0 { + if let Some(start_index) = start_index { + if start_index < end as i32 { + // TODO: find a better solution (bool field?) to identify non-deleted branches that were not assigned to any commits, and thus should not occupy a column. + branch.range = (None, None); + } else { + branch.range = (branch.range.0, Some(start_index as usize)); + } + } else { + branch.range = (branch.range.0, None); + } + } else { + branch.range = (branch.range.0, start_index.map(|si| si as usize)); + } + Ok(any_assigned) +} + +/// Sorts branches into columns for visualization, that all branches can be +/// visualizes linearly and without overlaps. Uses Shortest-First scheduling. +fn assign_branch_columns( + commits: &[CommitInfo], + indices: &HashMap<Oid, usize>, + branches: &mut [BranchInfo], + settings: &BranchSettings, + shortest_first: bool, + forward: bool, +) { + let mut occupied: Vec<Vec<Vec<(usize, usize)>>> = vec![vec![]; settings.order.len() + 1]; + + let length_sort_factor = if shortest_first { 1 } else { -1 }; + let start_sort_factor = if forward { 1 } else { -1 }; + + let mut branches_sort: Vec<_> = branches + .iter() + .enumerate() + .filter(|(_idx, br)| br.range.0.is_some() || br.range.1.is_some()) + .map(|(idx, br)| { + ( + idx, + br.range.0.unwrap_or(0), + br.range.1.unwrap_or(branches.len() - 1), + br.visual + .source_order_group + .unwrap_or(settings.order.len() + 1), + br.visual + .target_order_group + .unwrap_or(settings.order.len() + 1), + ) + }) + .collect(); + + branches_sort.sort_by_cached_key(|tup| { + ( + std::cmp::max(tup.3, tup.4), + (tup.2 as i32 - tup.1 as i32) * length_sort_factor, + tup.1 as i32 * start_sort_factor, + ) + }); + + for (branch_idx, start, end, _, _) in branches_sort { + let branch = &branches[branch_idx]; + let group = branch.visual.order_group; + let group_occ = &mut occupied[group]; + + let align_right = branch + .source_branch + .map(|src| branches[src].visual.order_group > branch.visual.order_group) + .unwrap_or(false) + || branch + .target_branch + .map(|trg| branches[trg].visual.order_group > branch.visual.order_group) + .unwrap_or(false); + + let len = group_occ.len(); + let mut found = len; + for i in 0..len { + let index = if align_right { len - i - 1 } else { i }; + let column_occ = &group_occ[index]; + let mut occ = false; + for (s, e) in column_occ { + if start <= *e && end >= *s { + occ = true; + break; + } + } + if !occ { + if let Some(merge_trace) = branch + .merge_target + .and_then(|t| indices.get(&t)) + .and_then(|t_idx| commits[*t_idx].branch_trace) + { + let merge_branch = &branches[merge_trace]; + if merge_branch.visual.order_group == branch.visual.order_group { + if let Some(merge_column) = merge_branch.visual.column { + if merge_column == index { + occ = true; + } + } + } + } + } + if !occ { + found = index; + break; + } + } + + let branch = &mut branches[branch_idx]; + branch.visual.column = Some(found); + if found == group_occ.len() { + group_occ.push(vec![]); + } + group_occ[found].push((start, end)); + } + + let group_offset: Vec<usize> = occupied + .iter() + .scan(0, |acc, group| { + *acc += group.len(); + Some(*acc) + }) + .collect(); + + for branch in branches { + if let Some(column) = branch.visual.column { + let offset = if branch.visual.order_group == 0 { + 0 + } else { + group_offset[branch.visual.order_group - 1] + }; + branch.visual.column = Some(column + offset); + } + } +} + +/// Finds the index for a branch name from a slice of prefixes +fn branch_order(name: &str, order: &[Regex]) -> usize { + order + .iter() + .position(|b| (name.starts_with(ORIGIN) && b.is_match(&name[7..])) || b.is_match(name)) + .unwrap_or(order.len()) +} + +/// Finds the svg color for a branch name. +fn branch_color<T: Clone>( + name: &str, + order: &[(Regex, Vec<T>)], + unknown: &[T], + counter: usize, +) -> T { + let color = order + .iter() + .find_position(|(b, _)| { + (name.starts_with(ORIGIN) && b.is_match(&name[7..])) || b.is_match(name) + }) + .map(|(_pos, col)| &col.1[counter % col.1.len()]) + .unwrap_or_else(|| &unknown[counter % unknown.len()]); + color.clone() +} + +/// Tries to extract the name of a merged-in branch from the merge commit summary. +pub fn parse_merge_summary(summary: &str, patterns: &MergePatterns) -> Option<String> { + for regex in &patterns.patterns { + if let Some(captures) = regex.captures(summary) { + if captures.len() == 2 && captures.get(1).is_some() { + return captures.get(1).map(|m| m.as_str().to_string()); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use crate::settings::MergePatterns; + + #[test] + fn parse_merge_summary() { + let patterns = MergePatterns::default(); + + let gitlab_pull = "Merge branch 'feature/my-feature' into 'master'"; + let git_default = "Merge branch 'feature/my-feature' into dev"; + let git_master = "Merge branch 'feature/my-feature'"; + let github_pull = "Merge pull request #1 from user-x/feature/my-feature"; + let github_pull_2 = "Merge branch 'feature/my-feature' of github.com:user-x/repo"; + let bitbucket_pull = "Merged in feature/my-feature (pull request #1)"; + + assert_eq!( + super::parse_merge_summary(gitlab_pull, &patterns), + Some("feature/my-feature".to_string()), + ); + assert_eq!( + super::parse_merge_summary(git_default, &patterns), + Some("feature/my-feature".to_string()), + ); + assert_eq!( + super::parse_merge_summary(git_master, &patterns), + Some("feature/my-feature".to_string()), + ); + assert_eq!( + super::parse_merge_summary(github_pull, &patterns), + Some("feature/my-feature".to_string()), + ); + assert_eq!( + super::parse_merge_summary(github_pull_2, &patterns), + Some("feature/my-feature".to_string()), + ); + assert_eq!( + super::parse_merge_summary(bitbucket_pull, &patterns), + Some("feature/my-feature".to_string()), + ); + } +} diff --git a/git-graph/src/lib.rs b/git-graph/src/lib.rs new file mode 100644 index 0000000000..7372939d8d --- /dev/null +++ b/git-graph/src/lib.rs @@ -0,0 +1,13 @@ +//! Command line tool to show clear git graphs arranged for your branching model. + +use git2::Repository; +use std::path::Path; + +pub mod config; +pub mod graph; +pub mod print; +pub mod settings; + +pub fn get_repo<P: AsRef<Path>>(path: P) -> Result<Repository, git2::Error> { + Repository::discover(path) +} diff --git a/git-graph/src/main.rs b/git-graph/src/main.rs new file mode 100644 index 0000000000..6c1fbfc06c --- /dev/null +++ b/git-graph/src/main.rs @@ -0,0 +1,539 @@ +use clap::{crate_version, Arg, Command}; +use crossterm::cursor::MoveToColumn; +use crossterm::event::{Event, KeyCode, KeyModifiers}; +use crossterm::style::Print; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; +use crossterm::{ErrorKind, ExecutableCommand}; +use git2::Repository; +use git_graph::config::{ + create_config, get_available_models, get_model, get_model_name, set_model, +}; +use git_graph::get_repo; +use git_graph::graph::GitGraph; +use git_graph::print::format::CommitFormat; +use git_graph::print::svg::print_svg; +use git_graph::print::unicode::print_unicode; +use git_graph::settings::{BranchOrder, BranchSettings, Characters, MergePatterns, Settings}; +use platform_dirs::AppDirs; +use std::io::stdout; +use std::str::FromStr; +use std::time::Instant; + +const REPO_CONFIG_FILE: &str = "git-graph.toml"; + +fn main() { + std::process::exit(match from_args() { + Ok(_) => 0, + Err(err) => { + eprintln!("{}", err); + 1 + } + }); +} + +fn from_args() -> Result<(), String> { + let app_dir = AppDirs::new(Some("git-graph"), false).unwrap().config_dir; + let mut models_dir = app_dir; + models_dir.push("models"); + + create_config(&models_dir)?; + + let app = Command::new("git-graph") + .version(crate_version!()) + .about( + "Structured Git graphs for your branching model.\n \ + https://github.com/mlange-42/git-graph\n\ + \n\ + EXAMPES:\n \ + git-graph -> Show graph\n \ + git-graph --style round -> Show graph in a different style\n \ + git-graph --model <model> -> Show graph using a certain <model>\n \ + git-graph model --list -> List available branching models\n \ + git-graph model -> Show repo's current branching models\n \ + git-graph model <model> -> Permanently set model <model> for this repo", + ) + .arg( + Arg::new("reverse") + .long("reverse") + .short('r') + .help("Reverse the order of commits.") + .required(false) + .num_args(0), + ) + .arg( + Arg::new("path") + .long("path") + .short('p') + .help("Open repository from this path or above. Default '.'") + .required(false) + .num_args(1), + ) + .arg( + Arg::new("max-count") + .long("max-count") + .short('n') + .help("Maximum number of commits") + .required(false) + .num_args(1) + .value_name("n"), + ) + .arg( + Arg::new("model") + .long("model") + .short('m') + .help("Branching model. Available presets are [simple|git-flow|none].\n\ + Default: git-flow. \n\ + Permanently set the model for a repository with\n\ + > git-graph model <model>") + .required(false) + .num_args(1), + ) + .arg( + Arg::new("local") + .long("local") + .short('l') + .help("Show only local branches, no remotes.") + .required(false) + .num_args(0), + ) + .arg( + Arg::new("svg") + .long("svg") + .help("Render graph as SVG instead of text-based.") + .required(false) + .num_args(0), + ) + .arg( + Arg::new("debug") + .long("debug") + .short('d') + .help("Additional debug output and graphics.") + .required(false) + .num_args(0), + ) + .arg( + Arg::new("sparse") + .long("sparse") + .short('S') + .help("Print a less compact graph: merge lines point to target lines\n\ + rather than merge commits.") + .required(false) + .num_args(0), + ) + .arg( + Arg::new("color") + .long("color") + .help("Specify when colors should be used. One of [auto|always|never].\n\ + Default: auto.") + .required(false) + .num_args(1), + ) + .arg( + Arg::new("no-color") + .long("no-color") + .help("Print without colors. Missing color support should be detected\n\ + automatically (e.g. when piping to a file).\n\ + Overrides option '--color'") + .required(false) + .num_args(0), + ) + .arg( + Arg::new("no-pager") + .long("no-pager") + .help("Use no pager (print everything at once without prompt).") + .required(false) + .num_args(0), + ) + .arg( + Arg::new("style") + .long("style") + .short('s') + .help("Output style. One of [normal/thin|round|bold|double|ascii].\n \ + (First character can be used as abbreviation, e.g. '-s r')") + .required(false) + .num_args(1), + ) + .arg( + Arg::new("wrap") + .long("wrap") + .short('w') + .help("Line wrapping for formatted commit text. Default: 'auto 0 8'\n\ + Argument format: [<width>|auto|none[ <indent1>[ <indent2>]]]\n\ + For examples, consult 'git-graph --help'") + .long_help("Line wrapping for formatted commit text. Default: 'auto 0 8'\n\ + Argument format: [<width>|auto|none[ <indent1>[ <indent2>]]]\n\ + Examples:\n \ + git-graph --wrap auto\n \ + git-graph --wrap auto 0 8\n \ + git-graph --wrap none\n \ + git-graph --wrap 80\n \ + git-graph --wrap 80 0 8\n\ + 'auto' uses the terminal's width if on a terminal.") + .required(false) + .num_args(0..=3), + ) + .arg( + Arg::new("format") + .long("format") + .short('f') + .help("Commit format. One of [oneline|short|medium|full|\"<string>\"].\n \ + (First character can be used as abbreviation, e.g. '-f m')\n\ + Default: oneline.\n\ + For placeholders supported in \"<string>\", consult 'git-graph --help'") + .long_help("Commit format. One of [oneline|short|medium|full|\"<string>\"].\n \ + (First character can be used as abbreviation, e.g. '-f m')\n\ + Formatting placeholders for \"<string>\":\n \ + %n newline\n \ + %H commit hash\n \ + %h abbreviated commit hash\n \ + %P parent commit hashes\n \ + %p abbreviated parent commit hashes\n \ + %d refs (branches, tags)\n \ + %s commit summary\n \ + %b commit message body\n \ + %B raw body (subject and body)\n \ + %an author name\n \ + %ae author email\n \ + %ad author date\n \ + %as author date in short format 'YYYY-MM-DD'\n \ + %cn committer name\n \ + %ce committer email\n \ + %cd committer date\n \ + %cs committer date in short format 'YYYY-MM-DD'\n \ + \n \ + If you add a + (plus sign) after % of a placeholder,\n \ + a line-feed is inserted immediately before the expansion if\n \ + and only if the placeholder expands to a non-empty string.\n \ + If you add a - (minus sign) after % of a placeholder, all\n \ + consecutive line-feeds immediately preceding the expansion are\n \ + deleted if and only if the placeholder expands to an empty string.\n \ + If you add a ' ' (space) after % of a placeholder, a space is\n \ + inserted immediately before the expansion if and only if\n \ + the placeholder expands to a non-empty string.\n\ + \n \ + See also the respective git help: https://git-scm.com/docs/pretty-formats\n") + .required(false) + .num_args(1), + ) + .subcommand(Command::new("model") + .about("Prints or permanently sets the branching model for a repository.") + .arg( + Arg::new("model") + .help("The branching model to be used. Available presets are [simple|git-flow|none].\n\ + When not given, prints the currently set model.") + .value_name("model") + .num_args(1) + .required(false) + .index(1)) + .arg( + Arg::new("list") + .long("list") + .short('l') + .help("List all available branching models.") + .required(false) + .num_args(0), + )); + + let matches = app.get_matches(); + + if let Some(matches) = matches.subcommand_matches("model") { + if matches.get_flag("list") { + println!( + "{}", + itertools::join(get_available_models(&models_dir)?, "\n") + ); + return Ok(()); + } + } + + let dot = ".".to_string(); + let path = matches.get_one::<String>("path").unwrap_or(&dot); + let repository = get_repo(path) + .map_err(|err| format!("ERROR: {}\n Navigate into a repository before running git-graph, or use option --path", err.message()))?; + + if let Some(matches) = matches.subcommand_matches("model") { + match matches.get_one::<String>("model") { + None => { + let curr_model = get_model_name(&repository, REPO_CONFIG_FILE)?; + match curr_model { + None => print!("No branching model set"), + Some(model) => print!("{}", model), + } + } + Some(model) => set_model(&repository, model, REPO_CONFIG_FILE, &models_dir)?, + }; + return Ok(()); + } + + let commit_limit = match matches.get_one::<String>("max-count") { + None => None, + Some(str) => match str.parse::<usize>() { + Ok(val) => Some(val), + Err(_) => { + return Err(format![ + "Option max-count must be a positive number, but got '{}'", + str + ]) + } + }, + }; + + let include_remote = !matches.get_flag("local"); + + let reverse_commit_order = matches.get_flag("reverse"); + + let svg = matches.get_flag("svg"); + let pager = !matches.get_flag("no-pager"); + let compact = !matches.get_flag("sparse"); + let debug = matches.get_flag("debug"); + let style = matches + .get_one::<String>("style") + .map(|s| Characters::from_str(s)) + .unwrap_or_else(|| Ok(Characters::thin()))?; + + let style = if reverse_commit_order { + style.reverse() + } else { + style + }; + + let model = get_model( + &repository, + matches.get_one::<String>("model").map(|s| &s[..]), + REPO_CONFIG_FILE, + &models_dir, + )?; + + let format = match matches.get_one::<String>("format") { + None => CommitFormat::OneLine, + Some(str) => CommitFormat::from_str(str)?, + }; + + let colored = if matches.get_flag("no-color") { + false + } else if let Some(mode) = matches.get_one::<String>("color") { + match &mode[..] { + "auto" => { + atty::is(atty::Stream::Stdout) + && (!cfg!(windows) || yansi::Paint::enable_windows_ascii()) + } + "always" => { + if cfg!(windows) { + yansi::Paint::enable_windows_ascii(); + } + true + } + "never" => false, + other => { + return Err(format!( + "Unknown color mode '{}'. Supports [auto|always|never].", + other + )) + } + } + } else { + atty::is(atty::Stream::Stdout) && (!cfg!(windows) || yansi::Paint::enable_windows_ascii()) + }; + + let wrapping = if let Some(wrap_values) = matches.get_many::<String>("wrap") { + let strings = wrap_values.map(|s| s.as_str()).collect::<Vec<_>>(); + if strings.is_empty() { + Some((None, Some(0), Some(8))) + } else { + match strings[0] { + "none" => None, + "auto" => { + let wrap = strings + .iter() + .skip(1) + .map(|str| str.parse::<usize>()) + .collect::<Result<Vec<_>, _>>() + .map_err(|_| { + format!( + "ERROR: Can't parse option --wrap '{}' to integers.", + strings.join(" ") + ) + })?; + Some((None, wrap.first().cloned(), wrap.get(1).cloned())) + } + _ => { + let wrap = strings + .iter() + .map(|str| str.parse::<usize>()) + .collect::<Result<Vec<_>, _>>() + .map_err(|_| { + format!( + "ERROR: Can't parse option --wrap '{}' to integers.", + strings.join(" ") + ) + })?; + Some(( + wrap.first().cloned(), + wrap.get(1).cloned(), + wrap.get(2).cloned(), + )) + } + } + } + } else { + Some((None, Some(0), Some(8))) + }; + + let settings = Settings { + reverse_commit_order, + debug, + colored, + compact, + include_remote, + format, + wrapping, + characters: style, + branch_order: BranchOrder::ShortestFirst(true), + branches: BranchSettings::from(model).map_err(|err| err.to_string())?, + merge_patterns: MergePatterns::default(), + }; + + run(repository, &settings, svg, commit_limit, pager) +} + +fn run( + repository: Repository, + settings: &Settings, + svg: bool, + max_commits: Option<usize>, + pager: bool, +) -> Result<(), String> { + let now = Instant::now(); + let graph = GitGraph::new(repository, settings, max_commits)?; + + let duration_graph = now.elapsed().as_micros(); + + if settings.debug { + for branch in &graph.all_branches { + eprintln!( + "{} (col {}) ({:?}) {} s: {:?}, t: {:?}", + branch.name, + branch.visual.column.unwrap_or(99), + branch.range, + if branch.is_merged { "m" } else { "" }, + branch.visual.source_order_group, + branch.visual.target_order_group + ); + } + } + + let now = Instant::now(); + + if svg { + println!("{}", print_svg(&graph, settings)?); + } else { + let (g_lines, t_lines, _indices) = print_unicode(&graph, settings)?; + if pager && atty::is(atty::Stream::Stdout) { + print_paged(&g_lines, &t_lines).map_err(|err| err.to_string())?; + } else { + print_unpaged(&g_lines, &t_lines); + } + }; + + let duration_print = now.elapsed().as_micros(); + + if settings.debug { + eprintln!( + "Graph construction: {:.1} ms, printing: {:.1} ms ({} commits)", + duration_graph as f32 / 1000.0, + duration_print as f32 / 1000.0, + graph.commits.len() + ); + } + Ok(()) +} + +/// Print the graph, paged (i.e. wait for user input once the terminal is filled). +fn print_paged(graph_lines: &[String], text_lines: &[String]) -> Result<(), ErrorKind> { + let (width, height) = crossterm::terminal::size()?; + let width = width as usize; + + let mut line_idx = 0; + let mut print_lines = height - 2; + let mut clear = false; + let mut abort = false; + + let help = "\r >>> Down: line, PgDown/Enter: page, End: all, Esc/Q/^C: quit\r"; + let help = if help.len() > width { + &help[0..width] + } else { + help + }; + + while line_idx < graph_lines.len() { + if print_lines > 0 { + if clear { + stdout() + .execute(Clear(ClearType::CurrentLine))? + .execute(MoveToColumn(0))?; + } + + stdout().execute(Print(format!( + " {} {}\n", + graph_lines[line_idx], text_lines[line_idx] + )))?; + + if print_lines == 1 && line_idx < graph_lines.len() - 1 { + stdout().execute(Print(help))?; + } + print_lines -= 1; + line_idx += 1; + } else { + enable_raw_mode()?; + let input = crossterm::event::read()?; + if let Event::Key(evt) = input { + match evt.code { + KeyCode::Down => { + clear = true; + print_lines = 1; + } + KeyCode::Enter | KeyCode::PageDown => { + clear = true; + print_lines = height - 2; + } + KeyCode::End => { + clear = true; + print_lines = graph_lines.len() as u16; + } + KeyCode::Char(c) => match c { + 'q' => { + abort = true; + break; + } + 'c' if evt.modifiers == KeyModifiers::CONTROL => { + abort = true; + break; + } + _ => {} + }, + KeyCode::Esc => { + abort = true; + break; + } + _ => {} + } + } + } + } + if abort { + stdout() + .execute(Clear(ClearType::CurrentLine))? + .execute(MoveToColumn(0))? + .execute(Print(" ...\n"))?; + } + disable_raw_mode()?; + + Ok(()) +} + +/// Print the graph, un-paged. +fn print_unpaged(graph_lines: &[String], text_lines: &[String]) { + for (g_line, t_line) in graph_lines.iter().zip(text_lines.iter()) { + println!(" {} {}", g_line, t_line); + } +} diff --git a/git-graph/src/print/colors.rs b/git-graph/src/print/colors.rs new file mode 100644 index 0000000000..bc8e11e707 --- /dev/null +++ b/git-graph/src/print/colors.rs @@ -0,0 +1,45 @@ +//! ANSI terminal color handling. + +use lazy_static::lazy_static; +use std::collections::HashMap; + +/// Converts a color name to the index in the 256-color palette. +pub fn to_terminal_color(color: &str) -> Result<u8, String> { + match NAMED_COLORS.get(color) { + None => match color.parse::<u8>() { + Ok(col) => Ok(col), + Err(_) => Err(format!("Color {} not found", color)), + }, + Some(rgb) => Ok(*rgb), + } +} + +macro_rules! hashmap { + ($( $key: expr => $val: expr ),*) => {{ + let mut map = ::std::collections::HashMap::new(); + $( map.insert($key, $val); )* + map + }} +} + +lazy_static! { + /// Named ANSI colors + pub static ref NAMED_COLORS: HashMap<&'static str, u8> = hashmap![ + "black" => 0, + "red" => 1, + "green" => 2, + "yellow" => 3, + "blue" => 4, + "magenta" => 5, + "cyan" => 6, + "white" => 7, + "bright_black" => 8, + "bright_red" => 9, + "bright_green" => 10, + "bright_yellow" => 11, + "bright_blue" => 12, + "bright_magenta" => 13, + "bright_cyan" => 14, + "bright_white" => 15 + ]; +} diff --git a/git-graph/src/print/format.rs b/git-graph/src/print/format.rs new file mode 100644 index 0000000000..0ca93206cc --- /dev/null +++ b/git-graph/src/print/format.rs @@ -0,0 +1,548 @@ +//! Formatting of commits. + +use chrono::{FixedOffset, Local, TimeZone}; +use git2::{Commit, Time}; +use lazy_static::lazy_static; +use std::fmt::Write; +use std::str::FromStr; +use textwrap::Options; +use yansi::Paint; + +/// Commit formatting options. +#[derive(Ord, PartialOrd, Eq, PartialEq)] +pub enum CommitFormat { + OneLine, + Short, + Medium, + Full, + Format(String), +} + +impl FromStr for CommitFormat { + type Err = String; + + fn from_str(str: &str) -> Result<Self, Self::Err> { + match str { + "oneline" | "o" => Ok(CommitFormat::OneLine), + "short" | "s" => Ok(CommitFormat::Short), + "medium" | "m" => Ok(CommitFormat::Medium), + "full" | "f" => Ok(CommitFormat::Full), + str => Ok(CommitFormat::Format(str.to_string())), + } + } +} + +const NEW_LINE: usize = 0; +const HASH: usize = 1; +const HASH_ABBREV: usize = 2; +const PARENT_HASHES: usize = 3; +const PARENT_HASHES_ABBREV: usize = 4; +const REFS: usize = 5; +const SUBJECT: usize = 6; +const AUTHOR: usize = 7; +const AUTHOR_EMAIL: usize = 8; +const AUTHOR_DATE: usize = 9; +const AUTHOR_DATE_SHORT: usize = 10; +const COMMITTER: usize = 11; +const COMMITTER_EMAIL: usize = 12; +const COMMITTER_DATE: usize = 13; +const COMMITTER_DATE_SHORT: usize = 14; +const BODY: usize = 15; +const BODY_RAW: usize = 16; + +const MODE_SPACE: usize = 1; +const MODE_PLUS: usize = 2; +const MODE_MINUS: usize = 3; + +lazy_static! { + pub static ref PLACEHOLDERS: Vec<[String; 4]> = { + let base = vec![ + "n", "H", "h", "P", "p", "d", "s", "an", "ae", "ad", "as", "cn", "ce", "cd", "cs", "b", + "B", + ]; + base.iter() + .map(|b| { + [ + format!("%{}", b), + format!("% {}", b), + format!("%+{}", b), + format!("%-{}", b), + ] + }) + .collect() + }; +} + +/// Format a commit for `CommitFormat::Format(String)`. +pub fn format_commit( + format: &str, + commit: &Commit, + branches: String, + wrapping: &Option<Options>, + hash_color: Option<u8>, +) -> Result<Vec<String>, String> { + let mut replacements = vec![]; + + for (idx, arr) in PLACEHOLDERS.iter().enumerate() { + let mut curr = 0; + loop { + let mut found = false; + for (mode, str) in arr.iter().enumerate() { + if let Some(start) = &format[curr..format.len()].find(str) { + replacements.push((curr + start, str.len(), idx, mode)); + curr += start + str.len(); + found = true; + break; + } + } + if !found { + break; + } + } + } + + replacements.sort_by_key(|p| p.0); + + let mut lines = vec![]; + let mut out = String::new(); + if replacements.is_empty() { + write!(out, "{}", format).unwrap(); + add_line(&mut lines, &mut out, wrapping); + } else { + let mut curr = 0; + for (start, len, idx, mode) in replacements { + if idx == NEW_LINE { + write!(out, "{}", &format[curr..start]).unwrap(); + add_line(&mut lines, &mut out, wrapping); + } else { + write!(out, "{}", &format[curr..start]).unwrap(); + match idx { + HASH => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + if let Some(color) = hash_color { + write!(out, "{}", Paint::fixed(color, commit.id())) + } else { + write!(out, "{}", commit.id()) + } + } + HASH_ABBREV => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + if let Some(color) = hash_color { + write!( + out, + "{}", + Paint::fixed(color, &commit.id().to_string()[..7]) + ) + } else { + write!(out, "{}", &commit.id().to_string()[..7]) + } + } + PARENT_HASHES => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + for i in 0..commit.parent_count() { + write!(out, "{}", commit.parent_id(i).unwrap()).unwrap(); + if i < commit.parent_count() - 1 { + write!(out, " ").unwrap(); + } + } + Ok(()) + } + PARENT_HASHES_ABBREV => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + for i in 0..commit.parent_count() { + write!( + out, + "{}", + &commit + .parent_id(i) + .map_err(|err| err.to_string())? + .to_string()[..7] + ) + .unwrap(); + if i < commit.parent_count() - 1 { + write!(out, " ").unwrap(); + } + } + Ok(()) + } + REFS => { + match mode { + MODE_SPACE => { + if !branches.is_empty() { + write!(out, " ").unwrap() + } + } + MODE_PLUS => { + if !branches.is_empty() { + add_line(&mut lines, &mut out, wrapping) + } + } + MODE_MINUS => { + if branches.is_empty() { + out = remove_empty_lines(&mut lines, out) + } + } + _ => {} + } + write!(out, "{}", branches) + } + SUBJECT => { + let summary = commit.summary().unwrap_or(""); + match mode { + MODE_SPACE => { + if !summary.is_empty() { + write!(out, " ").unwrap() + } + } + MODE_PLUS => { + if !summary.is_empty() { + add_line(&mut lines, &mut out, wrapping) + } + } + MODE_MINUS => { + if summary.is_empty() { + out = remove_empty_lines(&mut lines, out) + } + } + _ => {} + } + write!(out, "{}", summary) + } + AUTHOR => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + write!(out, "{}", &commit.author().name().unwrap_or("")) + } + AUTHOR_EMAIL => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + write!(out, "{}", &commit.author().email().unwrap_or("")) + } + AUTHOR_DATE => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + write!( + out, + "{}", + format_date(commit.author().when(), "%a %b %e %H:%M:%S %Y %z") + ) + } + AUTHOR_DATE_SHORT => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + write!(out, "{}", format_date(commit.author().when(), "%F")) + } + COMMITTER => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + write!(out, "{}", &commit.committer().name().unwrap_or("")) + } + COMMITTER_EMAIL => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + write!(out, "{}", &commit.committer().email().unwrap_or("")) + } + COMMITTER_DATE => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + write!( + out, + "{}", + format_date(commit.committer().when(), "%a %b %e %H:%M:%S %Y %z") + ) + } + COMMITTER_DATE_SHORT => { + match mode { + MODE_SPACE => write!(out, " ").unwrap(), + MODE_PLUS => add_line(&mut lines, &mut out, wrapping), + _ => {} + } + write!(out, "{}", format_date(commit.committer().when(), "%F")) + } + BODY => { + let message = commit + .message() + .unwrap_or("") + .lines() + .collect::<Vec<&str>>(); + + let num_parts = message.len(); + match mode { + MODE_SPACE => { + if num_parts > 2 { + write!(out, " ").unwrap() + } + } + MODE_PLUS => { + if num_parts > 2 { + add_line(&mut lines, &mut out, wrapping) + } + } + MODE_MINUS => { + if num_parts <= 2 { + out = remove_empty_lines(&mut lines, out) + } + } + _ => {} + } + for (cnt, line) in message.iter().enumerate() { + if cnt > 1 && (cnt < num_parts - 1 || !line.is_empty()) { + write!(out, "{}", line).unwrap(); + add_line(&mut lines, &mut out, wrapping); + } + } + Ok(()) + } + BODY_RAW => { + let message = commit + .message() + .unwrap_or("") + .lines() + .collect::<Vec<&str>>(); + + let num_parts = message.len(); + + match mode { + MODE_SPACE => { + if !message.is_empty() { + write!(out, " ").unwrap() + } + } + MODE_PLUS => { + if !message.is_empty() { + add_line(&mut lines, &mut out, wrapping) + } + } + MODE_MINUS => { + if message.is_empty() { + out = remove_empty_lines(&mut lines, out) + } + } + _ => {} + } + for (cnt, line) in message.iter().enumerate() { + if cnt < num_parts - 1 || !line.is_empty() { + write!(out, "{}", line).unwrap(); + add_line(&mut lines, &mut out, wrapping); + } + } + Ok(()) + } + x => return Err(format!("No commit field at index {}", x)), + } + .unwrap(); + } + curr = start + len; + } + write!(out, "{}", &format[curr..(format.len())]).unwrap(); + if !out.is_empty() { + add_line(&mut lines, &mut out, wrapping); + } + } + Ok(lines) +} + +/// Format a commit for `CommitFormat::OneLine`. +pub fn format_oneline( + commit: &Commit, + branches: String, + wrapping: &Option<Options>, + hash_color: Option<u8>, +) -> Vec<String> { + let mut out = String::new(); + if let Some(color) = hash_color { + write!( + out, + "{}", + Paint::fixed(color, &commit.id().to_string()[..7]) + ) + } else { + write!(out, "{}", &commit.id().to_string()[..7]) + } + .unwrap(); + + write!(out, "{} {}", branches, commit.summary().unwrap_or("")).unwrap(); + + if let Some(wrap) = wrapping { + textwrap::fill(&out, wrap) + .lines() + .map(|str| str.to_string()) + .collect() + } else { + vec![out] + } +} + +/// Format a commit for `CommitFormat::Short`, `CommitFormat::Medium` or `CommitFormat::Full`. +pub fn format( + commit: &Commit, + branches: String, + wrapping: &Option<Options>, + hash_color: Option<u8>, + format: &CommitFormat, +) -> Result<Vec<String>, String> { + match format { + CommitFormat::OneLine => return Ok(format_oneline(commit, branches, wrapping, hash_color)), + CommitFormat::Format(format) => { + return format_commit(format, commit, branches, wrapping, hash_color) + } + _ => {} + } + + let mut out_vec = vec![]; + let mut out = String::new(); + + if let Some(color) = hash_color { + write!(out, "commit {}", Paint::fixed(color, &commit.id())) + } else { + write!(out, "commit {}", &commit.id()) + } + .map_err(|err| err.to_string())?; + + write!(out, "{}", branches).map_err(|err| err.to_string())?; + append_wrapped(&mut out_vec, out, wrapping); + + if commit.parent_count() > 1 { + out = String::new(); + write!( + out, + "Merge: {} {}", + &commit.parent_id(0).unwrap().to_string()[..7], + &commit.parent_id(1).unwrap().to_string()[..7] + ) + .map_err(|err| err.to_string())?; + append_wrapped(&mut out_vec, out, wrapping); + } + + out = String::new(); + write!( + out, + "Author: {} <{}>", + commit.author().name().unwrap_or(""), + commit.author().email().unwrap_or("") + ) + .map_err(|err| err.to_string())?; + append_wrapped(&mut out_vec, out, wrapping); + + if format > &CommitFormat::Medium { + out = String::new(); + write!( + out, + "Commit: {} <{}>", + commit.committer().name().unwrap_or(""), + commit.committer().email().unwrap_or("") + ) + .map_err(|err| err.to_string())?; + append_wrapped(&mut out_vec, out, wrapping); + } + + if format > &CommitFormat::Short { + out = String::new(); + write!( + out, + "Date: {}", + format_date(commit.author().when(), "%a %b %e %H:%M:%S %Y %z") + ) + .map_err(|err| err.to_string())?; + append_wrapped(&mut out_vec, out, wrapping); + } + + if format == &CommitFormat::Short { + out_vec.push("".to_string()); + append_wrapped( + &mut out_vec, + format!(" {}", commit.summary().unwrap_or("")), + wrapping, + ); + out_vec.push("".to_string()); + } else { + out_vec.push("".to_string()); + let mut add_line = true; + for line in commit.message().unwrap_or("").lines() { + if line.is_empty() { + out_vec.push(line.to_string()); + } else { + append_wrapped(&mut out_vec, format!(" {}", line), wrapping); + } + add_line = !line.trim().is_empty(); + } + if add_line { + out_vec.push("".to_string()); + } + } + + Ok(out_vec) +} + +pub fn format_date(time: Time, format: &str) -> String { + let date = + Local::from_offset(&FixedOffset::east(time.offset_minutes())).timestamp(time.seconds(), 0); + format!("{}", date.format(format)) +} + +fn append_wrapped(vec: &mut Vec<String>, str: String, wrapping: &Option<Options>) { + if str.is_empty() { + vec.push(str); + } else if let Some(wrap) = wrapping { + vec.extend( + textwrap::fill(&str, wrap) + .lines() + .map(|str| str.to_string()), + ) + } else { + vec.push(str); + } +} + +fn add_line(lines: &mut Vec<String>, line: &mut String, wrapping: &Option<Options>) { + let mut temp = String::new(); + std::mem::swap(&mut temp, line); + append_wrapped(lines, temp, wrapping); +} + +fn remove_empty_lines(lines: &mut Vec<String>, mut line: String) -> String { + while !lines.is_empty() && lines.last().unwrap().is_empty() { + line = lines.remove(lines.len() - 1); + } + if !lines.is_empty() { + line = lines.remove(lines.len() - 1); + } + line +} diff --git a/git-graph/src/print/mod.rs b/git-graph/src/print/mod.rs new file mode 100644 index 0000000000..9ade855820 --- /dev/null +++ b/git-graph/src/print/mod.rs @@ -0,0 +1,45 @@ +//! Create visual representations of git graphs. + +use crate::graph::GitGraph; +use std::cmp::max; + +pub mod colors; +pub mod format; +pub mod svg; +pub mod unicode; + +/// Find the index at which a between-branch connection +/// has to deviate from the current branch's column. +/// +/// Returns the last index on the current column. +fn get_deviate_index(graph: &GitGraph, index: usize, par_index: usize) -> usize { + let info = &graph.commits[index]; + + let par_info = &graph.commits[par_index]; + let par_branch = &graph.all_branches[par_info.branch_trace.unwrap()]; + + let mut min_split_idx = index; + for sibling_oid in &par_info.children { + if let Some(&sibling_index) = graph.indices.get(sibling_oid) { + if let Some(sibling) = graph.commits.get(sibling_index) { + if let Some(sibling_trace) = sibling.branch_trace { + let sibling_branch = &graph.all_branches[sibling_trace]; + if sibling_oid != &info.oid + && sibling_branch.visual.column == par_branch.visual.column + && sibling_index > min_split_idx + { + min_split_idx = sibling_index; + } + } + } + } + } + + // TODO: in cases where no crossings occur, the rule for merge commits can also be applied to normal commits + // See also branch::trace_branch() + if info.is_merge { + max(index, min_split_idx) + } else { + (par_index as i32 - 1) as usize + } +} diff --git a/git-graph/src/print/svg.rs b/git-graph/src/print/svg.rs new file mode 100644 index 0000000000..35cba5a872 --- /dev/null +++ b/git-graph/src/print/svg.rs @@ -0,0 +1,161 @@ +//! Create graphs in SVG format (Scalable Vector Graphics). + +use crate::graph::GitGraph; +use crate::settings::Settings; +use svg::node::element::path::Data; +use svg::node::element::{Circle, Line, Path}; +use svg::Document; + +/// Creates a SVG visual representation of a graph. +pub fn print_svg(graph: &GitGraph, settings: &Settings) -> Result<String, String> { + let mut document = Document::new(); + + let max_idx = graph.commits.len(); + let mut max_column = 0; + + if settings.debug { + for branch in &graph.all_branches { + if let (Some(start), Some(end)) = branch.range { + document = document.add(bold_line( + start, + branch.visual.column.unwrap(), + end, + branch.visual.column.unwrap(), + "cyan", + )); + } + } + } + + for (idx, info) in graph.commits.iter().enumerate() { + if let Some(trace) = info.branch_trace { + let branch = &graph.all_branches[trace]; + let branch_color = &branch.visual.svg_color; + + if branch.visual.column.unwrap() > max_column { + max_column = branch.visual.column.unwrap(); + } + + for p in 0..2 { + if let Some(par_oid) = info.parents[p] { + if let Some(par_idx) = graph.indices.get(&par_oid) { + let par_info = &graph.commits[*par_idx]; + let par_branch = &graph.all_branches[par_info.branch_trace.unwrap()]; + + let color = if info.is_merge { + &par_branch.visual.svg_color + } else { + branch_color + }; + + if branch.visual.column == par_branch.visual.column { + document = document.add(line( + idx, + branch.visual.column.unwrap(), + *par_idx, + par_branch.visual.column.unwrap(), + color, + )); + } else { + let split_index = super::get_deviate_index(graph, idx, *par_idx); + document = document.add(path( + idx, + branch.visual.column.unwrap(), + *par_idx, + par_branch.visual.column.unwrap(), + split_index, + color, + )); + } + } + } + } + + document = document.add(commit_dot( + idx, + branch.visual.column.unwrap(), + branch_color, + !info.is_merge, + )); + } + } + let (x_max, y_max) = commit_coord(max_idx + 1, max_column + 1); + document = document + .set("viewBox", (0, 0, x_max, y_max)) + .set("width", x_max) + .set("height", y_max); + + let mut out: Vec<u8> = vec![]; + svg::write(&mut out, &document).map_err(|err| err.to_string())?; + Ok(String::from_utf8(out).unwrap_or_else(|_| "Invalid UTF8 character.".to_string())) +} + +fn commit_dot(index: usize, column: usize, color: &str, filled: bool) -> Circle { + let (x, y) = commit_coord(index, column); + Circle::new() + .set("cx", x) + .set("cy", y) + .set("r", 4) + .set("fill", if filled { color } else { "white" }) + .set("stroke", color) + .set("stroke-width", 1) +} + +fn line(index1: usize, column1: usize, index2: usize, column2: usize, color: &str) -> Line { + let (x1, y1) = commit_coord(index1, column1); + let (x2, y2) = commit_coord(index2, column2); + Line::new() + .set("x1", x1) + .set("y1", y1) + .set("x2", x2) + .set("y2", y2) + .set("stroke", color) + .set("stroke-width", 1) +} + +fn bold_line(index1: usize, column1: usize, index2: usize, column2: usize, color: &str) -> Line { + let (x1, y1) = commit_coord(index1, column1); + let (x2, y2) = commit_coord(index2, column2); + Line::new() + .set("x1", x1) + .set("y1", y1) + .set("x2", x2) + .set("y2", y2) + .set("stroke", color) + .set("stroke-width", 5) +} + +fn path( + index1: usize, + column1: usize, + index2: usize, + column2: usize, + split_idx: usize, + color: &str, +) -> Path { + let c0 = commit_coord(index1, column1); + + let c1 = commit_coord(split_idx, column1); + let c2 = commit_coord(split_idx + 1, column2); + + let c3 = commit_coord(index2, column2); + + let m = (0.5 * (c1.0 + c2.0), 0.5 * (c1.1 + c2.1)); + + let data = Data::new() + .move_to(c0) + .line_to(c1) + .quadratic_curve_to((c1.0, m.1, m.0, m.1)) + .quadratic_curve_to((c2.0, m.1, c2.0, c2.1)) + .line_to(c3); + + Path::new() + .set("d", data) + .set("fill", "none") + .set("stroke", color) + .set("stroke-width", 1) +} + +fn commit_coord(index: usize, column: usize) -> (f32, f32) { + (15.0 * (column as f32 + 1.0), 15.0 * (index as f32 + 1.0)) +} diff --git a/git-graph/src/print/unicode.rs b/git-graph/src/print/unicode.rs new file mode 100644 index 0000000000..528188be59 --- /dev/null +++ b/git-graph/src/print/unicode.rs @@ -0,0 +1,727 @@ +//! Create graphs in SVG format (Scalable Vector Graphics). + +use crate::graph::{CommitInfo, GitGraph, HeadInfo}; +use crate::print::format::CommitFormat; +use crate::settings::{Characters, Settings}; +use itertools::Itertools; +use std::cmp::max; +use std::collections::hash_map::Entry::{Occupied, Vacant}; +use std::collections::HashMap; +use std::fmt::Write; +use textwrap::Options; +use yansi::Paint; + +const SPACE: u8 = 0; +const DOT: u8 = 1; +const CIRCLE: u8 = 2; +const VER: u8 = 3; +const HOR: u8 = 4; +const CROSS: u8 = 5; +const R_U: u8 = 6; +const R_D: u8 = 7; +const L_D: u8 = 8; +const L_U: u8 = 9; +const VER_L: u8 = 10; +const VER_R: u8 = 11; +const HOR_U: u8 = 12; +const HOR_D: u8 = 13; + +const ARR_L: u8 = 14; +const ARR_R: u8 = 15; + +const WHITE: u8 = 7; +const HEAD_COLOR: u8 = 14; +const HASH_COLOR: u8 = 11; + +type UnicodeGraphInfo = (Vec<String>, Vec<String>, Vec<usize>); + +/// Creates a text-based visual representation of a graph. +pub fn print_unicode(graph: &GitGraph, settings: &Settings) -> Result<UnicodeGraphInfo, String> { + let num_cols = 2 * graph + .all_branches + .iter() + .map(|b| b.visual.column.unwrap_or(0)) + .max() + .unwrap() + + 1; + + let head_idx = graph.indices.get(&graph.head.oid); + + let inserts = get_inserts(graph, settings.compact); + + let (indent1, indent2) = if let Some((_, ind1, ind2)) = settings.wrapping { + (" ".repeat(ind1.unwrap_or(0)), " ".repeat(ind2.unwrap_or(0))) + } else { + ("".to_string(), "".to_string()) + }; + + let wrap_options = if let Some((width, _, _)) = settings.wrapping { + create_wrapping_options(width, &indent1, &indent2, num_cols + 4)? + } else { + None + }; + + // Compute commit text into text_lines + let mut index_map = vec![]; + let mut text_lines = vec![]; + let mut offset = 0; + for (idx, info) in graph.commits.iter().enumerate() { + index_map.push(idx + offset); + let cnt_inserts = if let Some(inserts) = inserts.get(&idx) { + inserts + .iter() + .filter(|vec| { + vec.iter().all(|occ| match occ { + Occ::Commit(_, _) => false, + Occ::Range(_, _, _, _) => true, + }) + }) + .count() + } else { + 0 + }; + + let head = if head_idx == Some(&idx) { + Some(&graph.head) + } else { + None + }; + + let lines = format( + &settings.format, + graph, + info, + head, + settings.colored, + &wrap_options, + )?; + + let num_lines = if lines.is_empty() { 0 } else { lines.len() - 1 }; + let max_inserts = max(cnt_inserts, num_lines); + let add_lines = max_inserts - num_lines; + + text_lines.extend(lines.into_iter().map(Some)); + text_lines.extend((0..add_lines).map(|_| None)); + + offset += max_inserts; + } + + let mut grid = Grid::new( + num_cols, + graph.commits.len() + offset, + [SPACE, WHITE, settings.branches.persistence.len() as u8 + 2], + ); + + // Compute branch lines in grid + for (idx, info) in graph.commits.iter().enumerate() { + if let Some(trace) = info.branch_trace { + let branch = &graph.all_branches[trace]; + let column = branch.visual.column.unwrap(); + let idx_map = index_map[idx]; + + let branch_color = branch.visual.term_color; + + grid.set( + column * 2, + idx_map, + if info.is_merge { CIRCLE } else { DOT }, + branch_color, + branch.persistence, + ); + + for p in 0..2 { + if let Some(par_oid) = info.parents[p] { + if let Some(par_idx) = graph.indices.get(&par_oid) { + let par_idx_map = index_map[*par_idx]; + let par_info = &graph.commits[*par_idx]; + let par_branch = &graph.all_branches[par_info.branch_trace.unwrap()]; + let par_column = par_branch.visual.column.unwrap(); + + let (color, pers) = if info.is_merge { + (par_branch.visual.term_color, par_branch.persistence) + } else { + (branch_color, branch.persistence) + }; + + if branch.visual.column == par_branch.visual.column { + if par_idx_map > idx_map + 1 { + vline(&mut grid, (idx_map, par_idx_map), column, color, pers); + } + } else { + let split_index = super::get_deviate_index(graph, idx, *par_idx); + let split_idx_map = index_map[split_index]; + let inserts = &inserts[&split_index]; + for (insert_idx, sub_entry) in inserts.iter().enumerate() { + for occ in sub_entry { + match occ { + Occ::Commit(_, _) => {} + Occ::Range(i1, i2, _, _) => { + if *i1 == idx && i2 == par_idx { + vline( + &mut grid, + (idx_map, split_idx_map + insert_idx), + column, + color, + pers, + ); + hline( + &mut grid, + split_idx_map + insert_idx, + (par_column, column), + info.is_merge && p > 0, + color, + pers, + ); + vline( + &mut grid, + (split_idx_map + insert_idx, par_idx_map), + par_column, + color, + pers, + ); + } + } + } + } + } + } + } + } + } + } + } + + if settings.reverse_commit_order { + text_lines.reverse(); + grid.reverse(); + } + + let lines = print_graph(&settings.characters, &grid, text_lines, settings.colored); + + Ok((lines.0, lines.1, index_map)) +} + +/// Create `textwrap::Options` from width and indent. +fn create_wrapping_options<'a>( + width: Option<usize>, + indent1: &'a str, + indent2: &'a str, + graph_width: usize, +) -> Result<Option<Options<'a>>, String> { + let wrapping = if let Some(width) = width { + Some( + textwrap::Options::new(width) + .initial_indent(indent1) + .subsequent_indent(indent2), + ) + } else if atty::is(atty::Stream::Stdout) { + let width = crossterm::terminal::size() + .map_err(|err| err.to_string())? + .0; + let width = if width as usize > graph_width { + width as usize - graph_width + } else { + 1 + }; + Some( + textwrap::Options::new(width) + .initial_indent(indent1) + .subsequent_indent(indent2), + ) + } else { + None + }; + Ok(wrapping) +} + +/// Draws a vertical line +fn vline(grid: &mut Grid, (from, to): (usize, usize), column: usize, color: u8, pers: u8) { + for i in (from + 1)..to { + let (curr, _, old_pers) = grid.get_tuple(column * 2, i); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match curr { + DOT | CIRCLE => {} + HOR => { + grid.set_opt(column * 2, i, Some(CROSS), Some(color), Some(pers)); + } + HOR_U | HOR_D => { + grid.set_opt(column * 2, i, Some(CROSS), Some(color), Some(pers)); + } + CROSS | VER | VER_L | VER_R => grid.set_opt(column * 2, i, None, new_col, new_pers), + L_D | L_U => { + grid.set_opt(column * 2, i, Some(VER_L), new_col, new_pers); + } + R_D | R_U => { + grid.set_opt(column * 2, i, Some(VER_R), new_col, new_pers); + } + _ => { + grid.set_opt(column * 2, i, Some(VER), new_col, new_pers); + } + } + } +} + +/// Draws a horizontal line +fn hline( + grid: &mut Grid, + index: usize, + (from, to): (usize, usize), + merge: bool, + color: u8, + pers: u8, +) { + if from == to { + return; + } + let from_2 = from * 2; + let to_2 = to * 2; + if from < to { + for column in (from_2 + 1)..to_2 { + if merge && column == to_2 - 1 { + grid.set(column, index, ARR_R, color, pers); + } else { + let (curr, _, old_pers) = grid.get_tuple(column, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match curr { + DOT | CIRCLE => {} + VER => grid.set_opt(column, index, Some(CROSS), None, None), + HOR | CROSS | HOR_U | HOR_D => { + grid.set_opt(column, index, None, new_col, new_pers) + } + L_U | R_U => grid.set_opt(column, index, Some(HOR_U), new_col, new_pers), + L_D | R_D => grid.set_opt(column, index, Some(HOR_D), new_col, new_pers), + _ => { + grid.set_opt(column, index, Some(HOR), new_col, new_pers); + } + } + } + } + + let (left, _, old_pers) = grid.get_tuple(from_2, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match left { + DOT | CIRCLE => {} + VER => grid.set_opt(from_2, index, Some(VER_R), new_col, new_pers), + VER_L => grid.set_opt(from_2, index, Some(CROSS), None, None), + VER_R => {} + HOR | L_U => grid.set_opt(from_2, index, Some(HOR_U), new_col, new_pers), + _ => { + grid.set_opt(from_2, index, Some(R_D), new_col, new_pers); + } + } + + let (right, _, old_pers) = grid.get_tuple(to_2, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match right { + DOT | CIRCLE => {} + VER => grid.set_opt(to_2, index, Some(VER_L), None, None), + VER_L | HOR_U => grid.set_opt(to_2, index, None, new_col, new_pers), + HOR | R_U => grid.set_opt(to_2, index, Some(HOR_U), new_col, new_pers), + _ => { + grid.set_opt(to_2, index, Some(L_U), new_col, new_pers); + } + } + } else { + for column in (to_2 + 1)..from_2 { + if merge && column == to_2 + 1 { + grid.set(column, index, ARR_L, color, pers); + } else { + let (curr, _, old_pers) = grid.get_tuple(column, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match curr { + DOT | CIRCLE => {} + VER => grid.set_opt(column, index, Some(CROSS), None, None), + HOR | CROSS | HOR_U | HOR_D => { + grid.set_opt(column, index, None, new_col, new_pers) + } + L_U | R_U => grid.set_opt(column, index, Some(HOR_U), new_col, new_pers), + L_D | R_D => grid.set_opt(column, index, Some(HOR_D), new_col, new_pers), + _ => { + grid.set_opt(column, index, Some(HOR), new_col, new_pers); + } + } + } + } + + let (left, _, old_pers) = grid.get_tuple(to_2, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match left { + DOT | CIRCLE => {} + VER => grid.set_opt(to_2, index, Some(VER_R), None, None), + VER_R => grid.set_opt(to_2, index, None, new_col, new_pers), + HOR | L_U => grid.set_opt(to_2, index, Some(HOR_U), new_col, new_pers), + _ => { + grid.set_opt(to_2, index, Some(R_U), new_col, new_pers); + } + } + + let (right, _, old_pers) = grid.get_tuple(from_2, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match right { + DOT | CIRCLE => {} + VER => grid.set_opt(from_2, index, Some(VER_L), new_col, new_pers), + VER_R => grid.set_opt(from_2, index, Some(CROSS), None, None), + VER_L => grid.set_opt(from_2, index, None, new_col, new_pers), + HOR | R_D => grid.set_opt(from_2, index, Some(HOR_D), new_col, new_pers), + _ => { + grid.set_opt(from_2, index, Some(L_D), new_col, new_pers); + } + } + } +} + +/// Calculates required additional rows +fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> { + let mut inserts: HashMap<usize, Vec<Vec<Occ>>> = HashMap::new(); + + for (idx, info) in graph.commits.iter().enumerate() { + let column = graph.all_branches[info.branch_trace.unwrap()] + .visual + .column + .unwrap(); + + inserts.insert(idx, vec![vec![Occ::Commit(idx, column)]]); + } + + for (idx, info) in graph.commits.iter().enumerate() { + if let Some(trace) = info.branch_trace { + let branch = &graph.all_branches[trace]; + let column = branch.visual.column.unwrap(); + + for p in 0..2 { + if let Some(par_oid) = info.parents[p] { + if let Some(par_idx) = graph.indices.get(&par_oid) { + let par_info = &graph.commits[*par_idx]; + let par_branch = &graph.all_branches[par_info.branch_trace.unwrap()]; + let par_column = par_branch.visual.column.unwrap(); + let column_range = sorted(column, par_column); + + if column != par_column { + let split_index = super::get_deviate_index(graph, idx, *par_idx); + match inserts.entry(split_index) { + Occupied(mut entry) => { + let mut insert_at = entry.get().len(); + for (insert_idx, sub_entry) in entry.get().iter().enumerate() { + let mut occ = false; + for other_range in sub_entry { + if other_range.overlaps(&column_range) { + match other_range { + Occ::Commit(target_index, _) => { + if !compact + || !info.is_merge + || idx != *target_index + || p == 0 + { + occ = true; + break; + } + } + Occ::Range(o_idx, o_par_idx, _, _) => { + if idx != *o_idx && par_idx != o_par_idx { + occ = true; + break; + } + } + } + } + } + if !occ { + insert_at = insert_idx; + break; + } + } + let vec = entry.get_mut(); + if insert_at == vec.len() { + vec.push(vec![Occ::Range( + idx, + *par_idx, + column_range.0, + column_range.1, + )]); + } else { + vec[insert_at].push(Occ::Range( + idx, + *par_idx, + column_range.0, + column_range.1, + )); + } + } + Vacant(entry) => { + entry.insert(vec![vec![Occ::Range( + idx, + *par_idx, + column_range.0, + column_range.1, + )]]); + } + } + } + } + } + } + } + } + + inserts +} + +/// Creates the complete graph visualization, incl. formatter commits. +fn print_graph( + characters: &Characters, + grid: &Grid, + text_lines: Vec<Option<String>>, + color: bool, +) -> (Vec<String>, Vec<String>) { + let mut g_lines = vec![]; + let mut t_lines = vec![]; + + for (row, line) in grid.data.chunks(grid.width).zip(text_lines.into_iter()) { + let mut g_out = String::new(); + let mut t_out = String::new(); + + if color { + for arr in row { + if arr[0] == SPACE { + write!(g_out, "{}", characters.chars[arr[0] as usize]) + } else { + write!( + g_out, + "{}", + Paint::fixed(arr[1], characters.chars[arr[0] as usize]) + ) + } + .unwrap(); + } + } else { + let str = row + .iter() + .map(|arr| characters.chars[arr[0] as usize]) + .collect::<String>(); + write!(g_out, "{}", str).unwrap(); + } + + if let Some(line) = line { + write!(t_out, "{}", line).unwrap(); + } + + g_lines.push(g_out); + t_lines.push(t_out); + } + + (g_lines, t_lines) +} + +/// Format a commit. +fn format( + format: &CommitFormat, + graph: &GitGraph, + info: &CommitInfo, + head: Option<&HeadInfo>, + color: bool, + wrapping: &Option<Options>, +) -> Result<Vec<String>, String> { + let commit = graph + .repository + .find_commit(info.oid) + .map_err(|err| err.message().to_string())?; + + let branch_str = format_branches(graph, info, head, color); + + let hash_color = if color { Some(HASH_COLOR) } else { None }; + + crate::print::format::format(&commit, branch_str, wrapping, hash_color, format) +} + +/// Format branches and tags. +pub fn format_branches( + graph: &GitGraph, + info: &CommitInfo, + head: Option<&HeadInfo>, + color: bool, +) -> String { + let curr_color = info + .branch_trace + .map(|branch_idx| &graph.all_branches[branch_idx].visual.term_color); + + let mut branch_str = String::new(); + + let head_str = "HEAD ->"; + if let Some(head) = head { + if !head.is_branch { + if color { + write!(branch_str, " {}", Paint::fixed(HEAD_COLOR, head_str)) + } else { + write!(branch_str, " {}", head_str) + } + .unwrap(); + } + } + + if !info.branches.is_empty() { + write!(branch_str, " (").unwrap(); + + let branches = info.branches.iter().sorted_by_key(|br| { + if let Some(head) = head { + head.name != graph.all_branches[**br].name + } else { + false + } + }); + + for (idx, branch_index) in branches.enumerate() { + let branch = &graph.all_branches[*branch_index]; + let branch_color = branch.visual.term_color; + + if let Some(head) = head { + if idx == 0 && head.is_branch { + if color { + write!(branch_str, "{} ", Paint::fixed(14, head_str)) + } else { + write!(branch_str, "{} ", head_str) + } + .unwrap(); + } + } + + if color { + write!(branch_str, "{}", Paint::fixed(branch_color, &branch.name)) + } else { + write!(branch_str, "{}", &branch.name) + } + .unwrap(); + + if idx < info.branches.len() - 1 { + write!(branch_str, ", ").unwrap(); + } + } + write!(branch_str, ")").unwrap(); + } + + if !info.tags.is_empty() { + write!(branch_str, " [").unwrap(); + for (idx, tag_index) in info.tags.iter().enumerate() { + let tag = &graph.all_branches[*tag_index]; + let tag_color = curr_color.unwrap_or(&tag.visual.term_color); + + if color { + write!(branch_str, "{}", Paint::fixed(*tag_color, &tag.name[5..])) + } else { + write!(branch_str, "{}", &tag.name[5..]) + } + .unwrap(); + + if idx < info.tags.len() - 1 { + write!(branch_str, ", ").unwrap(); + } + } + write!(branch_str, "]").unwrap(); + } + + branch_str +} + +/// Occupied row ranges +enum Occ { + Commit(usize, usize), + Range(usize, usize, usize, usize), +} + +impl Occ { + fn overlaps(&self, (start, end): &(usize, usize)) -> bool { + match self { + Occ::Commit(_, col) => start <= col && end >= col, + Occ::Range(_, _, s, e) => s <= end && e >= start, + } + } +} + +/// Sorts two numbers in ascending order +fn sorted(v1: usize, v2: usize) -> (usize, usize) { + if v2 > v1 { + (v1, v2) + } else { + (v2, v1) + } +} + +/// Two-dimensional grid with 3 layers, used to produce the graph representation. +#[allow(dead_code)] +struct Grid { + width: usize, + height: usize, + data: Vec<[u8; 3]>, +} + +impl Grid { + pub fn new(width: usize, height: usize, initial: [u8; 3]) -> Self { + Grid { + width, + height, + data: vec![initial; width * height], + } + } + + pub fn reverse(&mut self) { + self.data.reverse(); + } + pub fn index(&self, x: usize, y: usize) -> usize { + y * self.width + x + } + pub fn get_tuple(&self, x: usize, y: usize) -> (u8, u8, u8) { + let v = self.data[self.index(x, y)]; + (v[0], v[1], v[2]) + } + pub fn set(&mut self, x: usize, y: usize, character: u8, color: u8, pers: u8) { + let idx = self.index(x, y); + self.data[idx] = [character, color, pers]; + } + pub fn set_opt( + &mut self, + x: usize, + y: usize, + character: Option<u8>, + color: Option<u8>, + pers: Option<u8>, + ) { + let idx = self.index(x, y); + let arr = &mut self.data[idx]; + if let Some(character) = character { + arr[0] = character; + } + if let Some(color) = color { + arr[1] = color; + } + if let Some(pers) = pers { + arr[2] = pers; + } + } +} diff --git a/git-graph/src/settings.rs b/git-graph/src/settings.rs new file mode 100644 index 0000000000..a2ee5ed26b --- /dev/null +++ b/git-graph/src/settings.rs @@ -0,0 +1,362 @@ +//! Graph generation settings. + +use crate::print::format::CommitFormat; +use regex::{Error, Regex}; +use serde_derive::{Deserialize, Serialize}; +use std::str::FromStr; + +/// Repository settings for the branching model. +/// Used to read repo's git-graph.toml +#[derive(Serialize, Deserialize)] +pub struct RepoSettings { + /// The repository's branching model + pub model: String, +} + +/// Ordering policy for branches in visual columns. +pub enum BranchOrder { + /// Recommended! Shortest branches are inserted left-most. + /// + /// For branches with equal length, branches ending last are inserted first. + /// Reverse (arg = false): Branches ending first are inserted first. + ShortestFirst(bool), + /// Longest branches are inserted left-most. + /// + /// For branches with equal length, branches ending last are inserted first. + /// Reverse (arg = false): Branches ending first are inserted first. + LongestFirst(bool), +} + +/// Top-level settings +pub struct Settings { + /// Reverse the order of commits + pub reverse_commit_order: bool, + /// Debug printing and drawing + pub debug: bool, + /// Compact text-based graph + pub compact: bool, + /// Colored text-based graph + pub colored: bool, + /// Include remote branches? + pub include_remote: bool, + /// Formatting for commits + pub format: CommitFormat, + /// Text wrapping options + pub wrapping: Option<(Option<usize>, Option<usize>, Option<usize>)>, + /// Characters to use for text-based graph + pub characters: Characters, + /// Branch column sorting algorithm + pub branch_order: BranchOrder, + /// Settings for branches + pub branches: BranchSettings, + /// Regex patterns for finding branch names in merge commit summaries + pub merge_patterns: MergePatterns, +} + +/// Helper for reading BranchSettings, required due to RegEx. +#[derive(Serialize, Deserialize)] +pub struct BranchSettingsDef { + /// Branch persistence + pub persistence: Vec<String>, + /// Branch ordering + pub order: Vec<String>, + /// Branch colors + pub terminal_colors: ColorsDef, + /// Branch colors for SVG output + pub svg_colors: ColorsDef, +} + +/// Helper for reading branch colors, required due to RegEx. +#[derive(Serialize, Deserialize)] +pub struct ColorsDef { + matches: Vec<(String, Vec<String>)>, + unknown: Vec<String>, +} + +impl BranchSettingsDef { + /// The Git-Flow model. + pub fn git_flow() -> Self { + BranchSettingsDef { + persistence: vec![ + r"^(master|main|trunk)$".to_string(), + r"^(develop|dev)$".to_string(), + r"^feature.*$".to_string(), + r"^release.*$".to_string(), + r"^hotfix.*$".to_string(), + r"^bugfix.*$".to_string(), + ], + order: vec![ + r"^(master|main|trunk)$".to_string(), + r"^(hotfix|release).*$".to_string(), + r"^(develop|dev)$".to_string(), + ], + terminal_colors: ColorsDef { + matches: vec![ + ( + r"^(master|main|trunk)$".to_string(), + vec!["bright_blue".to_string()], + ), + ( + r"^(develop|dev)$".to_string(), + vec!["bright_yellow".to_string()], + ), + ( + r"^(feature|fork/).*$".to_string(), + vec!["bright_magenta".to_string(), "bright_cyan".to_string()], + ), + (r"^release.*$".to_string(), vec!["bright_green".to_string()]), + ( + r"^(bugfix|hotfix).*$".to_string(), + vec!["bright_red".to_string()], + ), + (r"^tags/.*$".to_string(), vec!["bright_green".to_string()]), + ], + unknown: vec!["white".to_string()], + }, + + svg_colors: ColorsDef { + matches: vec![ + ( + r"^(master|main|trunk)$".to_string(), + vec!["blue".to_string()], + ), + (r"^(develop|dev)$".to_string(), vec!["orange".to_string()]), + ( + r"^(feature|fork/).*$".to_string(), + vec!["purple".to_string(), "turquoise".to_string()], + ), + (r"^release.*$".to_string(), vec!["green".to_string()]), + (r"^(bugfix|hotfix).*$".to_string(), vec!["red".to_string()]), + (r"^tags/.*$".to_string(), vec!["green".to_string()]), + ], + unknown: vec!["gray".to_string()], + }, + } + } + + /// Simple feature-based model. + pub fn simple() -> Self { + BranchSettingsDef { + persistence: vec![r"^(master|main|trunk)$".to_string()], + order: vec![ + r"^tags/.*$".to_string(), + r"^(master|main|trunk)$".to_string(), + ], + terminal_colors: ColorsDef { + matches: vec![ + ( + r"^(master|main|trunk)$".to_string(), + vec!["bright_blue".to_string()], + ), + (r"^tags/.*$".to_string(), vec!["bright_green".to_string()]), + ], + unknown: vec![ + "bright_yellow".to_string(), + "bright_green".to_string(), + "bright_red".to_string(), + "bright_magenta".to_string(), + "bright_cyan".to_string(), + ], + }, + + svg_colors: ColorsDef { + matches: vec![ + ( + r"^(master|main|trunk)$".to_string(), + vec!["blue".to_string()], + ), + (r"^tags/.*$".to_string(), vec!["green".to_string()]), + ], + unknown: vec![ + "orange".to_string(), + "green".to_string(), + "red".to_string(), + "purple".to_string(), + "turquoise".to_string(), + ], + }, + } + } + + /// Very simple model without any defined branch roles. + pub fn none() -> Self { + BranchSettingsDef { + persistence: vec![], + order: vec![], + terminal_colors: ColorsDef { + matches: vec![], + unknown: vec![ + "bright_blue".to_string(), + "bright_yellow".to_string(), + "bright_green".to_string(), + "bright_red".to_string(), + "bright_magenta".to_string(), + "bright_cyan".to_string(), + ], + }, + + svg_colors: ColorsDef { + matches: vec![], + unknown: vec![ + "blue".to_string(), + "orange".to_string(), + "green".to_string(), + "red".to_string(), + "purple".to_string(), + "turquoise".to_string(), + ], + }, + } + } +} + +/// Settings defining branching models +pub struct BranchSettings { + /// Branch persistence + pub persistence: Vec<Regex>, + /// Branch ordering + pub order: Vec<Regex>, + /// Branch colors + pub terminal_colors: Vec<(Regex, Vec<String>)>, + /// Colors for branches not matching any of `colors` + pub terminal_colors_unknown: Vec<String>, + /// Branch colors for SVG output + pub svg_colors: Vec<(Regex, Vec<String>)>, + /// Colors for branches not matching any of `colors` for SVG output + pub svg_colors_unknown: Vec<String>, +} + +impl BranchSettings { + pub fn from(def: BranchSettingsDef) -> Result<Self, Error> { + let persistence = def + .persistence + .iter() + .map(|str| Regex::new(str)) + .collect::<Result<Vec<_>, Error>>()?; + + let order = def + .order + .iter() + .map(|str| Regex::new(str)) + .collect::<Result<Vec<_>, Error>>()?; + + let terminal_colors = def + .terminal_colors + .matches + .into_iter() + .map(|(str, vec)| Regex::new(&str).map(|re| (re, vec))) + .collect::<Result<Vec<_>, Error>>()?; + + let terminal_colors_unknown = def.terminal_colors.unknown; + + let svg_colors = def + .svg_colors + .matches + .into_iter() + .map(|(str, vec)| Regex::new(&str).map(|re| (re, vec))) + .collect::<Result<Vec<_>, Error>>()?; + + let svg_colors_unknown = def.svg_colors.unknown; + + Ok(BranchSettings { + persistence, + order, + terminal_colors, + terminal_colors_unknown, + svg_colors, + svg_colors_unknown, + }) + } +} + +/// RegEx patterns for extracting branch names from merge commit summaries. +pub struct MergePatterns { + /// The patterns. Evaluated in the given order. + pub patterns: Vec<Regex>, +} + +impl Default for MergePatterns { + fn default() -> Self { + MergePatterns { + patterns: vec![ + // GitLab pull request + Regex::new(r"^Merge branch '(.+)' into '.+'$").unwrap(), + // Git default + Regex::new(r"^Merge branch '(.+)' into .+$").unwrap(), + // Git default into main branch + Regex::new(r"^Merge branch '(.+)'$").unwrap(), + // GitHub pull request + Regex::new(r"^Merge pull request #[0-9]+ from .[^/]+/(.+)$").unwrap(), + // GitHub pull request (from fork?) + Regex::new(r"^Merge branch '(.+)' of .+$").unwrap(), + // BitBucket pull request + Regex::new(r"^Merged in (.+) \(pull request #[0-9]+\)$").unwrap(), + ], + } + } +} + +/// The characters used for drawing text-based graphs. +pub struct Characters { + pub chars: Vec<char>, +} + +impl FromStr for Characters { + type Err = String; + + fn from_str(str: &str) -> Result<Self, Self::Err> { + match str { + "normal" | "thin" | "n" | "t" => Ok(Characters::thin()), + "round" | "r" => Ok(Characters::round()), + "bold" | "b" => Ok(Characters::bold()), + "double" | "d" => Ok(Characters::double()), + "ascii" | "a" => Ok(Characters::ascii()), + _ => Err(format!("Unknown characters/style '{}'. Must be one of [normal|thin|round|bold|double|ascii]", str)), + } + } +} + +impl Characters { + /// Default/thin graphs + pub fn thin() -> Self { + Characters { + chars: " ●○│─┼└┌┐┘┤├┴┬<>".chars().collect(), + } + } + /// Graphs with rounded corners + pub fn round() -> Self { + Characters { + chars: " ●○│─┼╰╭╮╯┤├┴┬<>".chars().collect(), + } + } + /// Bold/fat graphs + pub fn bold() -> Self { + Characters { + chars: " ●○┃━╋┗┏┓┛┫┣┻┳<>".chars().collect(), + } + } + /// Double-lined graphs + pub fn double() -> Self { + Characters { + chars: " ●○║═╬╚╔╗╝╣╠╩╦<>".chars().collect(), + } + } + /// ASCII-only graphs + pub fn ascii() -> Self { + Characters { + chars: " *o|-+'..'||++<>".chars().collect(), + } + } + + pub fn reverse(self) -> Self { + let mut chars = self.chars; + + chars.swap(6, 8); + chars.swap(7, 9); + chars.swap(10, 11); + chars.swap(12, 13); + chars.swap(14, 15); + + Characters { chars } + } +} From 04b3304f6984734e47d7df3d90cbe0e6f74dd397 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 9 Apr 2025 15:55:26 +0200 Subject: [PATCH 02/34] Avoid deprecated functions --- git-graph/src/print/format.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/git-graph/src/print/format.rs b/git-graph/src/print/format.rs index 0ca93206cc..56a19aebfa 100644 --- a/git-graph/src/print/format.rs +++ b/git-graph/src/print/format.rs @@ -1,6 +1,6 @@ //! Formatting of commits. -use chrono::{FixedOffset, Local, TimeZone}; +use chrono::{FixedOffset, TimeZone}; use git2::{Commit, Time}; use lazy_static::lazy_static; use std::fmt::Write; @@ -512,9 +512,12 @@ pub fn format( } pub fn format_date(time: Time, format: &str) -> String { - let date = - Local::from_offset(&FixedOffset::east(time.offset_minutes())).timestamp(time.seconds(), 0); - format!("{}", date.format(format)) + let offset = FixedOffset::east_opt(time.offset_minutes() * 60) + .expect("Invalid offset minutes"); + let date = offset.timestamp_opt(time.seconds(), 0) + .single() + .expect("Invalid timestamp, maybe a fold or gap in local time"); + date.format(format).to_string() } fn append_wrapped(vec: &mut Vec<String>, str: String, wrapping: &Option<Options>) { From abd54a0030c90a183d816e0f825e0417ead8e24a Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sun, 13 Apr 2025 07:48:11 +0200 Subject: [PATCH 03/34] Describe UnicodeGraphInfo --- git-graph/src/print/unicode.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/git-graph/src/print/unicode.rs b/git-graph/src/print/unicode.rs index 528188be59..85d864267e 100644 --- a/git-graph/src/print/unicode.rs +++ b/git-graph/src/print/unicode.rs @@ -33,7 +33,37 @@ const WHITE: u8 = 7; const HEAD_COLOR: u8 = 14; const HASH_COLOR: u8 = 11; +/// graph-lines, text-lines, start-row type UnicodeGraphInfo = (Vec<String>, Vec<String>, Vec<usize>); +/* +UnicodeGraphInfo is a type alias for a tuple containing three elements: + +1. `Vec<String>`: This represents the lines of the generated text-based graph + visualization. Each `String` in this vector corresponds to a single row of + the graph output, containing characters that form the visual representation + of the commit history (like lines, dots, and branch intersections). + +2. `Vec<String>`: This represents the lines of the commit messages or other + textual information associated with each commit in the graph. Each `String` + in this vector corresponds to a line of text that is displayed alongside + the graph. This can include commit hashes, author information, commit + messages, branch names, and tags, depending on the formatting settings. + Some entries in this vector might be empty strings or correspond to + inserted blank lines for visual spacing. + +3. `Vec<usize>`: This vector acts as an index map. Each `usize` in this + vector corresponds to the starting row index in the combined output + (graph lines + text lines) for a specific commit in the original + `graph.commits` vector. This allows you to easily find the visual + representation and associated text for a given commit by its index in + the `graph.commits` data structure. For example, if the `i`-th element + of `graph.commits` corresponds to a certain commit, then the `i`-th + element of this `Vec<usize>` will tell you at which row in the combined + output that commit's visual representation and text begin. This accounts + for potential extra lines inserted for commit messages that wrap or + for spacing. +*/ + /// Creates a text-based visual representation of a graph. pub fn print_unicode(graph: &GitGraph, settings: &Settings) -> Result<UnicodeGraphInfo, String> { From 6560a82ea13782052ae98c995ff8650a9e8a4a07 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Mon, 14 Apr 2025 17:21:46 +0200 Subject: [PATCH 04/34] Shorter and more to the point description --- git-graph/src/print/unicode.rs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/git-graph/src/print/unicode.rs b/git-graph/src/print/unicode.rs index 85d864267e..a9ab15ac7a 100644 --- a/git-graph/src/print/unicode.rs +++ b/git-graph/src/print/unicode.rs @@ -51,17 +51,7 @@ UnicodeGraphInfo is a type alias for a tuple containing three elements: Some entries in this vector might be empty strings or correspond to inserted blank lines for visual spacing. -3. `Vec<usize>`: This vector acts as an index map. Each `usize` in this - vector corresponds to the starting row index in the combined output - (graph lines + text lines) for a specific commit in the original - `graph.commits` vector. This allows you to easily find the visual - representation and associated text for a given commit by its index in - the `graph.commits` data structure. For example, if the `i`-th element - of `graph.commits` corresponds to a certain commit, then the `i`-th - element of this `Vec<usize>` will tell you at which row in the combined - output that commit's visual representation and text begin. This accounts - for potential extra lines inserted for commit messages that wrap or - for spacing. +3. `Vec<usize>`: Starting row for commit in the `graph.commits` vector. */ From dd3201096ac78ce4742bca5d074b27ddc84015be Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sun, 13 Apr 2025 08:04:04 +0200 Subject: [PATCH 05/34] Make git-graph a workspace member --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 67424121c3..80e69802a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ vendor-openssl = ["asyncgit/vendor-openssl"] members = [ "asyncgit", "filetreelist", + "git-graph", "git2-hooks", "git2-testing", "scopetime", From 0e89d8a179b5435f87d284419e392185f69f9ec1 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 06/34] Let versions reflect that code is being added. --- Cargo.toml | 3 ++- git-graph/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 80e69802a9..e6d6da17e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gitui" -version = "0.27.0" +version = "0.28.0-alpha" authors = ["extrawurst <mail@rusticorn.com>"] description = "blazing fast terminal-ui for git" edition = "2021" @@ -32,6 +32,7 @@ easy-cast = "0.5" filetreelist = { path = "./filetreelist", version = "0.5" } fuzzy-matcher = "0.3" gh-emoji = { version = "1.0", optional = true } +git-graph = { path = "./git-graph", version = "0.6.1-alpha" } indexmap = "2" itertools = "0.14" log = "0.4" diff --git a/git-graph/Cargo.toml b/git-graph/Cargo.toml index a4a3f2cef1..0080d29ab6 100644 --- a/git-graph/Cargo.toml +++ b/git-graph/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-graph" -version = "0.6.0" +version = "0.6.1-alpha" authors = ["Martin Lange <martin_lange_@gmx.net>"] description = "Command line tool to show clear git graphs arranged for your branching model" repository = "https://github.com/mlange-42/git-graph.git" From 55fc286f5a051497ecc830b9d3e481102ae0bccc Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sun, 13 Apr 2025 08:06:11 +0200 Subject: [PATCH 07/34] Sync git2 version --- git-graph/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git-graph/Cargo.toml b/git-graph/Cargo.toml index 0080d29ab6..7bb7e418ed 100644 --- a/git-graph/Cargo.toml +++ b/git-graph/Cargo.toml @@ -18,7 +18,7 @@ debug-assertions = false overflow-checks = false [dependencies] -git2 = {version = "0.15", default-features = false, optional = false} +git2 = {version = "0.20", default-features = false, optional = false} regex = {version = "1.7", default-features = false, optional = false, features = ["std"]} serde = "1.0" serde_derive = {version = "1.0", default-features = false, optional = false} From ae8748ad63308b009ea7a06be2bbd2dcd2eca2c0 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sun, 13 Apr 2025 08:39:28 +0200 Subject: [PATCH 08/34] Introduce start_point when traversing graph --- git-graph/src/graph.rs | 14 ++++++++++++-- git-graph/src/main.rs | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/git-graph/src/graph.rs b/git-graph/src/graph.rs index c658756f2f..3ec6e3345b 100644 --- a/git-graph/src/graph.rs +++ b/git-graph/src/graph.rs @@ -30,6 +30,7 @@ impl GitGraph { pub fn new( mut repository: Repository, settings: &Settings, + start_point: Option<String>, max_count: Option<usize>, ) -> Result<Self, String> { let mut stashes = HashSet::new(); @@ -47,8 +48,17 @@ impl GitGraph { walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) .map_err(|err| err.message().to_string())?; - walk.push_glob("*") - .map_err(|err| err.message().to_string())?; + // Use starting point if specified + if let Some(start) = start_point { + let object = repository + .revparse_single(&start) + .map_err(|err| format!("Failed to resolve start point '{}': {}", start, err))?; + walk.push(object.id()) + .map_err(|err| err.message().to_string())?; + } else { + walk.push_glob("*") + .map_err(|err| err.message().to_string())?; + } if repository.is_shallow() { return Err("ERROR: git-graph does not support shallow clones due to a missing feature in the underlying libgit2 library.".to_string()); diff --git a/git-graph/src/main.rs b/git-graph/src/main.rs index 6c1fbfc06c..d06b5f25e6 100644 --- a/git-graph/src/main.rs +++ b/git-graph/src/main.rs @@ -404,7 +404,7 @@ fn run( pager: bool, ) -> Result<(), String> { let now = Instant::now(); - let graph = GitGraph::new(repository, settings, max_commits)?; + let graph = GitGraph::new(repository, settings, None, max_commits)?; let duration_graph = now.elapsed().as_micros(); From 73449a48b7e63a13b2fb659d58d19c17b4b4acb8 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sun, 13 Apr 2025 15:03:18 +0200 Subject: [PATCH 09/34] Add feature that uses git-graph to show log --- src/components/commitlist.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index d45bec1739..b1f33323cf 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -35,6 +35,7 @@ use std::{ const ELEMENTS_PER_LINE: usize = 9; const SLICE_SIZE: usize = 1200; +const LOG_GRAPH_ENABLED: bool = false; /// pub struct CommitList { @@ -559,7 +560,27 @@ impl CommitList { Line::from(txt) } + /// Compute text displayed in commit list at current scroll offset fn get_text(&self, height: usize, width: usize) -> Vec<Line> { + if LOG_GRAPH_ENABLED { + self.get_text_graph(height, width) + } + else { + self.get_text_no_graph(height, width) + } + } + + fn get_text_graph(&self, height: usize, width: usize) -> Vec<Line> { + let mut txt: Vec<Line> = Vec::with_capacity(height); + for _i in 0..height { + let mut spans: Vec<Span> = vec![]; + spans.push(string_width_align("nothing here, move along", width) + .into()); + txt.push(Line::from(spans)); + } + txt + } + fn get_text_no_graph(&self, height: usize, width: usize) -> Vec<Line> { let selection = self.relative_selection(); let mut txt: Vec<Line> = Vec::with_capacity(height); From a46b9beec98c5fa742d10ed18943119ab0bca722 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sun, 13 Apr 2025 16:20:48 +0200 Subject: [PATCH 10/34] Hack to avoid adding git2 dependency to gitui --- git-graph/src/graph.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git-graph/src/graph.rs b/git-graph/src/graph.rs index 3ec6e3345b..d52cfd2a79 100644 --- a/git-graph/src/graph.rs +++ b/git-graph/src/graph.rs @@ -2,7 +2,7 @@ use crate::print::colors::to_terminal_color; use crate::settings::{BranchOrder, BranchSettings, MergePatterns, Settings}; -use git2::{BranchType, Commit, Error, Oid, Reference, Repository}; +pub use git2::{BranchType, Commit, Error, Oid, Reference, Repository}; use itertools::Itertools; use regex::Regex; use std::collections::{HashMap, HashSet}; From 880dd34dcd486b336fc5c8687ace1729b01b7228 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sun, 13 Apr 2025 16:22:44 +0200 Subject: [PATCH 11/34] add GitGraph member to CommitList --- src/components/commitlist.rs | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index b1f33323cf..e22849493b 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -19,6 +19,13 @@ use asyncgit::sync::{ }; use chrono::{DateTime, Local}; use crossterm::event::Event; +use git_graph::{ + graph::GitGraph, + graph::Repository as Graph_Repository, // A reexport of git2::Repository + settings::Settings as Graph_Settings, + settings::BranchSettingsDef, +}; +//use git2::{Repository as Graph_Repository}; use indexmap::IndexSet; use itertools::Itertools; use ratatui::{ @@ -44,6 +51,8 @@ pub struct CommitList { selection: usize, highlighted_selection: Option<usize>, items: ItemBatch, + /// The cached subset of commits and their graph + local_graph: GitGraph, highlights: Option<Rc<IndexSet<CommitId>>>, commits: IndexSet<CommitId>, marked: Vec<(usize, CommitId)>, @@ -58,9 +67,48 @@ pub struct CommitList { key_config: SharedKeyConfig, } +fn default_graph_settings() -> Graph_Settings { + use git_graph::print::format::CommitFormat; + use git_graph::settings::{BranchOrder, BranchSettings, Characters, MergePatterns}; + + let reverse_commit_order = false; + let debug = false; + let colored = true; + let compact = true; + let include_remote = false; + let format = CommitFormat::OneLine; + let wrapping = None; + let style = Characters::round(); + + Graph_Settings { + reverse_commit_order, + debug, + colored, + compact, + include_remote, + format, + wrapping, + characters: style, + branch_order: BranchOrder::ShortestFirst(true), + branches: BranchSettings::from( + BranchSettingsDef::git_flow() + ).expect("Could not use default branch settings git_flow"), + merge_patterns: MergePatterns::default(), + } +} + impl CommitList { /// pub fn new(env: &Environment, title: &str) -> Self { + + // Open the asyncgit repository as a git2 repository for GitGraph + let repo_binding = env.repo.borrow(); + let repo_path = repo_binding.gitpath(); + let git2_repo = match Graph_Repository::open(repo_path) { + Ok(repo) => repo, + Err(e) => panic!("Unable to open git2 repository: {}", e), // Handle this error properly in a real application + }; + Self { repo: env.repo.clone(), items: ItemBatch::default(), @@ -68,6 +116,12 @@ impl CommitList { selection: 0, highlighted_selection: None, commits: IndexSet::new(), + local_graph: GitGraph::new( + git2_repo, + &default_graph_settings(), + None, + Some(0) + ).expect("Unable to initialize GitGraph"), highlights: None, scroll_state: (Instant::now(), 0_f32), tags: None, From 1874cef3decd0575a6021101c995c6970a49755e Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sun, 13 Apr 2025 21:18:31 +0200 Subject: [PATCH 12/34] WIP first buggy display via GitGraph --- src/components/commitlist.rs | 57 +++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index e22849493b..9050a65410 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -22,6 +22,7 @@ use crossterm::event::Event; use git_graph::{ graph::GitGraph, graph::Repository as Graph_Repository, // A reexport of git2::Repository + print::unicode::print_unicode, settings::Settings as Graph_Settings, settings::BranchSettingsDef, }; @@ -42,7 +43,7 @@ use std::{ const ELEMENTS_PER_LINE: usize = 9; const SLICE_SIZE: usize = 1200; -const LOG_GRAPH_ENABLED: bool = false; +const LOG_GRAPH_ENABLED: bool = true; /// pub struct CommitList { @@ -52,7 +53,7 @@ pub struct CommitList { highlighted_selection: Option<usize>, items: ItemBatch, /// The cached subset of commits and their graph - local_graph: GitGraph, + //local_graph: GitGraph, highlights: Option<Rc<IndexSet<CommitId>>>, commits: IndexSet<CommitId>, marked: Vec<(usize, CommitId)>, @@ -100,7 +101,7 @@ fn default_graph_settings() -> Graph_Settings { impl CommitList { /// pub fn new(env: &Environment, title: &str) -> Self { - + /* // Open the asyncgit repository as a git2 repository for GitGraph let repo_binding = env.repo.borrow(); let repo_path = repo_binding.gitpath(); @@ -108,6 +109,7 @@ impl CommitList { Ok(repo) => repo, Err(e) => panic!("Unable to open git2 repository: {}", e), // Handle this error properly in a real application }; + */ Self { repo: env.repo.clone(), @@ -116,12 +118,14 @@ impl CommitList { selection: 0, highlighted_selection: None, commits: IndexSet::new(), + /* local_graph: GitGraph::new( git2_repo, &default_graph_settings(), None, Some(0) ).expect("Unable to initialize GitGraph"), + */ highlights: None, scroll_state: (Instant::now(), 0_f32), tags: None, @@ -624,14 +628,53 @@ impl CommitList { } } - fn get_text_graph(&self, height: usize, width: usize) -> Vec<Line> { + fn get_text_graph(&self, height: usize, _width: usize) -> Vec<Line> { + // Fetch visible part of log from repository + // TODO Do not build graph every time it is drawn + // instead, update self.local_graph cache to hold those needed + // for the current display. + + // Open the asyncgit repository as a git2 repository for GitGraph + let repo_binding = self.repo.borrow(); + let repo_path = repo_binding.gitpath(); + let git2_repo = match Graph_Repository::open(repo_path) { + Ok(repo) => repo, + Err(e) => panic!("Unable to open git2 repository: {}", e), // Handle this error properly in a real application + }; + + // Find window of commits visible + let skip_commmits = self.scroll_top.get(); + let batch = &self.items; + let skip_items = skip_commmits - batch.index_offset(); + let mut start_rev: String = "HEAD".to_string(); + if let Some(start_log_entry) = batch.iter().skip(skip_items).next() { + start_rev = start_log_entry.id.get_short_string(); + } + let graph_settings = default_graph_settings(); + let local_graph = GitGraph::new( + git2_repo, + &graph_settings, + Some(start_rev), + Some(height) + ).expect("Unable to initialize GitGraph"); + + // Format commits as text + let (graph_lines, text_lines, _start_row) + = print_unicode(&local_graph, &graph_settings) + .expect("Unable to print GitGraph as unicode"); + + // MOCK Format commits as text let mut txt: Vec<Line> = Vec::with_capacity(height); - for _i in 0..height { + for i in 0..height { let mut spans: Vec<Span> = vec![]; - spans.push(string_width_align("nothing here, move along", width) - .into()); + spans.push(Span::raw(graph_lines[i].clone())); + spans.push(Span::raw(text_lines[i].clone())); + //spans.push(string_width_align("nothing here, move along", width) + // .into()); txt.push(Line::from(spans)); } + + // Return lines txt } fn get_text_no_graph(&self, height: usize, width: usize) -> Vec<Line> { From 4991a7e1dc0bf5d2fc3d173bc6d9246bbe95eb1f Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Mon, 14 Apr 2025 17:21:57 +0200 Subject: [PATCH 13/34] Update Cargo.lock --- Cargo.lock | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 149 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 22d53743a2..4765643d89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,6 +190,17 @@ dependencies = [ "url", ] +[[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" @@ -416,7 +427,9 @@ checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-link", ] @@ -557,6 +570,22 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -565,7 +594,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.9.0", "crossterm_winapi", - "mio", + "mio 1.0.3", "parking_lot", "rustix", "serde", @@ -721,6 +750,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf36e65a80337bea855cd4ef9b8401ffce06a7baedf2e85ec467b1ac3f6e82b6" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -733,6 +772,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1097,6 +1147,27 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "git-graph" +version = "0.6.0" +dependencies = [ + "atty", + "chrono", + "clap", + "crossterm 0.25.0", + "git2", + "itertools 0.10.5", + "lazy_static", + "platform-dirs", + "regex", + "serde", + "serde_derive", + "svg", + "textwrap", + "toml", + "yansi 0.5.1", +] + [[package]] name = "git-version" version = "0.3.9" @@ -1170,13 +1241,14 @@ dependencies = [ "chrono", "clap", "crossbeam-channel", - "crossterm", + "crossterm 0.28.1", "dirs", "easy-cast", "env_logger", "filetreelist", "fuzzy-matcher", "gh-emoji", + "git-graph", "indexmap", "itertools 0.14.0", "log", @@ -1817,6 +1889,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 = "hmac" version = "0.12.1" @@ -2078,6 +2159,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2336,6 +2426,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.3" @@ -2361,7 +2463,7 @@ dependencies = [ "kqueue", "libc", "log", - "mio", + "mio 1.0.3", "notify-types", "walkdir", "windows-sys 0.59.0", @@ -2672,6 +2774,15 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "platform-dirs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e188d043c1a692985f78b5464853a263f1a27e5bd6322bad3a4078ee3c998a38" +dependencies = [ + "dirs-next", +] + [[package]] name = "plist" version = "1.7.0" @@ -2745,7 +2856,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" dependencies = [ "diff", - "yansi", + "yansi 1.0.1", ] [[package]] @@ -2832,7 +2943,7 @@ dependencies = [ "bitflags 2.9.0", "cassowary", "compact_str", - "crossterm", + "crossterm 0.28.1", "indoc", "instability", "itertools 0.13.0", @@ -3188,7 +3299,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 0.8.11", + "mio 1.0.3", "signal-hook", ] @@ -3375,6 +3487,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svg" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e6ff893392e6a1eb94a210562432c6380cebf09d30962a012a655f7dde2ff8" + [[package]] name = "syn" version = "2.0.100" @@ -3447,6 +3565,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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" @@ -3553,13 +3680,22 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "tui-textarea" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" dependencies = [ - "crossterm", + "crossterm 0.28.1", "ratatui", "unicode-width 0.2.0", ] @@ -4058,6 +4194,12 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "yansi" version = "1.0.1" From 130a177adeb1569e95f2e167b800cb78eb5c5c3f Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:18:38 +0200 Subject: [PATCH 14/34] Add TODO octopus support --- git-graph/src/graph.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git-graph/src/graph.rs b/git-graph/src/graph.rs index d52cfd2a79..fa33959bfe 100644 --- a/git-graph/src/graph.rs +++ b/git-graph/src/graph.rs @@ -213,7 +213,7 @@ impl HeadInfo { pub struct CommitInfo { pub oid: Oid, pub is_merge: bool, - pub parents: [Option<Oid>; 2], + pub parents: [Option<Oid>; 2], // TODO change to Vec for octopus merge pub children: Vec<Oid>, pub branches: Vec<usize>, pub tags: Vec<usize>, From f450bafcc924f537595a4af9d2c40478dc22484e Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 15/34] Cargo.lock reflects alpha status --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4765643d89..0896a004f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1149,7 +1149,7 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "git-graph" -version = "0.6.0" +version = "0.6.1-alpha" dependencies = [ "atty", "chrono", @@ -1228,7 +1228,7 @@ dependencies = [ [[package]] name = "gitui" -version = "0.27.0" +version = "0.28.0-alpha" dependencies = [ "anyhow", "asyncgit", From 0444f83884556d630ef4520bfbc09e56104deac1 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 16/34] Document Grid usage --- git-graph/src/print/unicode.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/git-graph/src/print/unicode.rs b/git-graph/src/print/unicode.rs index a9ab15ac7a..6a7de36dc6 100644 --- a/git-graph/src/print/unicode.rs +++ b/git-graph/src/print/unicode.rs @@ -693,11 +693,17 @@ fn sorted(v1: usize, v2: usize) -> (usize, usize) { } } -/// Two-dimensional grid with 3 layers, used to produce the graph representation. +/// Two-dimensional grid used to produce the graph representation. #[allow(dead_code)] struct Grid { width: usize, height: usize, + + /// Grid cells are stored in the data vector, layout row wise. + /// For each cell in the grid, three values are stored: + /// - Character (symbol) + /// - Colour + /// - Persistence level (z-order, lower numbers take preceedence) data: Vec<[u8; 3]>, } @@ -713,6 +719,7 @@ impl Grid { pub fn reverse(&mut self) { self.data.reverse(); } + /// Turn a 2D coordinate into an index of Grid.data pub fn index(&self, x: usize, y: usize) -> usize { y * self.width + x } From 294e090f0667222470c2408d438c4600a8615461 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 17/34] Remove "nothing to see here, move along". This should be merged with the commit that replaces dummy code with first attempt --- src/components/commitlist.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 9050a65410..36b33a7e85 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -669,8 +669,6 @@ impl CommitList { let mut spans: Vec<Span> = vec![]; spans.push(Span::raw(graph_lines[i].clone())); spans.push(Span::raw(text_lines[i].clone())); - //spans.push(string_width_align("nothing here, move along", width) - // .into()); txt.push(Line::from(spans)); } From b064f0968bfc564a41ccb5e37c1ea200b9b81e27 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 18/34] Document struct Occ --- git-graph/src/print/unicode.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/git-graph/src/print/unicode.rs b/git-graph/src/print/unicode.rs index 6a7de36dc6..2358af023d 100644 --- a/git-graph/src/print/unicode.rs +++ b/git-graph/src/print/unicode.rs @@ -671,8 +671,19 @@ pub fn format_branches( /// Occupied row ranges enum Occ { - Commit(usize, usize), - Range(usize, usize, usize, usize), + /// Horizontal position of commit markers + // First field (usize): The index of a commit within the graph.commits vector. + // Second field (usize): The visual column in the grid where this commit is located. This column is determined by the branch the commit belongs to. + // Purpose: This variant of Occ signifies that a specific row in the grid is occupied by a commit marker (dot or circle) at a particular column. + Commit(usize, usize), // index in Graph.commits, column + + /// Horizontal line connecting two commits + // First field (usize): The index of the starting commit of a visual connection (usually the child commit). + // Second field (usize): The index of the ending commit of a visual connection (usually the parent commit). + // Third field (usize): The starting visual column of the range occupied by the connection line between the two commits. This is the minimum of the columns of the two connected commits. + // Fourth field (usize): The ending visual column of the range occupied by the connection line between the two commits. This is the maximum of the columns of the two connected commits. + // Purpose: This variant of Occ signifies that a range of columns in a particular row is occupied by a horizontal line segment connecting a commit to one of its parents. The range spans from the visual column of one commit to the visual column of the other. + Range(usize, usize, usize, usize), // ?child index, parent index, leftmost column, rightmost column } impl Occ { From 0c7b5ebebc316ba541d76675cb07fddff56e09c6 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 19/34] Explain the index terminology --- src/components/commitlist.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 36b33a7e85..4a92c10674 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -630,6 +630,19 @@ impl CommitList { fn get_text_graph(&self, height: usize, _width: usize) -> Vec<Line> { // Fetch visible part of log from repository + // We have a number of index here + // document line = the line number as seen by the user, assuming we + // are scrolling over a large list of lines. Note that one commit + // may take more than one line to show. + // commit index = the number of commits from the youngest commit + // screen row = the vertical distance from the top of the view window + // + // The link between commit index and document line is + // start_row[commit_index] = document line for commit + // The link between screen row and document line is + // screen row = document line - scroll top + + // TODO Do not build graph every time it is drawn // instead, update self.local_graph cache to hold those needed // for the current display. From 5ec605c9ad3507fef5c4e04e5d096dcc5b176b53 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 20/34] Remove CommitList::marked() to avoid exposing internal structure --- src/components/commitlist.rs | 9 --------- src/tabs/revlog.rs | 8 ++++---- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 4a92c10674..461eea5f68 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -173,15 +173,6 @@ impl CommitList { self.marked.len() } - /// - #[expect( - clippy::missing_const_for_fn, - reason = "as of 1.86.0 clippy wants this to be const even though that breaks" - )] - pub fn marked(&self) -> &[(usize, CommitId)] { - &self.marked - } - /// pub fn clear_marked(&mut self) { self.marked.clear(); diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index ff4f9a0f95..74f7d1b0cc 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -586,19 +586,19 @@ impl Component for Revlog { self.queue.push(InternalEvent::OpenPopup( StackablePopupOpen::CompareCommits( InspectCommitOpen::new( - self.list.marked()[0].1, + self.list.marked_commits()[0], ), ), )); return Ok(EventState::Consumed); } else if self.list.marked_count() == 2 { //compare two marked commits - let marked = self.list.marked(); + let marked = self.list.marked_commits(); self.queue.push(InternalEvent::OpenPopup( StackablePopupOpen::CompareCommits( InspectCommitOpen { - commit_id: marked[0].1, - compare_id: Some(marked[1].1), + commit_id: marked[0], + compare_id: Some(marked[1]), tags: None, }, ), From cdd2001da6d3eba5b01b7291a8dde94a35445dea Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Tue, 29 Apr 2025 07:43:38 +0200 Subject: [PATCH 21/34] Document tuple used in CommitList.marked --- src/components/commitlist.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 461eea5f68..8cdfc2b642 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -56,6 +56,8 @@ pub struct CommitList { //local_graph: GitGraph, highlights: Option<Rc<IndexSet<CommitId>>>, commits: IndexSet<CommitId>, + /// Commits that are marked. The .0 is used to provide a sort order. + /// It contains an index into self.items.items marked: Vec<(usize, CommitId)>, scroll_state: (Instant, f32), tags: Option<Tags>, From 0af1afdfdca4dcea70b24c6efd998761cbf6efdc Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 22/34] TEMP - Ignore build log --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 715365c3bb..bed5202eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .DS_Store /.idea/ flamegraph.svg +build.log From 34244a88128cbb15b0fdce8ccd1f216321fd1326 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 23/34] Improve description of logitems.rs --- src/components/utils/logitems.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs index 4c980b65fa..9e43245102 100644 --- a/src/components/utils/logitems.rs +++ b/src/components/utils/logitems.rs @@ -79,7 +79,7 @@ impl LogEntry { } } -/// +/// A subset of commits decoded and ready for printing #[derive(Default)] pub struct ItemBatch { index_offset: Option<usize>, @@ -144,7 +144,7 @@ impl ItemBatch { } } - /// returns `true` if we should fetch updated list of items + /// Is idx within reload offset of either top or bottom of commits. pub fn needs_data(&self, idx: usize, idx_max: usize) -> bool { let want_min = idx.saturating_sub(SLICE_OFFSET_RELOAD_THRESHOLD); From 146734a0ab6292b17cf4558967ea2161005a7144 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 24/34] Gemini AI generated comments for git-graph --- git-graph/src/print/unicode.rs | 64 +++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/git-graph/src/print/unicode.rs b/git-graph/src/print/unicode.rs index 2358af023d..5179fb7356 100644 --- a/git-graph/src/print/unicode.rs +++ b/git-graph/src/print/unicode.rs @@ -418,43 +418,96 @@ fn hline( } } -/// Calculates required additional rows +/// Calculates required additional rows to visually connect commits that are not direct +/// descendants in the main commit list. These "inserts" represent the horizontal lines +/// in the graph. +/// +/// # Arguments +/// +/// * `graph`: A reference to the `GitGraph` structure containing the commit and branch information. +/// * `compact`: A boolean indicating whether to use a compact layout, potentially merging some +/// insertions with commits. +/// +/// # Returns +/// +/// A `HashMap` where the keys are the indices of commits in the `graph.commits` vector, and +/// the values are vectors of vectors of `Occ`. Each inner vector represents a potential row +/// of insertions needed *before* the commit at the key index. The `Occ` enum describes what +/// occupies a cell in that row (either a commit or a range representing a connection). fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> { + // Initialize an empty HashMap to store the required insertions. The key is the commit + // index, and the value is a vector of rows, where each row is a vector of Occupations (`Occ`). let mut inserts: HashMap<usize, Vec<Vec<Occ>>> = HashMap::new(); + // First, for each commit, we initialize an entry in the `inserts` map with a single row + // containing the commit itself. This ensures that every commit has a position in the grid. for (idx, info) in graph.commits.iter().enumerate() { + // Get the visual column assigned to the branch of this commit. Unwrap is safe here + // because `branch_trace` should always point to a valid branch with an assigned column + // for commits that are included in the filtered graph. let column = graph.all_branches[info.branch_trace.unwrap()] .visual .column .unwrap(); + // For each commit index, create a new entry in the `inserts` map with a vector containing + // a single row. This row contains the `Occ::Commit` variant, indicating that at this + // index and column, a commit exists. inserts.insert(idx, vec![vec![Occ::Commit(idx, column)]]); } + // Now, iterate through the commits again to identify connections needed between parents + // that are not directly adjacent in the `graph.commits` list. for (idx, info) in graph.commits.iter().enumerate() { + // If the commit has a branch trace (meaning it belongs to a visualized branch). if let Some(trace) = info.branch_trace { + // Get the `BranchInfo` for the current commit's branch. let branch = &graph.all_branches[trace]; + // Get the visual column of the current commit's branch. Unwrap is safe as explained above. let column = branch.visual.column.unwrap(); + // Iterate through the two possible parents of the current commit. for p in 0..2 { + // If the commit has a parent at this index (0 for the first parent, 1 for the second). if let Some(par_oid) = info.parents[p] { + // Try to find the index of the parent commit in the `graph.commits` vector. if let Some(par_idx) = graph.indices.get(&par_oid) { + // Get the `CommitInfo` for the parent commit. let par_info = &graph.commits[*par_idx]; + // Get the `BranchInfo` for the parent commit's branch. Unwrap is safe. let par_branch = &graph.all_branches[par_info.branch_trace.unwrap()]; + // Get the visual column of the parent commit's branch. Unwrap is safe. let par_column = par_branch.visual.column.unwrap(); + // Determine the sorted range of columns between the current commit and its parent. let column_range = sorted(column, par_column); + // If the column of the current commit is different from the column of its parent, + // it means we need to draw a horizontal line (an "insert") to connect them. if column != par_column { + // Find the index in the `graph.commits` list where the visual connection + // should deviate from the parent's line. This helps in drawing the graph + // correctly when branches diverge or merge. let split_index = super::get_deviate_index(graph, idx, *par_idx); + // Access the entry in the `inserts` map for the `split_index`. match inserts.entry(split_index) { + // If there's already an entry at this `split_index` (meaning other + // insertions might be needed before this commit). Occupied(mut entry) => { + // Find the first available row in the existing vector of rows + // where the new range doesn't overlap with existing occupations. let mut insert_at = entry.get().len(); for (insert_idx, sub_entry) in entry.get().iter().enumerate() { let mut occ = false; + // Check for overlaps with existing `Occ` in the current row. for other_range in sub_entry { + // Check if the current column range overlaps with the other range. if other_range.overlaps(&column_range) { match other_range { + // If the other occupation is a commit. Occ::Commit(target_index, _) => { + // In compact mode, we might allow overlap with the commit itself + // for merge commits (specifically the second parent) to keep the + // graph tighter. if !compact || !info.is_merge || idx != *target_index @@ -464,7 +517,9 @@ fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> break; } } + // If the other occupation is a range (another connection). Occ::Range(o_idx, o_par_idx, _, _) => { + // Avoid overlap with connections between the same commits. if idx != *o_idx && par_idx != o_par_idx { occ = true; break; @@ -473,12 +528,15 @@ fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> } } } + // If no overlap is found in this row, we can insert here. if !occ { insert_at = insert_idx; break; } } + // Get a mutable reference to the vector of rows for this `split_index`. let vec = entry.get_mut(); + // If no suitable row was found, add a new row. if insert_at == vec.len() { vec.push(vec![Occ::Range( idx, @@ -487,6 +545,7 @@ fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> column_range.1, )]); } else { + // Otherwise, insert the new range into the found row. vec[insert_at].push(Occ::Range( idx, *par_idx, @@ -495,7 +554,9 @@ fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> )); } } + // If there's no entry at this `split_index` yet. Vacant(entry) => { + // Create a new entry with a single row containing the range. entry.insert(vec![vec![Occ::Range( idx, *par_idx, @@ -511,6 +572,7 @@ fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> } } + // Return the map of required insertions. inserts } From b311a97e99443ba1048c58d4df1c2ec30f5bd316 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 23 Apr 2025 06:10:01 +0200 Subject: [PATCH 25/34] Describe git-graph algorithm via inline comments --- git-graph/src/print/unicode.rs | 43 +++++++++++++++++----------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/git-graph/src/print/unicode.rs b/git-graph/src/print/unicode.rs index 5179fb7356..ea2c6eda71 100644 --- a/git-graph/src/print/unicode.rs +++ b/git-graph/src/print/unicode.rs @@ -81,7 +81,8 @@ pub fn print_unicode(graph: &GitGraph, settings: &Settings) -> Result<UnicodeGra None }; - // Compute commit text into text_lines + // Compute commit text into text_lines and add blank rows + // if needed to match branch graph inserts. let mut index_map = vec![]; let mut text_lines = vec![]; let mut offset = 0; @@ -418,29 +419,34 @@ fn hline( } } -/// Calculates required additional rows to visually connect commits that are not direct -/// descendants in the main commit list. These "inserts" represent the horizontal lines -/// in the graph. +/// Calculates required additional rows to visually connect commits that +/// are not direct descendants in the main commit list. These "inserts" +// represent the horizontal lines in the graph. /// /// # Arguments /// -/// * `graph`: A reference to the `GitGraph` structure containing the commit and branch information. -/// * `compact`: A boolean indicating whether to use a compact layout, potentially merging some -/// insertions with commits. +/// * `graph`: A reference to the `GitGraph` structure containing the +// commit and branch information. +/// * `compact`: A boolean indicating whether to use a compact layout, +// potentially merging some insertions with commits. /// /// # Returns /// -/// A `HashMap` where the keys are the indices of commits in the `graph.commits` vector, and -/// the values are vectors of vectors of `Occ`. Each inner vector represents a potential row -/// of insertions needed *before* the commit at the key index. The `Occ` enum describes what -/// occupies a cell in that row (either a commit or a range representing a connection). +/// A `HashMap` where the keys are the indices of commits in the +/// `graph.commits` vector, and the values are vectors of vectors +/// of `Occ`. Each inner vector represents a potential row of +/// insertions needed *before* the commit at the key index. The +/// `Occ` enum describes what occupies a cell in that row +/// (either a commit or a range representing a connection). +/// fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> { // Initialize an empty HashMap to store the required insertions. The key is the commit // index, and the value is a vector of rows, where each row is a vector of Occupations (`Occ`). let mut inserts: HashMap<usize, Vec<Vec<Occ>>> = HashMap::new(); - // First, for each commit, we initialize an entry in the `inserts` map with a single row - // containing the commit itself. This ensures that every commit has a position in the grid. + // First, for each commit, we initialize an entry in the `inserts` + // map with a single row containing the commit itself. This ensures + // that every commit has a position in the grid. for (idx, info) in graph.commits.iter().enumerate() { // Get the visual column assigned to the branch of this commit. Unwrap is safe here // because `branch_trace` should always point to a valid branch with an assigned column @@ -450,14 +456,12 @@ fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> .column .unwrap(); - // For each commit index, create a new entry in the `inserts` map with a vector containing - // a single row. This row contains the `Occ::Commit` variant, indicating that at this - // index and column, a commit exists. inserts.insert(idx, vec![vec![Occ::Commit(idx, column)]]); } - // Now, iterate through the commits again to identify connections needed between parents - // that are not directly adjacent in the `graph.commits` list. + // Now, iterate through the commits again to identify connections + // needed between parents that are not directly adjacent in the + // `graph.commits` list. for (idx, info) in graph.commits.iter().enumerate() { // If the commit has a branch trace (meaning it belongs to a visualized branch). if let Some(trace) = info.branch_trace { @@ -472,11 +476,8 @@ fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> if let Some(par_oid) = info.parents[p] { // Try to find the index of the parent commit in the `graph.commits` vector. if let Some(par_idx) = graph.indices.get(&par_oid) { - // Get the `CommitInfo` for the parent commit. let par_info = &graph.commits[*par_idx]; - // Get the `BranchInfo` for the parent commit's branch. Unwrap is safe. let par_branch = &graph.all_branches[par_info.branch_trace.unwrap()]; - // Get the visual column of the parent commit's branch. Unwrap is safe. let par_column = par_branch.visual.column.unwrap(); // Determine the sorted range of columns between the current commit and its parent. let column_range = sorted(column, par_column); From 175ad1fed76c7aff2ac6636582889791249dbe27 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 26/34] TEMP - Enable logging to a file (and name threads .. could be a separate commit) --- .gitignore | 1 + Cargo.lock | 21 +++++++++++++++++++++ Cargo.toml | 2 +- src/input.rs | 20 ++++++++++++-------- src/main.rs | 36 ++++++++++++++++++++++++++++++++++++ src/watcher.rs | 14 ++++++++++---- 6 files changed, 81 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index bed5202eaf..93ba0edd00 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /.idea/ flamegraph.svg build.log +gitui.log diff --git a/Cargo.lock b/Cargo.lock index 0896a004f8..1ff257486a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2549,6 +2549,15 @@ dependencies = [ "libm", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.36.7" @@ -3330,6 +3339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" dependencies = [ "log", + "termcolor", "time", ] @@ -3565,6 +3575,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -3632,7 +3651,9 @@ checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde", "time-core", diff --git a/Cargo.toml b/Cargo.toml index e6d6da17e9..69c2ddb243 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ scopeguard = "1.2" scopetime = { path = "./scopetime", version = "0.1" } serde = "1.0" shellexpand = "3.1" -simplelog = { version = "0.12", default-features = false } +simplelog = { version = "0.12", default-features = true } struct-patch = "0.9" syntect = { version = "5.2", default-features = false, features = [ "parsing", diff --git a/src/input.rs b/src/input.rs index 3ef7c0414b..e43df64b3a 100644 --- a/src/input.rs +++ b/src/input.rs @@ -50,14 +50,18 @@ impl Input { let arc_current = Arc::clone(¤t_state); let arc_aborted = Arc::clone(&aborted); - thread::spawn(move || { - if let Err(e) = - Self::input_loop(&arc_desired, &arc_current, &tx) - { - log::error!("input thread error: {}", e); - arc_aborted.store(true, Ordering::SeqCst); - } - }); + + thread::Builder::new() + .name("InputLoop".to_string()) + .spawn(move || { + if let Err(e) = + Self::input_loop(&arc_desired, &arc_current, &tx) + { + log::error!("input thread error: {}", e); + arc_aborted.store(true, Ordering::SeqCst); + } + }) + .expect("Failed to spawn input thread"); Self { receiver: rx, diff --git a/src/main.rs b/src/main.rs index feea894491..4029238aec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,6 +67,7 @@ use keys::KeyConfig; use ratatui::backend::CrosstermBackend; use scopeguard::defer; use scopetime::scope_time; +use simplelog; use spinner::Spinner; use std::{ cell::RefCell, @@ -120,11 +121,46 @@ enum Updater { NotifyWatcher, } +fn init_log() { + const LOG_TO_FILE: bool = true; + + if !LOG_TO_FILE { + // Set up log to terminal + simplelog::TermLogger::init( + simplelog::LevelFilter::Trace, // Set the log level + simplelog::Config::default(), // Use default log configuration + simplelog::TerminalMode::Mixed, // Output to both stdout and stderr + simplelog::ColorChoice::Auto, // Enable colored output if supported + ) + .expect("Failed to initialize logger"); + } else { + // Set up log to file + use std::fs::File; + let file = File::create("gitui.log").expect("Failed to create log file"); + + simplelog::WriteLogger::init( + simplelog::LevelFilter::Trace, + simplelog::Config::default(), + file, + ) + .expect("Failed to initialize logger"); + } + + // Example log messages + log::trace!("This is a trace message."); + log::debug!("This is a debug message."); + log::info!("This is an info message."); + log::warn!("This is a warning."); + log::error!("This is an error."); +} + fn main() -> Result<()> { let app_start = Instant::now(); let cliargs = process_cmdline()?; + init_log(); + asyncgit::register_tracing_logging(); if !valid_path(&cliargs.repo_path) { diff --git a/src/watcher.rs b/src/watcher.rs index 9c557b92bb..8c353dcf3d 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -20,19 +20,25 @@ impl RepoWatcher { let workdir = workdir.to_string(); - thread::spawn(move || { + thread::Builder::new() + .name("WatcherThread".to_string()) + .spawn(move || { let timeout = Duration::from_secs(2); create_watcher(timeout, tx, &workdir); - }); + }) + .expect("Failed to spawn thread"); let (out_tx, out_rx) = unbounded(); - thread::spawn(move || { + thread::Builder::new() + .name("ForwarderThread".to_string()) + .spawn(move || { if let Err(e) = Self::forwarder(&rx, &out_tx) { //maybe we need to restart the forwarder now? log::error!("notify receive error: {}", e); } - }); + }) + .expect("Failed to spawn thread"); Self { receiver: out_rx } } From bf7eab394f257060182c189528b56b92de77500a Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 27/34] Introduce document iterator --- src/components/commitlist.rs | 245 ++++++++++++++++++++++++++++------- 1 file changed, 197 insertions(+), 48 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 8cdfc2b642..f287827053 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -37,6 +37,7 @@ use ratatui::{ Frame, }; use std::{ +// ops::Add, borrow::Cow, cell::Cell, cmp, collections::BTreeMap, rc::Rc, time::Instant, }; @@ -45,15 +46,101 @@ const ELEMENTS_PER_LINE: usize = 9; const SLICE_SIZE: usize = 1200; const LOG_GRAPH_ENABLED: bool = true; + + +/* + +When navigating commits CommitList has two concepts of address +- commmit index + The commit id of all commits in the repository will be stored + in self.commits. The commit index points into this. +- document line + When all commits are formatted there may be a number of lines + for each commit. All commits together form a text document. + This is an index into the text document. + +Note that the text document is not created, because it would be too +large for large repositories. All commites are always listed. + +The UI shows a window on the text document and the document is created +on the fly, and discarded when no longer needed. + +Instead of generating lines in the window one at a time, a number of lines +is generated and stored in a cache. This cache begins before the ui window +and ends after it, but it only covers the full document on small repo. + +A document line is formed from a commit index and an offset into the +text lines for this particular commit. If the commit is not in the cache, +the offset will be None. + +*/ + + +/// Index into CommitList.commits +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct CommitIndex(usize); + +impl From<CommitIndex> for usize { + fn from(ci: CommitIndex) -> Self { + ci.0 + } +} + +/// Offset into formatted commit generate by git-graph +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct LineOfs(usize); + +impl From<LineOfs> for usize { + fn from(ofs: LineOfs) -> Self { + ofs.0 + } +} + +/// Index into the document that we scroll through. +/// It has two levels of accuracy: At a commit, or at a line within +/// a formatted commit. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct DocLine { + /// Index into CommitList.commits + pub commit_index: CommitIndex, + /// Index into lines generated for the commit + pub line_offset: Option<LineOfs>, +} + +impl DocLine { + pub fn at_commit(commit_index: usize) -> Self { + Self { + commit_index: CommitIndex(commit_index), + line_offset: None, + } + } + /* + pub fn at_line(commit_index: usize, line_offset: usize) -> Self { + Self { + commit_index: CommitIndex(commit_index), + line_offset: Some(LineOfs(line_offset)), + } + } + */ +} + + + + /// pub struct CommitList { repo: RepoPathRef, title: Box<str>, - selection: usize, + selection: DocLine, /// Document row of cursor + + /// Index into self.highlights, if selection is also a highlight highlighted_selection: Option<usize>, + items: ItemBatch, /// The cached subset of commits and their graph - //local_graph: GitGraph, + //local_graph: GitGraph, // TODO store here, once we implement caching + + /// Highlighted commits highlights: Option<Rc<IndexSet<CommitId>>>, commits: IndexSet<CommitId>, /// Commits that are marked. The .0 is used to provide a sort order. @@ -64,7 +151,7 @@ pub struct CommitList { local_branches: BTreeMap<CommitId, Vec<BranchInfo>>, remote_branches: BTreeMap<CommitId, Vec<BranchInfo>>, current_size: Cell<Option<(u16, u16)>>, - scroll_top: Cell<usize>, + scroll_top: Cell<DocLine>, theme: SharedTheme, queue: Queue, key_config: SharedKeyConfig, @@ -117,7 +204,7 @@ impl CommitList { repo: env.repo.clone(), items: ItemBatch::default(), marked: Vec::with_capacity(2), - selection: 0, + selection: DocLine::default(), highlighted_selection: None, commits: IndexSet::new(), /* @@ -134,7 +221,7 @@ impl CommitList { local_branches: BTreeMap::default(), remote_branches: BTreeMap::default(), current_size: Cell::new(None), - scroll_top: Cell::new(0), + scroll_top: Cell::new(DocLine::default()), theme: env.theme.clone(), queue: env.queue.clone(), key_config: env.key_config.clone(), @@ -142,6 +229,43 @@ impl CommitList { } } + /// The number of lines in the document + fn last_docline(&self) -> DocLine { + DocLine::at_commit(self.commits.len().saturating_sub(1)) + } + + /// Compute a DocLine a number of lines before + fn docline_saturating_sub(&self, doc_line: DocLine, ofs: usize) -> DocLine { + // TODO If line-formatting is available, do accurate movement + // FAKE always do commit-level movement + DocLine::at_commit( + doc_line.commit_index.0 + .saturating_sub(ofs)) + } + /// Compute a DocLine a number of lines after + fn docline_saturating_add(&self, doc_line: DocLine, ofs: usize) -> DocLine { + // TODO If line-formatting is available, do accurate movement + // FAKE always do commit-level movement + DocLine::at_commit( + doc_line.commit_index.0 + .saturating_add(ofs)) + } + + /// Find DocLine before current selection + fn selection_saturating_sub(&self, ofs: usize) -> DocLine { + self.docline_saturating_sub(self.selection, ofs) + } + /// Find DocLine after current selection + fn selection_saturating_add(&self, ofs: usize) -> DocLine { + self.docline_saturating_add(self.selection, ofs) + } + + /// CommitId of selected commit + pub fn selected_commit_id(&self) -> CommitId { + let inx: usize = self.selection.commit_index.into(); + self.commits[inx] + } + /// pub const fn tags(&self) -> Option<&Tags> { self.tags.as_ref() @@ -165,8 +289,9 @@ impl CommitList { /// pub fn selected_entry(&self) -> Option<&LogEntry> { + let sel_commit_inx: usize = self.selection.commit_index.into(); self.items.iter().nth( - self.selection.saturating_sub(self.items.index_offset()), + sel_commit_inx.saturating_sub(self.items.index_offset()), ) } @@ -190,12 +315,14 @@ impl CommitList { /// Build string of marked or selected (if none are marked) commit ids fn concat_selected_commit_ids(&self) -> Option<String> { + let selection_commit_inx: usize = + self.selection.commit_index.into(); match self.marked.as_slice() { [] => self .items .iter() .nth( - self.selection + selection_commit_inx .saturating_sub(self.items.index_offset()), ) .map(|e| e.id.to_string()), @@ -280,6 +407,9 @@ impl CommitList { let selection = self.selection(); let selection_max = self.selection_max(); + let selection = selection.commit_index; + let selection_max = selection_max.commit_index; + if self.needs_data(selection, selection_max) || new_commits { self.fetch_commits(false); } @@ -310,7 +440,7 @@ impl CommitList { let index = self.commits.get_index_of(&id); if let Some(index) = index { - self.selection = index; + self.selection = DocLine::at_commit(index); self.set_highlighted_selection_index(); Ok(()) } else { @@ -329,15 +459,16 @@ impl CommitList { } fn set_highlighted_selection_index(&mut self) { + let selected_commit_id = self.selected_commit_id(); self.highlighted_selection = self.highlights.as_ref().and_then(|highlights| { highlights.iter().position(|entry| { - entry == &self.commits[self.selection] + *entry == selected_commit_id }) }); } - const fn selection(&self) -> usize { + const fn selection(&self) -> DocLine { self.selection } @@ -346,8 +477,9 @@ impl CommitList { self.current_size.get() } - fn selection_max(&self) -> usize { - self.commits.len().saturating_sub(1) + /// Last document line with content + fn selection_max(&self) -> DocLine { + self.last_docline() } fn selected_entry_marked(&self) -> bool { @@ -421,18 +553,18 @@ impl CommitList { let new_selection = match scroll { ScrollType::Up => { - self.selection.saturating_sub(speed_int) + self.selection_saturating_sub(speed_int) } ScrollType::Down => { - self.selection.saturating_add(speed_int) + self.selection_saturating_add(speed_int) } ScrollType::PageUp => { - self.selection.saturating_sub(page_offset) + self.selection_saturating_sub(page_offset) } ScrollType::PageDown => { - self.selection.saturating_add(page_offset) + self.selection_saturating_add(page_offset) } - ScrollType::Home => 0, + ScrollType::Home => DocLine::at_commit(0), ScrollType::End => self.selection_max(), }; @@ -445,16 +577,20 @@ impl CommitList { Ok(needs_update) } + // Toggle mark on selected commit fn mark(&mut self) { if let Some(e) = self.selected_entry() { let id = e.id; - let selected = self - .selection + // Index into self.commits + let selected_ci: usize = self + .selection.commit_index.into(); + // Index into self.items.items + let selected_item = selected_ci .saturating_sub(self.items.index_offset()); if self.is_marked(&id).unwrap_or_default() { self.marked.retain(|marked| marked.1 != id); } else { - self.marked.push((selected, id)); + self.marked.push((selected_item, id)); self.marked.sort_unstable_by(|first, second| { first.0.cmp(&second.0) @@ -510,6 +646,7 @@ impl CommitList { now: DateTime<Local>, marked: Option<bool>, ) -> Line<'a> { + log::trace!("At get_entry_to_add"); let mut txt: Vec<Span> = Vec::with_capacity( ELEMENTS_PER_LINE + if marked.is_some() { 2 } else { 0 }, ); @@ -649,7 +786,8 @@ impl CommitList { }; // Find window of commits visible - let skip_commmits = self.scroll_top.get(); + let skip_commmits: usize = + self.scroll_top.get().commit_index.into(); let batch = &self.items; let skip_items = skip_commmits - batch.index_offset(); let mut start_rev: String = "HEAD".to_string(); @@ -665,15 +803,16 @@ impl CommitList { ).expect("Unable to initialize GitGraph"); // Format commits as text - let (graph_lines, text_lines, _start_row) + let (graph_lines, text_lines, start_row) = print_unicode(&local_graph, &graph_settings) .expect("Unable to print GitGraph as unicode"); - // MOCK Format commits as text + // MOCK Always show start of text let mut txt: Vec<Line> = Vec::with_capacity(height); for i in 0..height { let mut spans: Vec<Span> = vec![]; spans.push(Span::raw(graph_lines[i].clone())); + spans.push(Span::raw("|")); spans.push(Span::raw(text_lines[i].clone())); txt.push(Line::from(spans)); } @@ -682,7 +821,7 @@ impl CommitList { txt } fn get_text_no_graph(&self, height: usize, width: usize) -> Vec<Line> { - let selection = self.relative_selection(); + let selection_line: usize = self.selection.commit_index.into(); let mut txt: Vec<Line> = Vec::with_capacity(height); @@ -690,13 +829,17 @@ impl CommitList { let any_marked = !self.marked.is_empty(); + let skip_commits: usize = + self.scroll_top.get().commit_index.into(); + for (idx, e) in self .items .iter() - .skip(self.scroll_top.get()) + .skip(skip_commits) .take(height) .enumerate() { + let is_selected: bool = skip_commits + idx == selection_line; let tags = self.tags.as_ref().and_then(|t| t.get(&e.id)).map( |tags| { @@ -724,7 +867,7 @@ impl CommitList { txt.push(self.get_entry_to_add( e, - idx + self.scroll_top.get() == selection, + is_selected, tags, local_branches, self.remote_branches_string(e), @@ -782,25 +925,21 @@ impl CommitList { }) } - fn relative_selection(&self) -> usize { - self.selection.saturating_sub(self.items.index_offset()) - } - fn select_next_highlight(&mut self) { if self.highlights.is_none() { return; } - let old_selection = self.selection; + let old_selection: usize = self.selection.commit_index.into(); let mut offset = 0; loop { let hit_upper_bound = - old_selection + offset > self.selection_max(); + old_selection + offset > self.selection_max().commit_index.into(); let hit_lower_bound = offset > old_selection; if !hit_upper_bound { - self.selection = old_selection + offset; + self.selection = DocLine::at_commit(old_selection + offset); if self.selection_highlighted() { break; @@ -808,7 +947,7 @@ impl CommitList { } if !hit_lower_bound { - self.selection = old_selection - offset; + self.selection = DocLine::at_commit(old_selection - offset); if self.selection_highlighted() { break; @@ -816,7 +955,7 @@ impl CommitList { } if hit_lower_bound && hit_upper_bound { - self.selection = old_selection; + self.selection = DocLine::at_commit(old_selection); break; } @@ -825,15 +964,17 @@ impl CommitList { } fn selection_highlighted(&self) -> bool { - let commit = self.commits[self.selection]; + let commit_index: usize = self.selection.commit_index.into(); + let commit = self.commits[commit_index]; self.highlights .as_ref() .is_some_and(|highlights| highlights.contains(&commit)) } - fn needs_data(&self, idx: usize, idx_max: usize) -> bool { - self.items.needs_data(idx, idx_max) + /// Is idx close to reload threshold + fn needs_data(&self, idx: CommitIndex, idx_max: CommitIndex) -> bool { + self.items.needs_data(idx.0, idx_max.0) } // checks if first entry in items is the same commit as we expect @@ -850,8 +991,9 @@ impl CommitList { } fn fetch_commits(&mut self, force: bool) { - let want_min = - self.selection().saturating_sub(SLICE_SIZE / 2); + let selected_ci: usize = self.selection().commit_index.into(); + let want_min: usize = selected_ci + .saturating_sub(SLICE_SIZE / 2); let commits = self.commits.len(); let want_min = want_min.min(commits); @@ -896,18 +1038,25 @@ impl DrawableComponent for CommitList { self.current_size.set(Some(current_size)); let height_in_lines = current_size.1 as usize; - let selection = self.relative_selection(); - self.scroll_top.set(calc_scroll_top( - self.scroll_top.get(), + // Figure out if the selection is no longer visible + // and the window therefore has to scroll there + //let scroll_top_row = + + self.scroll_top.set(DocLine::at_commit(calc_scroll_top( + self.scroll_top.get().commit_index.into(), height_in_lines, - selection, - )); + self.selection.commit_index.into(), + ))); + let predecessors_of_selection: usize = + self.commits.len().saturating_sub( + self.selection.commit_index.into() + ); let title = format!( "{} {}/{}", self.title, - self.commits.len().saturating_sub(self.selection), + predecessors_of_selection, self.commits.len(), ); @@ -935,8 +1084,8 @@ impl DrawableComponent for CommitList { f, area, &self.theme, - self.commits.len(), - self.selection, + self.selection_max().commit_index.into(), + self.selection.commit_index.into(), Orientation::Vertical, ); From d8653172d82463ce74b59efde6621b6e9a6b66ca Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Wed, 16 Apr 2025 07:53:09 +0200 Subject: [PATCH 28/34] WIP - refactor formatting to allow a graph column --- git-graph/src/graph.rs | 6 ++ src/components/commitlist.rs | 204 +++++++++++++++++++++++++++-------- 2 files changed, 166 insertions(+), 44 deletions(-) diff --git a/git-graph/src/graph.rs b/git-graph/src/graph.rs index fa33959bfe..1c7b36c166 100644 --- a/git-graph/src/graph.rs +++ b/git-graph/src/graph.rs @@ -66,6 +66,8 @@ impl GitGraph { let head = HeadInfo::new(&repository.head().map_err(|err| err.message().to_string())?)?; + // commits will hold the CommitInfo for all commits covered + // indices maps git object id to an index into commits. let mut commits = Vec::new(); let mut indices = HashMap::new(); let mut idx = 0; @@ -106,22 +108,26 @@ impl GitGraph { forward, ); + // Remove commits not on a branch. This will give all commits a new index. let filtered_commits: Vec<CommitInfo> = commits .into_iter() .filter(|info| info.branch_trace.is_some()) .collect(); + // Create indices from git object id into the filtered commits let filtered_indices: HashMap<Oid, usize> = filtered_commits .iter() .enumerate() .map(|(idx, info)| (info.oid, idx)) .collect(); + // Map from old index to new index. None, if old index was removed let index_map: HashMap<usize, Option<&usize>> = indices .iter() .map(|(oid, index)| (*index, filtered_indices.get(oid))) .collect(); + // Update branch.range from old to new index. Shrink if endpoints were removed. for branch in all_branches.iter_mut() { if let Some(mut start_idx) = branch.range.0 { let mut idx0 = index_map[&start_idx]; diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index f287827053..d70e81a590 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -37,14 +37,20 @@ use ratatui::{ Frame, }; use std::{ -// ops::Add, - borrow::Cow, cell::Cell, cmp, collections::BTreeMap, rc::Rc, + ops::Deref, + borrow::Cow, + cell::{Cell, RefCell, Ref}, + cmp, collections::BTreeMap, rc::Rc, time::Instant, }; +/// Columns const ELEMENTS_PER_LINE: usize = 9; +/// Commits to fetch at a time const SLICE_SIZE: usize = 1200; +/// Feature toggle const LOG_GRAPH_ENABLED: bool = true; +const GRAPH_COLUMN_ENABLED: bool = true; @@ -133,12 +139,14 @@ pub struct CommitList { title: Box<str>, selection: DocLine, /// Document row of cursor - /// Index into self.highlights, if selection is also a highlight highlighted_selection: Option<usize>, items: ItemBatch, - /// The cached subset of commits and their graph - //local_graph: GitGraph, // TODO store here, once we implement caching + + /// Configuration for the graph + graph_settings: RefCell<Option<Graph_Settings>>, + /// The cached subset of commits in the graph + graph_cache: RefCell<Option<GitGraph>>, /// Highlighted commits highlights: Option<Rc<IndexSet<CommitId>>>, @@ -207,14 +215,8 @@ impl CommitList { selection: DocLine::default(), highlighted_selection: None, commits: IndexSet::new(), - /* - local_graph: GitGraph::new( - git2_repo, - &default_graph_settings(), - None, - Some(0) - ).expect("Unable to initialize GitGraph"), - */ + graph_settings: RefCell::new(None), + graph_cache: RefCell::new(None), highlights: None, scroll_state: (Instant::now(), 0_f32), tags: None, @@ -646,9 +648,38 @@ impl CommitList { now: DateTime<Local>, marked: Option<bool>, ) -> Line<'a> { - log::trace!("At get_entry_to_add"); + self.get_graph_entry_to_add( + None, // graph: Option(String), + e, //: &'a LogEntry, + selected, //: bool, + tags, //: Option<String>, + local_branches, //: Option<String>, + remote_branches, + theme, //: &Theme, + width, //: usize, + now, //: DateTime<Local>, + marked) //: Option<bool>, + } + + #[allow(clippy::too_many_arguments)] + fn get_graph_entry_to_add<'a>( + &self, + graph: Option<String>, + e: &'a LogEntry, + selected: bool, + tags: Option<String>, + local_branches: Option<String>, + remote_branches: Option<String>, + theme: &Theme, + width: usize, + now: DateTime<Local>, + marked: Option<bool>, + ) -> Line<'a> { let mut txt: Vec<Span> = Vec::with_capacity( - ELEMENTS_PER_LINE + if marked.is_some() { 2 } else { 0 }, + ELEMENTS_PER_LINE + + if marked.is_some() { 2 } else { 0 } + + if graph.is_some() { 2 } else { 0 } + , ); let normal = !self.items.highlighting() @@ -677,6 +708,12 @@ impl CommitList { txt.push(splitter.clone()); } + // graph + if let Some(graph) = graph { + txt.push(Span::raw( Cow::from(graph) )); + txt.push(splitter.clone()); + } + let style_hash = normal .then(|| theme.commit_hash(selected)) .unwrap_or_else(|| theme.commit_unhighlighted()); @@ -758,24 +795,12 @@ impl CommitList { } } - fn get_text_graph(&self, height: usize, _width: usize) -> Vec<Line> { - // Fetch visible part of log from repository - // We have a number of index here - // document line = the line number as seen by the user, assuming we - // are scrolling over a large list of lines. Note that one commit - // may take more than one line to show. - // commit index = the number of commits from the youngest commit - // screen row = the vertical distance from the top of the view window - // - // The link between commit index and document line is - // start_row[commit_index] = document line for commit - // The link between screen row and document line is - // screen row = document line - scroll top - - - // TODO Do not build graph every time it is drawn - // instead, update self.local_graph cache to hold those needed - // for the current display. + // Get a GitGraph instance, either cached or fresh + fn get_git_graph(&self) -> Ref<'_, GitGraph> { + // Return the cached graph if it exists + if self.graph_cache.borrow().is_some() { + return Ref::map(self.graph_cache.borrow(), |cache| cache.as_ref().unwrap()); + } // Open the asyncgit repository as a git2 repository for GitGraph let repo_binding = self.repo.borrow(); @@ -784,7 +809,15 @@ impl CommitList { Ok(repo) => repo, Err(e) => panic!("Unable to open git2 repository: {}", e), // Handle this error properly in a real application }; - + + // Create graph settings if missing + self.graph_settings + .borrow_mut() + .get_or_insert_with(default_graph_settings); + let ref_graph_settings = self.graph_settings.borrow(); + let graph_settings = ref_graph_settings.as_ref() + .expect("No graph settings present"); + // Find window of commits visible let skip_commmits: usize = self.scroll_top.get().commit_index.into(); @@ -794,32 +827,115 @@ impl CommitList { if let Some(start_log_entry) = batch.iter().skip(skip_items).next() { start_rev = start_log_entry.id.get_short_string(); } - let graph_settings = default_graph_settings(); - let local_graph = GitGraph::new( + + const GRAPH_COMMIT_COUNT: usize = 200; + let git_graph = GitGraph::new( git2_repo, - &graph_settings, + graph_settings, Some(start_rev), - Some(height) + Some(GRAPH_COMMIT_COUNT) ).expect("Unable to initialize GitGraph"); + // Store the newly created graph in the cache + *self.graph_cache.borrow_mut() = Some(git_graph); + + // Return a reference to the cached graph + return Ref::map(self.graph_cache.borrow(), |cache| cache.as_ref().unwrap()) + } + + fn get_text_graph(&self, height: usize, _width: usize) -> Vec<Line> { + // Fetch visible part of log from repository + // We have a number of index here + // document line = the line number as seen by the user, assuming we + // are scrolling over a large list of lines. Note that one commit + // may take more than one line to show. + // commit index = the number of commits from the youngest commit + // screen row = the vertical distance from the top of the view window + // + // The link between commit index and document line is + // start_row[commit_index] = document line for commit + // The link between screen row and document line is + // screen row = document line - scroll top + + let local_graph = self.get_git_graph(); + let graph_settings_ref = self.graph_settings.borrow(); + let graph_settings = graph_settings_ref.as_ref() + .expect("Missing graph settings"); + // Format commits as text - let (graph_lines, text_lines, start_row) - = print_unicode(&local_graph, &graph_settings) + let (graph_lines, text_lines, start_row) + = print_unicode(local_graph.deref(), &graph_settings) .expect("Unable to print GitGraph as unicode"); - // MOCK Always show start of text + // Convert git-graph text to ratatui text + let scroll_top_doc_line: usize = self.scroll_top.get().commit_index.into(); + let selection_doc_line: usize = self.selection().commit_index.into(); let mut txt: Vec<Line> = Vec::with_capacity(height); for i in 0..height { let mut spans: Vec<Span> = vec![]; - spans.push(Span::raw(graph_lines[i].clone())); + + let doc_line = scroll_top_doc_line + i; + + // Show selected line in document + if doc_line == selection_doc_line { + // TODO apply background selection-color to git-graph text + spans.push(Span::styled( + "-->", + self.theme.text(true /*enabled*/, true /*selected*/), + )); + } + + log::trace!("graph_lines[{i}]={}", graph_lines[i]); + log::trace!("text_lines[{i}]={}", text_lines[i]); + + if GRAPH_COLUMN_ENABLED { + spans.push(Span::raw( + if i < graph_lines.len() { + graph_lines[i].clone() + } else { + format!("no graph_lines[{}]", i) + } + )); + } + spans.push(Span::raw("|")); - spans.push(Span::raw(text_lines[i].clone())); + + spans.push(Span::raw( + if i < text_lines.len() { + text_lines[i].clone() + } else { + format!("no text line at {}", i) + } + )); + txt.push(Line::from(spans)); } // Return lines txt } + /* + fn push_marker(&self, mut& spans: Spans, doc_line: DocLine) { + let e = + let marked = if any_marked { + self.is_marked(&e.id) + } else { + None + }; + // marker + if let Some(marked) = marked { + txt.push(Span::styled( + Cow::from(if marked { + symbol::CHECKMARK + } else { + symbol::EMPTY_SPACE + }), + theme.log_marker(selected), + )); + txt.push(splitter.clone()); + } + } + */ fn get_text_no_graph(&self, height: usize, width: usize) -> Vec<Line> { let selection_line: usize = self.selection.commit_index.into(); @@ -1085,7 +1201,7 @@ impl DrawableComponent for CommitList { area, &self.theme, self.selection_max().commit_index.into(), - self.selection.commit_index.into(), + self.selection.commit_index.into(), Orientation::Vertical, ); From 1457cd622c776f59cabffd4e1847563c4cad14fc Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sat, 26 Apr 2025 05:34:03 +0200 Subject: [PATCH 29/34] Use ansi-to-tui to integrate git-graph --- Cargo.lock | 36 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + 2 files changed, 37 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 1ff257486a..392f231a78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,19 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi-to-tui" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" +dependencies = [ + "nom", + "ratatui", + "simdutf8", + "smallvec", + "thiserror 1.0.69", +] + [[package]] name = "anstream" version = "0.6.18" @@ -1230,6 +1243,7 @@ dependencies = [ name = "gitui" version = "0.28.0-alpha" dependencies = [ + "ansi-to-tui", "anyhow", "asyncgit", "backtrace", @@ -2417,6 +2431,12 @@ dependencies = [ "libc", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.7" @@ -2450,6 +2470,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notify" version = "8.0.0" @@ -3332,6 +3362,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplelog" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index 69c2ddb243..2df24bbbbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ keywords = ["git", "gui", "cli", "terminal", "ui"] build = "build.rs" [dependencies] +ansi-to-tui = "7.0.0" anyhow = "1.0" asyncgit = { path = "./asyncgit", version = "0.27.0", default-features = false } backtrace = "0.3" From 442f686af9d925833bd5ef3ffa39739645473a2d Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sat, 26 Apr 2025 05:36:43 +0200 Subject: [PATCH 30/34] Use ansi-to-tui to get rid of rendering artifacts. Adapt git-graph to Ratatui --- src/components/commitlist.rs | 53 ++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index d70e81a590..d672f3bf1d 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -12,6 +12,7 @@ use crate::{ ui::style::{SharedTheme, Theme}, ui::{calc_scroll_top, draw_scrollbar, Orientation}, }; +use ansi_to_tui::IntoText; use anyhow::Result; use asyncgit::sync::{ self, checkout_commit, BranchDetails, BranchInfo, CommitId, @@ -879,34 +880,52 @@ impl CommitList { // Show selected line in document if doc_line == selection_doc_line { // TODO apply background selection-color to git-graph text - spans.push(Span::styled( - "-->", - self.theme.text(true /*enabled*/, true /*selected*/), - )); + spans.push(Span::raw("-->")); } log::trace!("graph_lines[{i}]={}", graph_lines[i]); log::trace!("text_lines[{i}]={}", text_lines[i]); + fn deep_copy_span(span: &Span<'_>) -> Span<'static> { + Span { + content: Cow::Owned( + //remove_ansi_escape_codes( + span.content.to_string() + //) + ), + style: span.style.clone(), + } + } + fn append_ansi_text(spans: &mut Vec<Span>, ansi_text: &String) { + let text: ratatui::text::Text = ansi_text.as_bytes().into_text().unwrap(); + log::trace!("append_ansi_text from '{}' to '{}'", + ansi_text, text); + if text.lines.len() != 1 { + panic!("Converting one line did not return one line"); + } + for span in &text.lines[0] { + spans.push(deep_copy_span(span)); + } + } if GRAPH_COLUMN_ENABLED { - spans.push(Span::raw( - if i < graph_lines.len() { - graph_lines[i].clone() - } else { + if i < graph_lines.len() { + append_ansi_text(&mut spans, &graph_lines[i]); + } else { + spans.push(Span::raw( format!("no graph_lines[{}]", i) - } - )); + )); + } } spans.push(Span::raw("|")); - spans.push(Span::raw( - if i < text_lines.len() { - text_lines[i].clone() - } else { - format!("no text line at {}", i) - } - )); + if i < text_lines.len() { + append_ansi_text(&mut spans, &text_lines[i]); + } else { + spans.push( + Span::raw(format!("no text line at {}", i)) + ); + } txt.push(Line::from(spans)); } From be98fbecbca757d8f3c603652bbc71d436a8f46a Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sat, 26 Apr 2025 07:18:29 +0200 Subject: [PATCH 31/34] Add proper formatting of selected line --- src/components/commitlist.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index d672f3bf1d..a666c0cdc1 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -844,7 +844,7 @@ impl CommitList { return Ref::map(self.graph_cache.borrow(), |cache| cache.as_ref().unwrap()) } - fn get_text_graph(&self, height: usize, _width: usize) -> Vec<Line> { + fn get_text_graph(&self, height: usize, width: usize) -> Vec<Line> { // Fetch visible part of log from repository // We have a number of index here // document line = the line number as seen by the user, assuming we @@ -877,15 +877,6 @@ impl CommitList { let doc_line = scroll_top_doc_line + i; - // Show selected line in document - if doc_line == selection_doc_line { - // TODO apply background selection-color to git-graph text - spans.push(Span::raw("-->")); - } - - log::trace!("graph_lines[{i}]={}", graph_lines[i]); - log::trace!("text_lines[{i}]={}", text_lines[i]); - fn deep_copy_span(span: &Span<'_>) -> Span<'static> { Span { content: Cow::Owned( @@ -927,6 +918,20 @@ impl CommitList { ); } + // Fill right side to reach full line length + let unused_space = width.saturating_sub( + spans.iter().map(|span| span.content.len()).sum(), + ); + spans.push(Span::raw(format!("{:unused_space$}", ""))); + + // Apply selection background colour + if doc_line == selection_doc_line { + let selection_bg = self.theme.text(true, true).bg.unwrap(); + for span in &mut spans { + span.style = span.style.bg(selection_bg); + } + } + txt.push(Line::from(spans)); } From 39573ac785ee0bc5fe33163fc93e83c2b55c8779 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sat, 26 Apr 2025 11:38:42 +0200 Subject: [PATCH 32/34] fix scrolling --- src/components/commitlist.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index a666c0cdc1..fa4aada307 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -874,8 +874,7 @@ impl CommitList { let mut txt: Vec<Line> = Vec::with_capacity(height); for i in 0..height { let mut spans: Vec<Span> = vec![]; - - let doc_line = scroll_top_doc_line + i; + let i_doc_line = scroll_top_doc_line + i; fn deep_copy_span(span: &Span<'_>) -> Span<'static> { Span { @@ -899,8 +898,8 @@ impl CommitList { } } if GRAPH_COLUMN_ENABLED { - if i < graph_lines.len() { - append_ansi_text(&mut spans, &graph_lines[i]); + if i_doc_line < graph_lines.len() { + append_ansi_text(&mut spans, &graph_lines[i_doc_line]); } else { spans.push(Span::raw( format!("no graph_lines[{}]", i) @@ -910,11 +909,11 @@ impl CommitList { spans.push(Span::raw("|")); - if i < text_lines.len() { - append_ansi_text(&mut spans, &text_lines[i]); + if i_doc_line < text_lines.len() { + append_ansi_text(&mut spans, &text_lines[i_doc_line]); } else { spans.push( - Span::raw(format!("no text line at {}", i)) + Span::raw(format!("no text line at {}", i_doc_line)) ); } @@ -925,7 +924,7 @@ impl CommitList { spans.push(Span::raw(format!("{:unused_space$}", ""))); // Apply selection background colour - if doc_line == selection_doc_line { + if i_doc_line == selection_doc_line { let selection_bg = self.theme.text(true, true).bg.unwrap(); for span in &mut spans { span.style = span.style.bg(selection_bg); From 2f298af962610e497e9053b434aa5e53da306235 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sat, 26 Apr 2025 11:38:42 +0200 Subject: [PATCH 33/34] TODO reminder to handle markers --- src/components/commitlist.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index fa4aada307..9f3c166da8 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -875,6 +875,32 @@ impl CommitList { for i in 0..height { let mut spans: Vec<Span> = vec![]; let i_doc_line = scroll_top_doc_line + i; + + /* + let SHOW_MARKER_FEATURE = false; + // Add a marker column if any commit is marked. + // Show marker if on first row of commit + if SHOW_MARKER_FEATURE /* any_marked */ { + let marked = self.is_marked(commit_id) == std::option::Option(true); + let is_first_commit_row = start_row[commit_index] == doc_line; + if marked && is_first_commit_row { + spans.push(MARK); + } else { + spans.push(SPACER); + } + } + */ + /* + let splitter_txt = Cow::from(symbol::EMPTY_SPACE); + let splitter = Span::styled( + splitter_txt, + if normal { + theme.text(true, selected) + } else { + Style::default() + }, + ); + */ fn deep_copy_span(span: &Span<'_>) -> Span<'static> { Span { From e97e0bc0bc33e6044f85ff5da98d4c2ca3ddd0b5 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund <peer.sommerlund@gmail.com> Date: Sat, 26 Apr 2025 11:38:42 +0200 Subject: [PATCH 34/34] Comment on ELEMENT_PER_LINE update (MOVE FORWARD) --- 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 9f3c166da8..6cbd8c2d81 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -45,7 +45,7 @@ use std::{ time::Instant, }; -/// Columns +/// Number of Spans to allocate per line const ELEMENTS_PER_LINE: usize = 9; /// Commits to fetch at a time const SLICE_SIZE: usize = 1200;