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