Skip to main content

par_term/status_bar/
git_poller.rs

1//! Background git branch and status poller for the status bar.
2//!
3//! `GitBranchPoller` runs a background thread that periodically queries git
4//! for the current branch, ahead/behind counts, and dirty status. The results
5//! are surfaced to the status bar render loop via a shared `GitStatus`.
6
7use parking_lot::Mutex;
8use std::process::Command;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::time::{Duration, Instant};
12
13/// Snapshot of git repository status.
14#[derive(Debug, Clone, Default)]
15pub struct GitStatus {
16    /// Current branch name.
17    pub branch: Option<String>,
18    /// Commits ahead of upstream.
19    pub ahead: u32,
20    /// Commits behind upstream.
21    pub behind: u32,
22    /// Whether the working tree has uncommitted changes.
23    pub dirty: bool,
24}
25
26/// Git branch poller that runs on a background thread.
27pub(super) struct GitBranchPoller {
28    /// Shared git status (read from render thread, written by poll thread).
29    pub(super) status: Arc<Mutex<GitStatus>>,
30    /// Current working directory to poll in.
31    cwd: Arc<Mutex<Option<String>>>,
32    /// Whether the poller is running.
33    running: Arc<AtomicBool>,
34    /// Handle to the polling thread.
35    thread: Mutex<Option<std::thread::JoinHandle<()>>>,
36}
37
38impl GitBranchPoller {
39    pub(super) fn new() -> Self {
40        Self {
41            status: Arc::new(Mutex::new(GitStatus::default())),
42            cwd: Arc::new(Mutex::new(None)),
43            running: Arc::new(AtomicBool::new(false)),
44            thread: Mutex::new(None),
45        }
46    }
47
48    /// Start the background polling thread.
49    pub(super) fn start(&self, poll_interval_secs: f32) {
50        if self
51            .running
52            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
53            .is_err()
54        {
55            return;
56        }
57
58        let status = Arc::clone(&self.status);
59        let cwd = Arc::clone(&self.cwd);
60        let running = Arc::clone(&self.running);
61        let interval = Duration::from_secs_f32(poll_interval_secs.max(1.0));
62
63        let handle = std::thread::Builder::new()
64            .name("status-bar-git".into())
65            .spawn(move || {
66                while running.load(Ordering::SeqCst) {
67                    let dir = cwd.lock().clone();
68                    let result = dir.map(|d| poll_git_status(&d)).unwrap_or_default();
69                    *status.lock() = result;
70                    // Sleep in short increments so stop() returns quickly
71                    let deadline = Instant::now() + interval;
72                    while Instant::now() < deadline && running.load(Ordering::Relaxed) {
73                        std::thread::sleep(Duration::from_millis(50));
74                    }
75                }
76            });
77
78        match handle {
79            Ok(h) => *self.thread.lock() = Some(h),
80            Err(e) => {
81                // Thread spawn failed (e.g. OS out of resources); reset the
82                // running flag so start() can be retried and degrade gracefully
83                // without crashing the terminal session.
84                self.running.store(false, Ordering::SeqCst);
85                crate::debug_error!(
86                    "SESSION_LOGGER",
87                    "failed to spawn git branch poller thread: {:?}",
88                    e
89                );
90            }
91        }
92    }
93
94    /// Signal the background thread to stop without waiting for it to finish.
95    pub(super) fn signal_stop(&self) {
96        self.running.store(false, Ordering::SeqCst);
97    }
98
99    /// Stop the background polling thread and wait for it to finish.
100    pub(super) fn stop(&self) {
101        self.signal_stop();
102        if let Some(handle) = self.thread.lock().take() {
103            let _ = handle.join();
104        }
105    }
106
107    /// Update the working directory to poll in.
108    pub(super) fn set_cwd(&self, new_cwd: Option<&str>) {
109        *self.cwd.lock() = new_cwd.map(String::from);
110    }
111
112    /// Get the current git status snapshot.
113    pub(super) fn status(&self) -> GitStatus {
114        self.status.lock().clone()
115    }
116
117    pub(super) fn is_running(&self) -> bool {
118        self.running.load(Ordering::SeqCst)
119    }
120}
121
122impl Drop for GitBranchPoller {
123    fn drop(&mut self) {
124        self.stop();
125    }
126}
127
128/// Poll git for branch name, ahead/behind counts, and dirty status.
129pub(super) fn poll_git_status(dir: &str) -> GitStatus {
130    // Get branch name
131    let branch = Command::new("git")
132        .args(["rev-parse", "--abbrev-ref", "HEAD"])
133        .current_dir(dir)
134        .output()
135        .ok()
136        .and_then(|out| {
137            if out.status.success() {
138                let b = String::from_utf8_lossy(&out.stdout).trim().to_string();
139                if b.is_empty() { None } else { Some(b) }
140            } else {
141                None
142            }
143        });
144
145    if branch.is_none() {
146        return GitStatus::default();
147    }
148
149    // Get ahead/behind counts via rev-list
150    let (ahead, behind) = Command::new("git")
151        .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
152        .current_dir(dir)
153        .output()
154        .ok()
155        .and_then(|out| {
156            if out.status.success() {
157                let text = String::from_utf8_lossy(&out.stdout);
158                let parts: Vec<&str> = text.trim().split('\t').collect();
159                if parts.len() == 2 {
160                    let a = parts[0].parse::<u32>().unwrap_or(0);
161                    let b = parts[1].parse::<u32>().unwrap_or(0);
162                    Some((a, b))
163                } else {
164                    None
165                }
166            } else {
167                // No upstream configured
168                None
169            }
170        })
171        .unwrap_or((0, 0));
172
173    // Check dirty status (fast: just check if there are any changes)
174    let dirty = Command::new("git")
175        .args(["status", "--porcelain", "-uno"])
176        .current_dir(dir)
177        .output()
178        .ok()
179        .is_some_and(|out| out.status.success() && !out.stdout.is_empty());
180
181    GitStatus {
182        branch,
183        ahead,
184        behind,
185        dirty,
186    }
187}