1
0
Fork 0

Adding upstream version 0.6.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-04 23:09:13 +02:00
parent 4de83856e9
commit 5b48f7aed6
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
21 changed files with 5187 additions and 0 deletions

6
.cargo_vcs_info.json Normal file
View file

@ -0,0 +1,6 @@
{
"git": {
"sha1": "7b9bb72a310243cc53d906d1e7ec3c9aad1c75d2"
},
"path_in_vcs": ""
}

25
.github/workflows/crates-io.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Crates.io
on:
release:
types: [ created ]
env:
CARGO_TERM_COLOR: always
jobs:
publish:
name: Publish to crates.io
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Publish binaries
uses: katyo/publish-crates@v1
with:
registry-token: ${{ secrets.CRATES_IO_TOKEN }}

47
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: Release
on:
release:
types: [ created ]
env:
CARGO_TERM_COLOR: always
REPO: git-graph
jobs:
release:
name: Release for ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
bin_extension: ""
os_name: "linux-amd64"
- os: windows-latest
bin_extension: ".exe"
os_name: "windows-amd64"
- os: macos-latest
bin_extension: ""
os_name: "macos-amd64"
steps:
- uses: actions/checkout@v2
- name: Get tag
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
shell: bash
- name: Build
run: |
cargo build --release
- name: Compress
run: |
cp -f target/release/$REPO${{ matrix.bin_extension }} .
tar -czf release.tar.gz $REPO${{ matrix.bin_extension }}
shell: bash
- name: Upload binaries to release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: release.tar.gz
asset_name: ${{ env.REPO }}-${{ env.RELEASE_VERSION }}-${{ matrix.os_name }}.tar.gz
tag: ${{ github.ref }}

58
.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,58 @@
name: Tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: Test Suite
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- name: Run cargo test
uses: actions-rs/cargo@v1
with:
command: test
args: --all
lints:
name: Lints
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
components: rustfmt, clippy
- name: Run cargo fmt
uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
- name: Run cargo clippy
uses: actions-rs/cargo@v1
with:
command: clippy
args: --all --all-targets -- --deny warnings

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
*.iml

899
Cargo.lock generated Normal file
View file

@ -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"

91
Cargo.toml Normal file
View file

@ -0,0 +1,91 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
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"
readme = "README.md"
keywords = [
"git",
"graph",
]
license = "MIT"
repository = "https://github.com/mlange-42/git-graph.git"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
debug = 0
debug-assertions = false
overflow-checks = false
[dependencies.atty]
version = "0.2"
[dependencies.chrono]
version = "0.4"
optional = false
[dependencies.clap]
version = "4.0"
features = ["cargo"]
optional = false
[dependencies.crossterm]
version = "0.25"
optional = false
[dependencies.git2]
version = "0.15"
optional = false
default-features = false
[dependencies.itertools]
version = "0.10"
[dependencies.lazy_static]
version = "1.4"
[dependencies.platform-dirs]
version = "0.3"
[dependencies.regex]
version = "1.7"
features = ["std"]
optional = false
default-features = false
[dependencies.serde]
version = "1.0"
[dependencies.serde_derive]
version = "1.0"
optional = false
default-features = false
[dependencies.svg]
version = "0.12"
[dependencies.textwrap]
version = "0.16"
features = ["unicode-width"]
optional = false
default-features = false
[dependencies.toml]
version = "0.5"
[dependencies.yansi]
version = "0.5"

35
Cargo.toml.orig generated Normal file
View file

@ -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"]}

21
LICENSE Normal file
View file

@ -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.

111
README.md Normal file
View file

@ -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.

326
docs/manual.md Normal file
View file

@ -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)$', # Matches exactly `master` or `main`
'^(develop|dev)$',
'^feature.*$', # Matches everything starting with `feature`
'^release.*$',
'^hotfix.*$',
'^bugfix.*$',
]
# RegEx patterns for visual ordering of branches, from left to right.
# Here, `master` or `main` 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)$', # Matches exactly `master` or `main`
'^(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)$',
['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)$',
['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`.

153
src/config.rs Normal file
View file

@ -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(())
}

984
src/graph.rs Normal file
View file

@ -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()),
);
}
}

13
src/lib.rs Normal file
View file

@ -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)
}

539
src/main.rs Normal file
View file

@ -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);
}
}

45
src/print/colors.rs Normal file
View file

@ -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
];
}

548
src/print/format.rs Normal file
View file

@ -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
}

45
src/print/mod.rs Normal file
View file

@ -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
}
}

161
src/print/svg.rs Normal file
View file

@ -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))
}

725
src/print/unicode.rs Normal file
View file

@ -0,0 +1,725 @@
//! 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
};
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.map_or(false, |h| h == &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],
);
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;
}
}
}

353
src/settings.rs Normal file
View file

@ -0,0 +1,353 @@
//! 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)$".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)$".to_string(),
r"^(hotfix|release).*$".to_string(),
r"^(develop|dev)$".to_string(),
],
terminal_colors: ColorsDef {
matches: vec![
(
r"^(master|main)$".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)$".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)$".to_string()],
order: vec![r"^tags/.*$".to_string(), r"^(master|main)$".to_string()],
terminal_colors: ColorsDef {
matches: vec![
(
r"^(master|main)$".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)$".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 }
}
}