eazygit 0.5.1

A fast TUI for Git with staging, conflicts, rebase, and palette-first UX
Documentation
mod app;       // Declare the app module (state/actions/reducer to come)
mod async_result; // Async operation results
mod cache;     // LRU caches for performance
mod commands;  // Command layer for side effects
mod components; // Component-based architecture
mod config;    // Config module (settings, keymaps, themes to come)
mod core;      // TEA (The Elm Architecture) core types
mod effects;   // TEA effect execution
mod errors;    // Error types for components, commands, services
mod git;       // Git layer (git CLI primary, libgit2 for local ops to come)
mod input;     // Input event normalization
mod jobs;      // Async job executor module to come
mod middleware; // TEA middleware (logging, etc.)
mod palette;   // Command palette system
// mod rebase; // REMOVED - Interactive rebase feature removed
mod services;  // Service layer (GitService, etc.)
mod ui;        // UI helpers (styles)

use anyhow::Result;
use tracing_subscriber::EnvFilter; 
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use crossterm::terminal::{enable_raw_mode, disable_raw_mode};
use crossterm::execute;
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::event::{EnableMouseCapture, DisableMouseCapture};
use std::sync::Arc;
use tokio::sync::{watch, mpsc};
use notify::{Watcher, RecursiveMode, RecommendedWatcher};

// Initialize tracing/logging; override with RUST_LOG=debug ./eazygit
fn init_tracing() {
    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .with_target(false)
        .without_time()
        .with_writer(std::io::stderr)
        .init();
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
    // 1. Parse CLI args
    let (args, cli_path) = parse_cli_args();

    // 2. Handle flags (--version, --help, etc.)
    if handle_info_flags(&args)? {
        return Ok(());
    }
    
    init_tracing();
    tracing::info!("Starting eazygit");

    // 3. Load Config
    if let Err(e) = config::create_default_config() {
        tracing::warn!("config create failed: {e}");
    }
    let settings = config::load().unwrap_or_default();
    tracing::info!("Loaded theme: {}", settings.ui.theme);
    let themes = config::list_themes();

    // 4. Initialize Services & State
    let git_service = Arc::new(services::GitService::new());
    let state = initialize_state(&settings, cli_path, themes, &git_service);

    // 5. Setup Async & Cancellation
    let (async_tx, async_rx) = mpsc::unbounded_channel::<crate::async_result::AsyncResult>();
    let (cancel_tx, cancel_rx) = watch::channel(false);
    spawn_cancellation_handler(cancel_tx);

    // 6. Setup File Watcher
    let watcher = setup_file_watcher(state.repo_path.clone(), async_tx.clone());
    
    // 7. Initialize Component Manager
    let component_manager = components::ComponentManager::new(git_service.clone());

    // 8. Setup Terminal
    let mut terminal = setup_terminal()?;

    // 9. Run Application
    let mut application = app::Application::new(
        state,
        component_manager,
        git_service,
        cancel_rx,
        async_tx,
        async_rx,
        watcher,
    )?;

    if let Err(e) = application.run(&mut terminal).await {
        tracing::error!(error = %e, "Application error");
    }

    // 10. Restore Terminal
    restore_terminal()?;

    tracing::info!("Exiting eazygit");
    Ok(())
}

fn parse_cli_args() -> (Vec<String>, Option<String>) {
    let args: Vec<String> = std::env::args().collect();
    let mut cli_path = None;
    for arg in args.iter().skip(1) {
        if !arg.starts_with('-') {
            cli_path = Some(arg.clone());
            break;
        }
    }
    (args, cli_path)
}

fn handle_info_flags(args: &[String]) -> Result<bool> {
    if args.iter().any(|arg| arg == "--version" || arg == "-v") {
        println!("eazygit {}", env!("CARGO_PKG_VERSION"));
        return Ok(true);
    }
    
    if args.iter().any(|arg| arg == "--list-themes") {
        init_tracing();
        let themes = config::list_themes();
        println!("Discovered {} themes:", themes.len());
        for (i, theme) in themes.iter().enumerate() {
            println!("  {}. {}", i + 1, theme);
        }
        return Ok(true);
    }
    
    if args.iter().any(|arg| arg == "--help" || arg == "-h") {
        print_help();
        return Ok(true);
    }
    
    Ok(false)
}

