GitButler Project Configuration Storage: Architecture and Implementation Guide

GitButler stores runtime configuration in platform-specific directories using JSON files for global settings and TOML files for per-project virtual branch metadata, with live file watching and automatic migration from legacy Tauri stores.

GitButler, the open-source Git client built in Rust, implements a sophisticated project configuration storage system that balances user customization with reliable defaults. This architecture separates global application preferences from per-project virtual branch state, ensuring that settings persist correctly across platforms while remaining responsive to external changes.

Configuration Storage Architecture

GitButler organizes configuration into two distinct layers: global application settings stored in the user's configuration directory, and project-specific metadata embedded within each repository's .git folder.

Global Settings Directory

The application determines the configuration root using but_path::app_config_dir() defined in crates/but-path/src/lib.rs. This helper returns platform-appropriate paths:

  • Linux: ~/.config/gitbutler
  • macOS: ~/Library/Application Support/gitbutler
  • Windows: %APPDATA%/gitbutler

Within this directory, settings.json stores user-editable preferences including telemetry options, feature flags, and UI state.

Per-Project Metadata Location

Each Git repository managed by GitButler contains a .git/gitbutler/ directory. The file virtual_branches.toml within this directory stores the virtual branch topology, stack identifiers, and workspace state specific to that project. This approach ensures that GitButler's metadata travels with the repository and remains isolated between projects.

Configuration Files and Formats

GitButler utilizes three primary configuration artifacts, each serving a distinct purpose in the settings hierarchy.

settings.json

Located at app_config_dir()/settings.json, this JSON file persists user customizations. It is created automatically when users modify defaults and is watched for external changes. The file follows the schema defined in defaults.jsonc but only contains keys that differ from built-in defaults.

virtual_branches.toml

This TOML file, located at <project>/.git/gitbutler/virtual_branches.toml, manages per-project state. The VirtualBranchesTomlMetadata type in crates/but-meta/src/legacy.rs handles serialization and deserialization, providing methods like from_path() to load metadata and stack() to retrieve specific branch information.

defaults.jsonc

The built-in defaults reside in crates/but-settings/assets/defaults.jsonc. This JSON-with-comments file defines the complete configuration schema and sensible defaults for all settings, including telemetry preferences and feature flags. The file is compiled into the binary using include_str!() in crates/but-settings/src/persistence.rs, ensuring defaults are always available even if the configuration file is missing.

Loading and Merging Strategy

GitButler implements a default-first configuration strategy that ensures the application remains functional even with missing or corrupted user settings.

Default-First Approach

The loading process, implemented in crates/but-settings/src/persistence.rs, follows this sequence:

  1. Load defaults: Parse the embedded defaults.jsonc into a base serde_json::Value
  2. Merge customizations: Read settings.json from disk and recursively merge it into the defaults using merge_json_value, where user values overwrite defaults but omitted keys preserve their default values
  3. Apply runtime overrides: Optionally merge additional runtime configuration provided by the caller

This approach ensures that new settings introduced in updates automatically adopt their default values without requiring user intervention.

Migration from Legacy Tauri Store

For users upgrading from older Tauri-based builds, the maybe_migrate_legacy_settings function in crates/but-settings/src/persistence.rs automatically migrates existing settings. The function:

  1. Detects the legacy Tauri configuration location
  2. Copies valid settings into the new settings.json format
  3. Sets telemetry.migratedFromLegacy to true to prevent repeated migration attempts

This ensures seamless upgrades without manual configuration transfer.

Live Configuration Updates

GitButler provides a thread-safe, live view of configuration that automatically synchronizes with disk changes and enforces safe mutation patterns.

AppSettingsWithDiskSync Implementation

The AppSettingsWithDiskSync type in crates/but-settings/src/watch.rs provides the primary interface for runtime configuration access. It maintains an in-memory snapshot wrapped in Arc<RwLock<AppSettings>>, allowing concurrent reads while ensuring exclusive access for writes.

