Skip to main content

hematite/agent/
git_monitor.rs

1use std::sync::atomic::{AtomicU8, Ordering};
2use std::sync::Arc;
3use std::time::Duration;
4use tokio::process::Command;
5
6#[derive(Debug, Copy, Clone, PartialEq)]
7#[repr(u8)]
8pub enum GitRemoteStatus {
9    Unknown = 0,
10    NoRemote = 1,
11    Connected = 2,
12    Behind = 3,
13    Ahead = 4,
14    Diverged = 5,
15    Error = 6,
16}
17
18pub struct GitState {
19    /// Remote status represented as u8 for lock-free atomic access.
20    pub remote_status: AtomicU8,
21    /// Current remote origin URL.
22    pub remote_url: std::sync::Mutex<String>,
23}
24
25impl Default for GitState {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl GitState {
32    pub fn new() -> Self {
33        Self {
34            remote_status: AtomicU8::new(GitRemoteStatus::Unknown as u8),
35            remote_url: std::sync::Mutex::new("None".into()),
36        }
37    }
38
39    pub fn status(&self) -> GitRemoteStatus {
40        match self.remote_status.load(Ordering::Relaxed) {
41            1 => GitRemoteStatus::NoRemote,
42            2 => GitRemoteStatus::Connected,
43            3 => GitRemoteStatus::Behind,
44            4 => GitRemoteStatus::Ahead,
45            5 => GitRemoteStatus::Diverged,
46            6 => GitRemoteStatus::Error,
47            _ => GitRemoteStatus::Unknown,
48        }
49    }
50
51    pub fn label(&self) -> String {
52        match self.status() {
53            GitRemoteStatus::Unknown => "UNKNOWN".into(),
54            GitRemoteStatus::NoRemote => "NONE".into(),
55            GitRemoteStatus::Connected => "CONNECTED".into(),
56            GitRemoteStatus::Behind => "BEHIND".into(),
57            GitRemoteStatus::Ahead => "AHEAD".into(),
58            GitRemoteStatus::Diverged => "OUT-OF-SYNC".into(),
59            GitRemoteStatus::Error => "ERR".into(),
60        }
61    }
62
63    pub fn url(&self) -> String {
64        self.remote_url.lock().unwrap().clone()
65    }
66}
67
68pub fn spawn_git_monitor() -> Arc<GitState> {
69    let state = Arc::new(GitState::new());
70    let bg = state.clone();
71
72    tokio::spawn(async move {
73        // Initial delay to avoid slowing down startup.
74        tokio::time::sleep(Duration::from_secs(5)).await;
75
76        loop {
77            if let Some((status, url)) = check_git_status().await {
78                bg.remote_status.store(status as u8, Ordering::Relaxed);
79                if let Ok(mut u) = bg.remote_url.lock() {
80                    *u = url;
81                }
82            }
83            // Poll every 5 minutes (300s). Git operations are relatively expensive.
84            tokio::time::sleep(Duration::from_secs(300)).await;
85        }
86    });
87
88    state
89}
90
91async fn check_git_status() -> Option<(GitRemoteStatus, String)> {
92    // 1. Check if it's a git repo.
93    let repo_check = Command::new("git")
94        .args(["rev-parse", "--is-inside-work-tree"])
95        .output()
96        .await
97        .ok()?;
98
99    if !repo_check.status.success() {
100        return Some((GitRemoteStatus::NoRemote, "Not a Repo".into()));
101    }
102
103    // 2. Check for remotes.
104    let remote_check = Command::new("git").args(["remote"]).output().await.ok()?;
105
106    let remotes = String::from_utf8_lossy(&remote_check.stdout)
107        .trim()
108        .to_string();
109    if remotes.is_empty() {
110        return Some((GitRemoteStatus::NoRemote, "None".into()));
111    }
112
113    // 3. Get remote URL (assume 'origin' but check first remote if origin missing).
114    let primary_remote = if remotes.contains("origin") {
115        "origin"
116    } else {
117        remotes.split_whitespace().next().unwrap_or("origin")
118    };
119    let url_check = Command::new("git")
120        .args(["remote", "get-url", primary_remote])
121        .output()
122        .await
123        .ok()?;
124    let url = String::from_utf8_lossy(&url_check.stdout)
125        .trim()
126        .to_string();
127
128    // 4. Fetch to check sync status (optional, but requested for "persistent check").
129    // We do a "quiet" fetch.
130    let _ = Command::new("git")
131        .args(["fetch", "--quiet", primary_remote])
132        .output()
133        .await;
134
135    // 5. Compare local and remote.
136    let sync_check = Command::new("git")
137        .args(["rev-list", "--left-right", "--count", "HEAD...HEAD@{u}"])
138        .output()
139        .await
140        .ok()?;
141
142    if sync_check.status.success() {
143        let counts = String::from_utf8_lossy(&sync_check.stdout)
144            .trim()
145            .to_string();
146        let mut it = counts.split_whitespace();
147        if let (Some(ahead_str), Some(behind_str), None) = (it.next(), it.next(), it.next()) {
148            let ahead: u32 = ahead_str.parse().unwrap_or(0);
149            let behind: u32 = behind_str.parse().unwrap_or(0);
150
151            if ahead > 0 && behind > 0 {
152                return Some((GitRemoteStatus::Diverged, url));
153            } else if ahead > 0 {
154                return Some((GitRemoteStatus::Ahead, url));
155            } else if behind > 0 {
156                return Some((GitRemoteStatus::Behind, url));
157            } else {
158                return Some((GitRemoteStatus::Connected, url));
159            }
160        }
161    }
162
163    // If rev-list fails, it might mean there's no upstream branch set.
164    Some((GitRemoteStatus::Connected, url))
165}