Skip to main content

tmai_core/git/
mod.rs

1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3use tokio::process::Command;
4
5/// Timeout for git commands to prevent hanging on unresponsive repos
6const GIT_TIMEOUT: Duration = Duration::from_secs(5);
7
8/// Git information for a working directory
9#[derive(Debug, Clone)]
10pub struct GitInfo {
11    /// Current branch name
12    pub branch: String,
13    /// Whether the working tree has uncommitted changes
14    pub dirty: bool,
15    /// Whether this directory is a git worktree (not the main repo)
16    pub is_worktree: bool,
17}
18
19/// Cache for git information with TTL
20pub struct GitCache {
21    cache: HashMap<String, (Option<GitInfo>, Instant)>,
22    ttl_secs: u64,
23}
24
25impl Default for GitCache {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl GitCache {
32    /// Create a new GitCache with default TTL of 10 seconds
33    pub fn new() -> Self {
34        Self {
35            cache: HashMap::new(),
36            ttl_secs: 10,
37        }
38    }
39
40    /// Get git info for a directory, using cache if available.
41    /// Fetches fresh info from git if cache is expired or missing.
42    pub async fn get_info(&mut self, dir: &str) -> Option<GitInfo> {
43        // Check cache (includes negative cache for non-git directories)
44        if let Some((info, ts)) = self.cache.get(dir) {
45            if ts.elapsed().as_secs() < self.ttl_secs {
46                return info.clone();
47            }
48        }
49
50        // Fetch fresh info
51        let info = fetch_git_info(dir).await;
52        self.cache
53            .insert(dir.to_string(), (info.clone(), Instant::now()));
54        info
55    }
56
57    /// Get cached git info without fetching from git.
58    /// Returns None if no cached entry exists or cache is expired.
59    pub fn get_cached(&self, dir: &str) -> Option<GitInfo> {
60        if let Some((info, ts)) = self.cache.get(dir) {
61            if ts.elapsed().as_secs() < self.ttl_secs {
62                return info.clone();
63            }
64        }
65        None
66    }
67
68    /// Remove expired entries from cache
69    pub fn cleanup(&mut self) {
70        self.cache
71            .retain(|_, (_, ts)| ts.elapsed().as_secs() < self.ttl_secs * 3);
72    }
73}
74
75/// Fetch all git info for a directory (branch, dirty, worktree) with timeout
76async fn fetch_git_info(dir: &str) -> Option<GitInfo> {
77    let branch = fetch_branch(dir).await?;
78    // Run dirty and worktree checks in parallel
79    let (dirty, is_worktree) = tokio::join!(fetch_dirty(dir), fetch_is_worktree(dir));
80    Some(GitInfo {
81        branch,
82        dirty,
83        is_worktree,
84    })
85}
86
87/// Fetch the current branch name for a directory
88async fn fetch_branch(dir: &str) -> Option<String> {
89    let output = tokio::time::timeout(
90        GIT_TIMEOUT,
91        Command::new("git")
92            .args(["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"])
93            .output(),
94    )
95    .await
96    .ok()?
97    .ok()?;
98    if output.status.success() {
99        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
100    } else {
101        None
102    }
103}
104
105/// Check if the working tree has uncommitted changes
106async fn fetch_dirty(dir: &str) -> bool {
107    let output = tokio::time::timeout(
108        GIT_TIMEOUT,
109        Command::new("git")
110            .args(["-C", dir, "status", "--porcelain"])
111            .output(),
112    )
113    .await;
114    match output {
115        Ok(Ok(o)) => !o.stdout.is_empty(),
116        _ => false,
117    }
118}
119
120/// Check if the directory is a git worktree (not the main repo)
121async fn fetch_is_worktree(dir: &str) -> bool {
122    let results = tokio::join!(
123        tokio::time::timeout(
124            GIT_TIMEOUT,
125            Command::new("git")
126                .args(["-C", dir, "rev-parse", "--git-dir"])
127                .output(),
128        ),
129        tokio::time::timeout(
130            GIT_TIMEOUT,
131            Command::new("git")
132                .args(["-C", dir, "rev-parse", "--git-common-dir"])
133                .output(),
134        ),
135    );
136    match results {
137        (Ok(Ok(gd)), Ok(Ok(cd))) => {
138            let gd_str = String::from_utf8_lossy(&gd.stdout).trim().to_string();
139            let cd_str = String::from_utf8_lossy(&cd.stdout).trim().to_string();
140            gd_str != cd_str
141        }
142        _ => false,
143    }
144}