From 84593e9480edd7c7e57e6ce06a6e21dbe0b234c6 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 1 Sep 2026 22:22:11 +0200 Subject: [PATCH] fix: git revwalk performance --- src/main.rs | 268 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 240 insertions(+), 28 deletions(-) diff --git a/src/main.rs b/src/main.rs index b59bb5b..914856a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,9 +23,10 @@ use std::collections::HashMap; use std::io::Write; use std::os::fd::AsRawFd; use std::process::{Command, Stdio}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; +use std::sync::mpsc; +use std::time::{Duration, Instant}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use polling::{Event, Events, Poller}; @@ -992,7 +993,7 @@ fn build_cwd_section(cwd: &str, is_light: bool) -> StatuslineSection { } /// Git repository status information. -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] struct GitStatus { /// Current branch or HEAD reference. head: String, @@ -1047,6 +1048,77 @@ impl<'a> gix_status::index_as_worktree_with_renames::VisitEntry<'a> } } +/// TTL for how long a cached git status is considered fresh. The statusline +/// only ever displays the active pane's cwd, so recomputing more often than +/// this just wastes CPU scanning the worktree. +const GIT_STATUS_TTL: Duration = Duration::from_millis(1000); + +/// Latest computed git status, shared between the render thread (reader) and +/// the background git-status thread (writer). The render thread never blocks +/// on gix: it only ever reads this cheaply-locked value, so a large +/// repository cannot stall the UI. +#[derive(Default)] +struct GitCache { + /// cwd the cached `status` was computed for. + cwd: Option, + /// When `status` was computed. + computed_at: Option, + /// The computed status (None when not in a git repository). + status: Option, +} + +impl GitCache { + /// True when we hold a fresh status for `cwd` (no recompute needed). + fn is_fresh(&self, cwd: &str) -> bool { + self.cwd.as_deref() == Some(cwd) + && self + .computed_at + .is_some_and(|t| t.elapsed() < GIT_STATUS_TTL) + } +} + +/// Spawn a background thread that recomputes git status off the render path. +/// +/// The render thread calls [`GitCache`]-backed reads and, when the cache is +/// stale, `try_send`s the cwd to this thread. The thread recomputes (at most +/// once per `GIT_STATUS_TTL` per cwd) and writes the result back to the +/// shared cache. Returns the request sender. +fn spawn_git_status_thread( + shutdown: Arc, + cache: Arc>, +) -> Option> { + let (tx, rx) = mpsc::channel::(); + std::thread::Builder::new() + .name("git-status".into()) + .spawn(move || { + loop { + if shutdown.load(Ordering::Relaxed) { + break; + } + match rx.recv_timeout(Duration::from_millis(250)) { + Ok(cwd) => { + // The guard from this statement is dropped before the + // (potentially slow) compute, so the render thread can + // still read the cache while we work. + let need_compute = + !cache.lock().unwrap().is_fresh(&cwd); + if need_compute { + let status = get_git_status(&cwd); + let mut c = cache.lock().unwrap(); + c.cwd = Some(cwd); + c.computed_at = Some(Instant::now()); + c.status = status; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + }) + .ok()?; + Some(tx) +} + /// Get git status for a directory using gix (no subprocesses). /// Returns None if not in a git repository or an error occurs. fn get_git_status(cwd: &str) -> Option { @@ -1110,7 +1182,10 @@ fn get_git_status(cwd: &str) -> Option { } seen.insert(current); if current == merge_base_detached { - break; + // Skip the merge base itself + // (exclusive) but keep walking the + // rest of the queue. + continue; } if let Ok(commit) = repo.find_commit(current.clone()) @@ -1139,7 +1214,10 @@ fn get_git_status(cwd: &str) -> Option { } seen.insert(current); if current == merge_base_detached { - break; + // Skip the merge base itself + // (exclusive) but keep walking the + // rest of the queue. + continue; } if let Ok(commit) = repo.find_commit(current.clone()) @@ -1264,9 +1342,10 @@ fn get_git_status(cwd: &str) -> Option { /// Build a statusline section for git status. /// Returns None if not in a git repository. -fn build_git_section(cwd: &str, is_light: bool) -> Option { - let status = get_git_status(cwd)?; - +fn build_git_section( + status: &GitStatus, + is_light: bool, +) -> Option { // Determine foreground color based on state (matching oh-my-posh template) // Priority order (last match wins in oh-my-posh): // 1. Default: #0da300 (green) @@ -1509,6 +1588,12 @@ struct App { render_fatal_error: bool, /// Whether UI state changed and a full redraw is needed (tab switch, pane focus, etc.). needs_redraw: bool, + /// Shared git status cache (read by render, written by git-status thread). + git_cache: Arc>, + /// Sender to the background git-status thread (requests a cwd recompute). + git_refresh_tx: Option>, + /// Throttles refresh requests so we don't spam the git thread each frame. + last_git_request_at: Instant, } impl App { @@ -1517,6 +1602,15 @@ impl App { let action_map = config.keybindings.build_action_map(); + // Git status is computed on a background thread so large repos don't + // stall the render path. The render thread only reads the shared + // cache and requests recomputes when it goes stale. + let shutdown = Arc::new(AtomicBool::new(false)); + let git_cache: Arc> = + Arc::new(Mutex::new(GitCache::default())); + let git_refresh_tx = + spawn_git_status_thread(shutdown.clone(), git_cache.clone()); + Self { window: None, renderer: None, @@ -1527,7 +1621,7 @@ impl App { modifiers: WinitModifiers::default(), keyboard_state: KeyboardState::new(), event_loop_proxy: None, - shutdown: Arc::new(AtomicBool::new(false)), + shutdown, cursor_position: PhysicalPosition::new(0.0, 0.0), mouse_down_pos: None, frame_count: 0, @@ -1547,6 +1641,9 @@ impl App { last_render_at: std::time::Instant::now(), render_fatal_error: false, needs_redraw: false, + git_cache, + git_refresh_tx, + last_git_request_at: Instant::now(), } } @@ -2239,28 +2336,85 @@ impl App { || glow_in_progress || image_animation_in_progress; - // Get the statusline content for the active pane - let statusline_content: StatuslineContent = tab - .panes - .get(&active_pane_id) - .map(|pane| { - if let Some(ref custom) = pane.custom_statusline { - StatuslineContent::Raw(custom.clone()) - } else if let Some(cwd) = pane.pty.foreground_cwd() { - let is_light = pane.terminal.palette.is_light(); - let mut sections = - vec![build_cwd_section(&cwd, is_light)]; - if let Some(git_section) = - build_git_section(&cwd, is_light) + // Get the statusline content for the active pane. + // Git status comes from the shared cache (computed on a + // background thread) so rendering never blocks on gix. + let statusline_content: StatuslineContent = + match tab.panes.get(&active_pane_id) { + Some(pane) => { + if let Some(ref custom) = + pane.custom_statusline { - sections.push(git_section); + StatuslineContent::Raw(custom.clone()) + } else if let Some(cwd) = + pane.pty.foreground_cwd() + { + let is_light = + pane.terminal.palette.is_light(); + let mut sections = + vec![build_cwd_section( + &cwd, + is_light + )]; + + // Read latest cached git status. Only use + // it if it matches this pane's cwd. + let (git_status, fresh) = { + let c = self + .git_cache + .lock() + .unwrap_or_else( + |p| p.into_inner(), + ); + let matches = + c.cwd.as_deref() + == Some(cwd.as_str()); + ( + if matches { + c.status.clone() + } else { + None + }, + c.is_fresh(&cwd), + ) + }; + + // Request a recompute if stale, throttled + // so we don't queue one per frame while the + // background thread is still working. + if !fresh + && self + .last_git_request_at + .elapsed() + > Duration::from_millis(150) + { + self.last_git_request_at = + Instant::now(); + if let Some(tx) = + &self.git_refresh_tx + { + // Unbounded channel: send never blocks. + let _ = tx.send(cwd.clone()); + } + } + + if let Some(ref status) = git_status { + if let Some(git_section) = + build_git_section( + status, + is_light + ) + { + sections.push(git_section); + } + } + StatuslineContent::Sections(sections) + } else { + StatuslineContent::Sections(Vec::new()) } - StatuslineContent::Sections(sections) - } else { - StatuslineContent::Sections(Vec::new()) } - }) - .unwrap_or_default(); + None => StatuslineContent::Sections(Vec::new()), + }; match renderer.render_panes( &pane_render_data, @@ -3927,3 +4081,61 @@ extern "C" fn handle_sigusr1(_: i32) { } } } + +#[cfg(test)] +mod git_status_tests { + use super::*; + use std::process::Command; + + fn git(dir: &std::path::Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("failed to run git"); + assert!( + out.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr) + ); + } + + #[test] + fn get_git_status_reports_branch_and_changes() { + let dir = std::env::temp_dir().join(format!( + "zterm-git-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + git(&dir, &["init", "-q", "-b", "main"]); + git(&dir, &["config", "user.email", "t@t.t"]); + git(&dir, &["config", "user.name", "t"]); + std::fs::write(dir.join("a.txt"), "hello").unwrap(); + git(&dir, &["add", "a.txt"]); + git(&dir, &["commit", "-q", "-m", "c1"]); + + let cwd = dir.to_str().unwrap(); + let status = get_git_status(cwd).expect("expected git status"); + assert_eq!(status.head, "main"); + assert_eq!(status.ahead, 0); + assert_eq!(status.behind, 0); + assert_eq!(status.working_changed, 0); + assert_eq!(status.staging_changed, 0); + + // Modify a tracked file -> 1 working change. + std::fs::write(dir.join("a.txt"), "changed").unwrap(); + let status = get_git_status(cwd).expect("expected git status"); + assert_eq!(status.working_changed, 1); + assert_eq!(status.staging_changed, 0); + + // Stage it -> moves from working to staging. + git(&dir, &["add", "a.txt"]); + let status = get_git_status(cwd).expect("expected git status"); + assert_eq!(status.working_changed, 0); + assert_eq!(status.staging_changed, 1); + + std::fs::remove_dir_all(&dir).ok(); + } +}