gites: colorful git tree viewer

Tauri 2 + Vue app that draws the commit graph of the repository given on
the command line. Branches can be hidden one by one or by patterns,
runs of plain commits fold into a dotted line, and everything is
reachable from the keyboard.
This commit is contained in:
2026-10-14 21:11:55 +02:00
commit c05edf7b74
58 changed files with 7716 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+4810
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "gites"
version = "0.1.0"
description = "A colorful git tree viewer"
authors = ["Mirek Wróbel"]
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "gites_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-clipboard-manager = "2"
dirs = "6"
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": [
"main"
],
"permissions": [
"core:default",
"core:window:allow-set-title",
"clipboard-manager:allow-write-text"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+37
View File
@@ -0,0 +1,37 @@
//! Filters are stored per repository in `~/.local/share/gites/filters.json`,
//! as one JSON object keyed by the repository's root path.
use serde_json::{Map, Value};
use std::path::PathBuf;
fn file() -> Result<PathBuf, String> {
let dir = dirs::data_dir().ok_or("no data folder found")?.join("gites");
Ok(dir.join("filters.json"))
}
fn read_all() -> Result<Map<String, Value>, String> {
let path = file()?;
match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str(&text) {
Ok(Value::Object(map)) => Ok(map),
_ => Ok(Map::new()),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
Err(e) => Err(format!("could not read {}: {e}", path.display())),
}
}
pub fn load(repo: &str) -> Result<Value, String> {
Ok(read_all()?.remove(repo).unwrap_or(Value::Null))
}
pub fn save(repo: &str, value: Value) -> Result<(), String> {
let mut all = read_all()?;
all.insert(repo.to_string(), value);
let path = file()?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let text = serde_json::to_string_pretty(&Value::Object(all)).map_err(|e| e.to_string())?;
std::fs::write(&path, text).map_err(|e| format!("could not write {}: {e}", path.display()))
}
+127
View File
@@ -0,0 +1,127 @@
use serde::Serialize;
use std::path::Path;
use std::process::Command;
#[derive(Serialize)]
pub struct Commit {
hash: String,
short: String,
parents: Vec<String>,
author: String,
time: i64,
subject: String,
}
#[derive(Serialize)]
pub struct Ref {
/// Full name, e.g. `refs/heads/main`.
name: String,
/// The commit the ref points to (annotated tags are followed to their commit).
target: String,
}
#[derive(Serialize)]
pub struct RepoData {
root: String,
commits: Vec<Commit>,
refs: Vec<Ref>,
/// Full name of the checked-out branch, or `None` when HEAD is detached.
head_ref: Option<String>,
/// The checked-out commit, or `None` in a repository with no commits.
head: Option<String>,
}
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map_err(|e| format!("could not run git: {e}"))?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn load(dir: &Path) -> Result<RepoData, String> {
let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_string();
let root_path = Path::new(&root);
let head = git(root_path, &["rev-parse", "--verify", "-q", "HEAD"])
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let head_ref = git(root_path, &["symbolic-ref", "-q", "HEAD"])
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let mut refs = Vec::new();
let ref_out = git(
root_path,
&[
"for-each-ref",
"--format=%(refname)%1f%(objectname)%1f%(*objectname)%1f%(symref)",
"refs/heads",
"refs/remotes",
"refs/tags",
],
)?;
for line in ref_out.lines() {
let f: Vec<&str> = line.split('\x1f').collect();
if f.len() < 4 || !f[3].is_empty() {
// Skip symbolic refs such as origin/HEAD: they only repeat another branch.
continue;
}
let target = if f[2].is_empty() { f[1] } else { f[2] };
refs.push(Ref { name: f[0].to_string(), target: target.to_string() });
}
let mut commits = Vec::new();
if head.is_some() || !refs.is_empty() {
let log = git(
root_path,
&[
"log",
"--branches",
"--remotes",
"--tags",
"HEAD",
"--topo-order",
"--format=%H%x1f%h%x1f%P%x1f%an%x1f%at%x1f%s%x1e",
"--",
],
)
.or_else(|_| {
// No HEAD commit (fresh repository with other refs): log without it.
git(
root_path,
&[
"log", "--branches", "--remotes", "--tags", "--topo-order",
"--format=%H%x1f%h%x1f%P%x1f%an%x1f%at%x1f%s%x1e", "--",
],
)
})?;
for rec in log.split('\x1e') {
let rec = rec.trim_start_matches('\n');
if rec.is_empty() {
continue;
}
let f: Vec<&str> = rec.splitn(6, '\x1f').collect();
if f.len() < 6 {
continue;
}
commits.push(Commit {
hash: f[0].to_string(),
short: f[1].to_string(),
parents: f[2].split_whitespace().map(String::from).collect(),
author: f[3].to_string(),
time: f[4].parse().unwrap_or(0),
subject: f[5].to_string(),
});
}
}
Ok(RepoData { root, commits, refs, head_ref, head })
}
+37
View File
@@ -0,0 +1,37 @@
mod filters;
mod git;
use std::path::PathBuf;
/// The repository folder: the first command line argument, or the current folder.
struct RepoArg(PathBuf);
#[tauri::command]
fn load_repo(arg: tauri::State<RepoArg>) -> Result<git::RepoData, String> {
git::load(&arg.0)
}
#[tauri::command]
fn load_filters(repo: String) -> Result<serde_json::Value, String> {
filters::load(&repo)
}
#[tauri::command]
fn save_filters(repo: String, value: serde_json::Value) -> Result<(), String> {
filters::save(&repo, value)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let path = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
.manage(RepoArg(path))
.invoke_handler(tauri::generate_handler![load_repo, load_filters, save_filters])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
gites_lib::run()
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "gites",
"version": "0.1.0",
"identifier": "it.wrobel.gites",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "gites",
"width": 1280,
"height": 820
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}