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)
[](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");
+ }
+}