fn print_help() {
    println!("eazygit - A fast, easy-to-use Git TUI");
    println!();
    println!("USAGE:");
    println!("    eazygit [OPTIONS]");
    println!();
    println!("OPTIONS:");
    println!("    -v, --version    Print version information");
    println!("    -h, --help       Print this help message");
    println!("    --list-themes    List all discovered themes (for debugging)");
    println!();
    println!("Run eazygit in a Git repository to start the TUI.");
}

fn initialize_state(
    settings: &config::Settings, 
    cli_path: Option<String>, 
    themes: Vec<String>,
    _git_service: &services::GitService // reserved for future use
) -> app::AppState {
    let mut state = app::AppState::new();
    
    // Path resolution
    state.repo_path = ".".to_string();
    if let Some(repo) = cli_path {
        state.repo_path = repo;
    } else if let Ok(cwd) = std::env::current_dir() {
        state.repo_path = cwd.to_string_lossy().to_string();
    }

    // Check for interrupted rebase
    if let Ok(Some(recovery_info)) = app::rebase::operations::RebaseRecovery::detect_interrupted_rebase(&state.repo_path) {
        state.rebase_recovery_open = true;
        // Initialize session with recovery info
        state.rebase_session.phase = if recovery_info.has_conflicts {
            app::rebase::RebasePhase::Conflict
        } else {
            app::rebase::RebasePhase::Active
        };
    }

    // Apply Settings
    state.theme = settings.theme.clone();
    state.available_themes = themes;
    state.pull_timeout_secs = settings.core.pull_timeout_secs;
    state.rebase_timeout_secs = settings.core.rebase_timeout_secs;
    state.font_family = settings.ui.font_family.clone();
    state.font_size = settings.ui.font_size;
    state.commit_template = settings.commit_template.clone();
    state.custom_commands = settings.custom_commands.clone();
    state.background_image_path = settings.ui.background_image_path.clone();
    state.background_opacity = settings.ui.background_opacity;

    if settings.core.auto_fetch {
        state.auto_fetch_interval_secs = settings.core.auto_fetch_interval;
        state.auto_fetch_remote = settings.core.auto_fetch_remote.clone();
    }
    state.push_ff_only_enforce = settings.core.push_ff_only_enforce;
    state.pull_ff_only = settings.core.pull_ff_only;
    state.pull_autostash = settings.core.pull_autostash;
    
    // Side effects logic (could be moved out, but acceptable here for init)
    if let Some(font) = &state.font_family {
        ui::font::set_font(font);
    }
    
    let git_cli = git::cli::GitCli::new();
    state = app::reducer(state, app::Action::SetHasDelta(git_cli.has_delta()));
    
    state
}

fn spawn_cancellation_handler(tx: watch::Sender<bool>) {
    tokio::spawn(async move {
        if tokio::signal::ctrl_c().await.is_ok() {
            let _ = tx.send(true);
        }
    });
}

fn setup_file_watcher(watch_path: String, tx: mpsc::UnboundedSender<crate::async_result::AsyncResult>) -> Option<RecommendedWatcher> {
    let debounce = Arc::new(std::sync::Mutex::new(std::time::Instant::now()));
    let watcher_res = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
        if let Ok(event) = res {
            let dominated_by_write = event.kind.is_modify() || event.kind.is_create() || event.kind.is_remove();
            if dominated_by_write {
                match debounce.lock() {
                    Ok(mut last) => {
                        if last.elapsed().as_millis() > 200 {
                            *last = std::time::Instant::now();
                            let _ = tx.send(crate::async_result::AsyncResult::FileChanged);
                        }
                    }
                    Err(_) => {
                        tracing::warn!("File watcher debounce mutex poisoned, sending event anyway");
                        let _ = tx.send(crate::async_result::AsyncResult::FileChanged);
                    }
                }
            }
        }
    });

    match watcher_res {
        Ok(mut w) => {
            if w.watch(std::path::Path::new(&watch_path), RecursiveMode::Recursive).is_ok() {
                tracing::info!("File watcher enabled for {}", watch_path);
                Some(w)
            } else {
                tracing::warn!("File watcher failed to watch path");
                None
            }
        }
        Err(e) => {
            tracing::warn!("File watcher not available: {}", e);
            None
        }
    }
}

fn setup_terminal() -> Result<Terminal<CrosstermBackend<std::io::Stdout>>> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    Terminal::new(backend).map_err(Into::into)
}

fn restore_terminal() -> Result<()> {
    disable_raw_mode()?;
    let mut out = io::stdout();
    execute!(out, DisableMouseCapture, LeaveAlternateScreen)?;
    Ok(())
}