Key methods include:

  • new_with_customization(config_dir, extra) – Initializes the sync instance, loading and merging configuration from disk
  • get() – Acquires a read lock and returns a clone of the current settings
  • get_mut_enforce_save() – Returns a mutable guard that must call .save() before being dropped (enforced via Drop implementation)

File Watching Mechanism

The watch_in_background method spawns an asynchronous task that monitors settings.json for external modifications using the notify crate. When the file changes:

  1. The watcher detects the filesystem event
  2. The configuration is reloaded from disk
  3. The in-memory snapshot is updated via the Arc<RwLock>
  4. A user-provided callback receives the updated AppSettings instance

This mechanism handles platform-specific quirks, such as re-watching files on Linux when editors use atomic save patterns (write-to-temp-then-rename).

Working with Configuration in Code

The following examples demonstrate practical usage patterns for interacting with GitButler's configuration storage system.

Load Settings and Read a Feature Flag

use but_settings::AppSettingsWithDiskSync;
use but_path::app_config_dir;

fn check_workspace_v3() -> anyhow::Result<()> {
    // Determine platform-specific config directory
    let config_dir = app_config_dir()?;
    
    // Initialize live settings view
    let settings = AppSettingsWithDiskSync::new_with_customization(config_dir, None)?;
    
    // Acquire read-only snapshot
    let snapshot = settings.get()?;
    
    // Access feature flag
    if snapshot.feature_flags.ws3 {
        println!("V3 workspace APIs are enabled");
    }
    
    Ok(())
}

Relevant source: AppSettingsWithDiskSync::new_with_customization in crates/but-settings/src/watch.rs.

Mutate a Setting and Persist to Disk

use but_settings::AppSettingsWithDiskSync;
use but_path::app_config_dir;

fn enable_undo_feature() -> anyhow::Result<()> {
    let config_dir = app_config_dir()?;
    let sync = AppSettingsWithDiskSync::new_with_customization(config_dir, None)?;
    
    // Obtain mutable access with enforced save semantics
    let mut mutable = sync.get_mut_enforce_save()?;
    mutable.feature_flags.undo = true;
    
    // Persistence is mandatory before the guard drops
    mutable.save()?;
    
    Ok(())
}

Relevant source: AppSettingsEnforceSaveToDisk enforcement logic in crates/but-settings/src/watch.rs.

Watch for External Configuration Changes

use but_settings::{AppSettingsWithDiskSync, AppSettings};
use but_path::app_config_dir;
use std::sync::Arc;
use tokio::sync::Mutex;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config_dir = app_config_dir()?;
    let mut sync = AppSettingsWithDiskSync::new_with_customization(config_dir, None)?;
    
    // Shared state for reactive updates
    let latest = Arc::new(Mutex::new(sync.get()?.clone()));

    // Spawn background watcher
    sync.watch_in_background(move |updated: AppSettings| {
        let latest = Arc::clone(&latest);
        async move {
            let mut guard = latest.lock().await;
            *guard = updated;
            println!("Configuration reloaded from disk");
            Ok(())
        }
    })?;

    // Application continues running...
    Ok(())
}

Relevant source: watch_in_background implementation using the notify crate in crates/but-settings/src/watch.rs.

Load Per-Project Virtual Branch Metadata

use but_meta::VirtualBranchesTomlMetadata;
use std::path::PathBuf;

fn inspect_project_metadata(project_root: PathBuf) -> anyhow::Result<()> {
    let meta_path = project_root.join(".git/gitbutler/virtual_branches.toml");
    
    // Load TOML metadata for virtual branches
    let meta = VirtualBranchesTomlMetadata::from_path(meta_path)?;
    
    // Iterate through stacks
    for stack in meta.stacks()? {
        println!("Stack {} - In workspace: {}", 
                 stack.id, 
                 stack.in_workspace);
    }
    
    Ok(())
}

Relevant source: VirtualBranchesTomlMetadata::from_path in crates/but-meta/src/legacy.rs.

