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
+
+[![Tests](https://github.com/mlange-42/git-graph/actions/workflows/tests.yml/badge.svg)](https://github.com/mlange-42/git-graph/actions/workflows/tests.yml)
+[![GitHub](https://img.shields.io/badge/github-repo-blue?logo=github)](https://github.com/mlange-42/git-graph)
+[![Crate](https://img.shields.io/crates/v/git-graph.svg)](https://crates.io/crates/git-graph)
+[![MIT license](https://img.shields.io/github/license/mlange-42/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.
+
+![Graph comparison between tools](https://user-images.githubusercontent.com/44003176/103466403-36a81780-4d45-11eb-90cc-167d210d7a52.png)
+
+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
+```
+
+![styles](https://user-images.githubusercontent.com/44003176/103467621-357ce780-4d51-11eb-8ff9-dd7be8b40f84.png)
+
+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(&current_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;