fix: git revwalk performance
This commit is contained in:
+240
-28
@@ -23,9 +23,10 @@ use std::collections::HashMap;
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
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 notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
use polling::{Event, Events, Poller};
|
use polling::{Event, Events, Poller};
|
||||||
@@ -992,7 +993,7 @@ fn build_cwd_section(cwd: &str, is_light: bool) -> StatuslineSection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Git repository status information.
|
/// Git repository status information.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default, Clone)]
|
||||||
struct GitStatus {
|
struct GitStatus {
|
||||||
/// Current branch or HEAD reference.
|
/// Current branch or HEAD reference.
|
||||||
head: String,
|
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<String>,
|
||||||
|
/// When `status` was computed.
|
||||||
|
computed_at: Option<Instant>,
|
||||||
|
/// The computed status (None when not in a git repository).
|
||||||
|
status: Option<GitStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AtomicBool>,
|
||||||
|
cache: Arc<Mutex<GitCache>>,
|
||||||
|
) -> Option<mpsc::Sender<String>> {
|
||||||
|
let (tx, rx) = mpsc::channel::<String>();
|
||||||
|
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).
|
/// Get git status for a directory using gix (no subprocesses).
|
||||||
/// Returns None if not in a git repository or an error occurs.
|
/// Returns None if not in a git repository or an error occurs.
|
||||||
fn get_git_status(cwd: &str) -> Option<GitStatus> {
|
fn get_git_status(cwd: &str) -> Option<GitStatus> {
|
||||||
@@ -1110,7 +1182,10 @@ fn get_git_status(cwd: &str) -> Option<GitStatus> {
|
|||||||
}
|
}
|
||||||
seen.insert(current);
|
seen.insert(current);
|
||||||
if current == merge_base_detached {
|
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) =
|
if let Ok(commit) =
|
||||||
repo.find_commit(current.clone())
|
repo.find_commit(current.clone())
|
||||||
@@ -1139,7 +1214,10 @@ fn get_git_status(cwd: &str) -> Option<GitStatus> {
|
|||||||
}
|
}
|
||||||
seen.insert(current);
|
seen.insert(current);
|
||||||
if current == merge_base_detached {
|
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) =
|
if let Ok(commit) =
|
||||||
repo.find_commit(current.clone())
|
repo.find_commit(current.clone())
|
||||||
@@ -1264,9 +1342,10 @@ fn get_git_status(cwd: &str) -> Option<GitStatus> {
|
|||||||
|
|
||||||
/// Build a statusline section for git status.
|
/// Build a statusline section for git status.
|
||||||
/// Returns None if not in a git repository.
|
/// Returns None if not in a git repository.
|
||||||
fn build_git_section(cwd: &str, is_light: bool) -> Option<StatuslineSection> {
|
fn build_git_section(
|
||||||
let status = get_git_status(cwd)?;
|
status: &GitStatus,
|
||||||
|
is_light: bool,
|
||||||
|
) -> Option<StatuslineSection> {
|
||||||
// Determine foreground color based on state (matching oh-my-posh template)
|
// Determine foreground color based on state (matching oh-my-posh template)
|
||||||
// Priority order (last match wins in oh-my-posh):
|
// Priority order (last match wins in oh-my-posh):
|
||||||
// 1. Default: #0da300 (green)
|
// 1. Default: #0da300 (green)
|
||||||
@@ -1509,6 +1588,12 @@ struct App {
|
|||||||
render_fatal_error: bool,
|
render_fatal_error: bool,
|
||||||
/// Whether UI state changed and a full redraw is needed (tab switch, pane focus, etc.).
|
/// Whether UI state changed and a full redraw is needed (tab switch, pane focus, etc.).
|
||||||
needs_redraw: bool,
|
needs_redraw: bool,
|
||||||
|
/// Shared git status cache (read by render, written by git-status thread).
|
||||||
|
git_cache: Arc<Mutex<GitCache>>,
|
||||||
|
/// Sender to the background git-status thread (requests a cwd recompute).
|
||||||
|
git_refresh_tx: Option<mpsc::Sender<String>>,
|
||||||
|
/// Throttles refresh requests so we don't spam the git thread each frame.
|
||||||
|
last_git_request_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -1517,6 +1602,15 @@ impl App {
|
|||||||
|
|
||||||
let action_map = config.keybindings.build_action_map();
|
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<Mutex<GitCache>> =
|
||||||
|
Arc::new(Mutex::new(GitCache::default()));
|
||||||
|
let git_refresh_tx =
|
||||||
|
spawn_git_status_thread(shutdown.clone(), git_cache.clone());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
window: None,
|
window: None,
|
||||||
renderer: None,
|
renderer: None,
|
||||||
@@ -1527,7 +1621,7 @@ impl App {
|
|||||||
modifiers: WinitModifiers::default(),
|
modifiers: WinitModifiers::default(),
|
||||||
keyboard_state: KeyboardState::new(),
|
keyboard_state: KeyboardState::new(),
|
||||||
event_loop_proxy: None,
|
event_loop_proxy: None,
|
||||||
shutdown: Arc::new(AtomicBool::new(false)),
|
shutdown,
|
||||||
cursor_position: PhysicalPosition::new(0.0, 0.0),
|
cursor_position: PhysicalPosition::new(0.0, 0.0),
|
||||||
mouse_down_pos: None,
|
mouse_down_pos: None,
|
||||||
frame_count: 0,
|
frame_count: 0,
|
||||||
@@ -1547,6 +1641,9 @@ impl App {
|
|||||||
last_render_at: std::time::Instant::now(),
|
last_render_at: std::time::Instant::now(),
|
||||||
render_fatal_error: false,
|
render_fatal_error: false,
|
||||||
needs_redraw: false,
|
needs_redraw: false,
|
||||||
|
git_cache,
|
||||||
|
git_refresh_tx,
|
||||||
|
last_git_request_at: Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2239,28 +2336,85 @@ impl App {
|
|||||||
|| glow_in_progress
|
|| glow_in_progress
|
||||||
|| image_animation_in_progress;
|
|| image_animation_in_progress;
|
||||||
|
|
||||||
// Get the statusline content for the active pane
|
// Get the statusline content for the active pane.
|
||||||
let statusline_content: StatuslineContent = tab
|
// Git status comes from the shared cache (computed on a
|
||||||
.panes
|
// background thread) so rendering never blocks on gix.
|
||||||
.get(&active_pane_id)
|
let statusline_content: StatuslineContent =
|
||||||
.map(|pane| {
|
match tab.panes.get(&active_pane_id) {
|
||||||
if let Some(ref custom) = pane.custom_statusline {
|
Some(pane) => {
|
||||||
StatuslineContent::Raw(custom.clone())
|
if let Some(ref custom) =
|
||||||
} else if let Some(cwd) = pane.pty.foreground_cwd() {
|
pane.custom_statusline
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
})
|
None => StatuslineContent::Sections(Vec::new()),
|
||||||
.unwrap_or_default();
|
};
|
||||||
|
|
||||||
match renderer.render_panes(
|
match renderer.render_panes(
|
||||||
&pane_render_data,
|
&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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user