Key Source Files and Implementation Details

The GitButler project configuration storage system spans several crates with distinct responsibilities:

Path Responsibility Direct Link
crates/but-path/src/lib.rs Cross-platform directory resolution (app_config_dir, app_data_dir) https://github.com/gitbutlerapp/gitbutler/blob/master/crates/but-path/src/lib.rs
crates/but-settings/src/persistence.rs Default loading, JSON merging, legacy migration https://github.com/gitbutlerapp/gitbutler/blob/master/crates/but-settings/src/persistence.rs
crates/but-settings/src/watch.rs Live settings sync, file watching, enforced save semantics https://github.com/gitbutlerapp/gitbutler/blob/master/crates/but-settings/src/watch.rs
crates/but-settings/assets/defaults.jsonc Built-in default configuration schema https://github.com/gitbutlerapp/gitbutler/blob/master/crates/but-settings/assets/defaults.jsonc
crates/but-meta/src/legacy.rs Per-project virtual branch metadata handling https://github.com/gitbutlerapp/gitbutler/blob/master/crates/but-meta/src/legacy.rs

These components collectively provide a robust, default-first configuration system that supports live reloading, thread-safe access, and seamless upgrades from legacy installations.

Summary

  • GitButler project configuration storage uses platform-specific directories (e.g., ~/.config/gitbutler on Linux) for global settings and .git/gitbutler/ for per-project metadata.
  • Default-first loading merges built-in defaults.jsonc with user customizations in settings.json, ensuring new features automatically adopt sensible defaults.
  • Live synchronization via AppSettingsWithDiskSync provides thread-safe access with automatic file watching and background reloading when users edit configuration externally.
  • Enforced persistence requires explicit .save() calls when mutating settings, preventing accidental data loss through the get_mut_enforce_save() API.
  • Legacy migration automatically imports settings from older Tauri-based installations, setting telemetry.migratedFromLegacy to prevent duplicate migrations.
  • Virtual branch metadata resides in virtual_branches.toml within each repository, managed by VirtualBranchesTomlMetadata in the but-meta crate.

Frequently Asked Questions

Where does GitButler store its configuration files?

GitButler stores global configuration in platform-specific application directories: ~/.config/gitbutler on Linux, ~/Library/Application Support/gitbutler on macOS, and %APPDATA%/gitbutler on Windows. The primary file is settings.json, which contains user preferences and feature flags. Per-project metadata, including virtual branch state, is stored in .git/gitbutler/virtual_branches.toml within each managed repository.

How does GitButler handle configuration changes while the app is running?

The application uses the AppSettingsWithDiskSync type from crates/but-settings/src/watch.rs to maintain a live, thread-safe view of configuration. This implementation spawns a background file watcher using the notify crate that monitors settings.json for external modifications. When changes are detected, the system automatically reloads the file, merges it with built-in defaults, and updates the in-memory snapshot, ensuring the application always reflects the current configuration without requiring a restart.

What happens to my settings when upgrading from an older version of GitButler?

GitButler automatically migrates settings from legacy Tauri-based installations through the maybe_migrate_legacy_settings function in crates/but-settings/src/persistence.rs. During initialization, the system checks for existing Tauri configuration files, copies valid settings into the new settings.json format, and sets the telemetry.migratedFromLegacy flag to true to prevent repeated migrations. This ensures seamless upgrades without manual configuration transfer or loss of user preferences.

How do I modify GitButler settings programmatically?

To mutate settings safely, use the get_mut_enforce_save() method on AppSettingsWithDiskSync, which returns a guard that enforces persistence through Rust's Drop trait. After modifying the desired fields (such as feature_flags.undo or telemetry.appMetricsEnabled), you must explicitly call .save() on the guard before it goes out of scope, otherwise the application will panic to prevent accidental data loss. For read-only access, use the get() method, which returns a clone of the current settings snapshot without locking requirements.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →