From a87b9b95f5ba1eaffc13fe2b295c850f5478453f Mon Sep 17 00:00:00 2001 From: Amaan Qureshi Date: Sat, 24 Jan 2026 19:53:43 -0500 Subject: [PATCH] options: add `GITUI_CONFIG_DIR` and `--config-dir` If gitui can't find a local `.git/gitui.ron`, it now falls back to `gitui.ron` in the config directory (`~/.config/gitui/` by default). The config directory can be overridden with `--config-dir` / `-c` or the `GITUI_CONFIG_DIR` env var, which also affects where themes and key bindings are loaded from. Both `OptionsData` and `DiffOptions` now derive `#[serde(default)]` so partial configs (e.g. only setting `diff.ignore_whitespace`) work without needing every field present. Supersedes #2207, fixes #2140. --- CHANGELOG.md | 3 ++ README.md | 25 +++++++++++----- asyncgit/src/sync/diff.rs | 1 + src/args.rs | 52 ++++++++++++++++++++++++++++++-- src/options.rs | 62 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 129 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aba19c7e0..c58772ae71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +* support global options file and `GITUI_CONFIG_DIR` / `--config-dir` [[@amaanq](https://github.com/amaanq)] ([#2852](https://github.com/gitui-org/gitui/pull/2852)) + ## [0.28.0] - 2025-12-14 **discard changes on checkout** diff --git a/README.md b/README.md index f97b708148..b1a3ad33d8 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,11 @@ 9. [Diagnostics](#diagnostics) 10. [Color Theme](#theme) 11. [Key Bindings](#bindings) -12. [Sponsoring](#sponsoring) -13. [Inspiration](#inspiration) -14. [Contributing](#contributing) -15. [Contributors](#contributors) +12. [Options](#options) +13. [Sponsoring](#sponsoring) +14. [Inspiration](#inspiration) +15. [Contributing](#contributing) +16. [Contributors](#contributors) ## 1. Features [Top ▲](#table-of-contents) @@ -268,11 +269,19 @@ However, you can customize everything to your liking: See [Themes](THEMES.md). The key bindings can be customized: See [Key Config](KEY_CONFIG.md) on how to set them to `vim`-like bindings. -## 12. Sponsoring [Top ▲](#table-of-contents) +## 12. Options [Top ▲](#table-of-contents) + +All config files (theme, key bindings, options) are loaded from `~/.config/gitui/` (Linux/macOS) or `%APPDATA%/gitui/` (Windows). + +Use `--config-dir` / `-c` or set `GITUI_CONFIG_DIR` to use a custom config directory (e.g., `/etc/gitui`). + +Options are stored per-repo in `.git/gitui.ron`. If no local config exists, gitui looks for `gitui.ron` in the config directory. + +## 13. Sponsoring [Top ▲](#table-of-contents) [![github](https://img.shields.io/badge/-GitHub%20Sponsors-fafbfc?logo=GitHub%20Sponsors)](https://github.com/sponsors/extrawurst) -## 13. Inspiration [Top ▲](#table-of-contents) +## 14. Inspiration [Top ▲](#table-of-contents) - [lazygit](https://github.com/jesseduffield/lazygit) - [tig](https://github.com/jonas/tig) @@ -280,11 +289,11 @@ The key bindings can be customized: See [Key Config](KEY_CONFIG.md) on how to se - It would be nice to come up with a way to have the map view available in a terminal tool - [git-brunch](https://github.com/andys8/git-brunch) -## 14. Contributing [Top ▲](#table-of-contents) +## 15. Contributing [Top ▲](#table-of-contents) See [CONTRIBUTING.md](CONTRIBUTING.md). -## 15. Contributors [Top ▲](#table-of-contents) +## 16. Contributors [Top ▲](#table-of-contents) Thanks goes to all the contributors that help make GitUI amazing! ❤️ diff --git a/asyncgit/src/sync/diff.rs b/asyncgit/src/sync/diff.rs index c13fc476c7..a1e9441717 100644 --- a/asyncgit/src/sync/diff.rs +++ b/asyncgit/src/sync/diff.rs @@ -131,6 +131,7 @@ pub struct FileDiff { #[derive( Debug, Hash, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, )] +#[serde(default)] pub struct DiffOptions { /// see pub ignore_whitespace: bool, diff --git a/src/args.rs b/src/args.rs index 22c6cc8d92..dd2c4f88a6 100644 --- a/src/args.rs +++ b/src/args.rs @@ -22,6 +22,7 @@ const GIT_DIR_FLAG_ID: &str = "directory"; const WATCHER_FLAG_ID: &str = "watcher"; const KEY_BINDINGS_FLAG_ID: &str = "key_bindings"; const KEY_SYMBOLS_FLAG_ID: &str = "key_symbols"; +const CONFIG_DIR_FLAG_ID: &str = "config_dir"; const DEFAULT_THEME: &str = "theme.ron"; const DEFAULT_GIT_DIR: &str = "."; @@ -68,6 +69,13 @@ pub fn process_cmdline() -> Result { RepoPath::Path(gitdir) }; + // Set GITUI_CONFIG_DIR env var early so get_app_config_path() picks it up + if let Some(config_dir) = + arg_matches.get_one::(CONFIG_DIR_FLAG_ID) + { + env::set_var("GITUI_CONFIG_DIR", config_dir); + } + let arg_theme = arg_matches .get_one::(THEME_FLAG_ID) .map_or_else(|| PathBuf::from(DEFAULT_THEME), PathBuf::from); @@ -134,6 +142,15 @@ fn app() -> ClapApp { .value_name("KEY_SYMBOLS_FILENAME") .num_args(1), ) + .arg( + Arg::new(CONFIG_DIR_FLAG_ID) + .help("Use a custom config directory") + .short('c') + .long("config-dir") + .env("GITUI_CONFIG_DIR") + .value_name("CONFIG_DIR") + .num_args(1), + ) .arg( Arg::new(THEME_FLAG_ID) .help("Set color theme filename loaded from config directory") @@ -227,6 +244,11 @@ fn get_app_cache_path() -> Result { } pub fn get_app_config_path() -> Result { + // Check GITUI_CONFIG_DIR first, then fall back to default + if let Ok(config_dir) = env::var("GITUI_CONFIG_DIR") { + return Ok(PathBuf::from(config_dir)); + } + let mut path = if cfg!(target_os = "macos") { dirs::home_dir().map(|h| h.join(".config")) } else { @@ -238,7 +260,31 @@ pub fn get_app_config_path() -> Result { Ok(path) } -#[test] -fn verify_app() { - app().debug_assert(); +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn verify_app() { + app().debug_assert(); + } + + // env var tests must be serial since they mutate process state + #[test] + fn config_path_env_var_and_fallback() { + // with env var set, should return the custom path + let custom = "/tmp/gitui-test-config"; + env::set_var("GITUI_CONFIG_DIR", custom); + let path = get_app_config_path().unwrap(); + assert_eq!(path, PathBuf::from(custom)); + + // without env var, should fall back to default + env::remove_var("GITUI_CONFIG_DIR"); + let path = get_app_config_path().unwrap(); + assert!( + path.ends_with("gitui"), + "expected path ending in 'gitui', got: {path:?}" + ); + } } diff --git a/src/options.rs b/src/options.rs index 84063e6970..2c9e191eaf 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,3 +1,5 @@ +use crate::args::get_app_config_path; + use anyhow::Result; use asyncgit::sync::{ diff::DiffOptions, repo_dir, RepoPathRef, @@ -17,6 +19,7 @@ use std::{ }; #[derive(Default, Clone, Serialize, Deserialize)] +#[serde(default)] struct OptionsData { pub tab: usize, pub diff: DiffOptions, @@ -26,6 +29,8 @@ struct OptionsData { const COMMIT_MSG_HISTORY_LENGTH: usize = 20; +const OPTIONS_FILENAME: &str = "gitui.ron"; + #[derive(Clone)] pub struct Options { repo: RepoPathRef, @@ -144,9 +149,18 @@ impl Options { } fn read(repo: &RepoPathRef) -> Result { - let dir = Self::options_file(repo)?; + let local_file = Self::options_file(repo)?; + + // Precedence: local -> global (respects GITUI_CONFIG_DIR) + let mut f = match File::open(&local_file) { + Ok(file) => file, + Err(_) => { + let app_home = get_app_config_path()?; + let global_file = app_home.join(OPTIONS_FILENAME); + File::open(global_file)? + } + }; - let mut f = File::open(dir)?; let mut buffer = Vec::new(); f.read_to_end(&mut buffer)?; Ok(from_bytes(&buffer)?) @@ -165,7 +179,49 @@ impl Options { fn options_file(repo: &RepoPathRef) -> Result { let dir = repo_dir(&repo.borrow())?; - let dir = dir.join("gitui"); + let dir = dir.join(OPTIONS_FILENAME); Ok(dir) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::{env, fs}; + + #[test] + fn read_falls_back_to_global_config() { + let global_dir = tempfile::tempdir().unwrap(); + let global_file = + global_dir.path().join(OPTIONS_FILENAME); + fs::write( + &global_file, + "(diff: (ignore_whitespace: true))", + ) + .unwrap(); + + env::set_var( + "GITUI_CONFIG_DIR", + global_dir.path().to_str().unwrap(), + ); + + // Init a real git repo so repo_dir() works + let repo_dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(repo_dir.path()) + .output() + .unwrap(); + let git_dir = repo_dir.path().join(".git"); + let repo = RefCell::new( + asyncgit::sync::RepoPath::Path( + git_dir.to_path_buf(), + ), + ); + + let data = Options::read(&repo).unwrap(); + assert!(data.diff.ignore_whitespace); + + env::remove_var("GITUI_CONFIG_DIR"); + } +}