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    /// Absolute path to the shared git common directory (same as .git dir for main repo)
18    pub common_dir: Option<String>,
19}
20
21/// Cache for git information with TTL
22pub struct GitCache {
23    cache: HashMap<String, (Option<GitInfo>, Instant)>,
24    ttl_secs: u64,
25}
26
27impl Default for GitCache {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl GitCache {
34    /// Create a new GitCache with default TTL of 10 seconds
35    pub fn new() -> Self {
36        Self {
37            cache: HashMap::new(),
38            ttl_secs: 10,
39        }
40    }
41
42    /// Get git info for a directory, using cache if available.
43    /// Fetches fresh info from git if cache is expired or missing.
44    pub async fn get_info(&mut self, dir: &str) -> Option<GitInfo> {
45        // Check cache (includes negative cache for non-git directories)
46        if let Some((info, ts)) = self.cache.get(dir) {
47            if ts.elapsed().as_secs() < self.ttl_secs {
48                return info.clone();
49            }
50        }
51
52        // Fetch fresh info
53        let info = fetch_git_info(dir).await;
54        self.cache
55            .insert(dir.to_string(), (info.clone(), Instant::now()));
56        info
57    }
58
59    /// Get cached git info without fetching from git.
60    /// Returns None if no cached entry exists or cache is expired.
61    pub fn get_cached(&self, dir: &str) -> Option<GitInfo> {
62        if let Some((info, ts)) = self.cache.get(dir) {
63            if ts.elapsed().as_secs() < self.ttl_secs {
64                return info.clone();
65            }
66        }
67        None
68    }
69
70    /// Remove expired entries from cache
71    pub fn cleanup(&mut self) {
72        self.cache
73            .retain(|_, (_, ts)| ts.elapsed().as_secs() < self.ttl_secs * 3);
74    }
75}
76
77/// Fetch all git info for a directory (branch, dirty, worktree) with timeout
78async fn fetch_git_info(dir: &str) -> Option<GitInfo> {
79    let branch = fetch_branch(dir).await?;
80    // Run dirty and worktree checks in parallel
81    let (dirty, (is_worktree, common_dir)) =
82        tokio::join!(fetch_dirty(dir), fetch_worktree_info(dir));
83    Some(GitInfo {
84        branch,
85        dirty,
86        is_worktree,
87        common_dir,
88    })
89}
90
91/// Fetch the current branch name for a directory
92async fn fetch_branch(dir: &str) -> Option<String> {
93    let output = tokio::time::timeout(
94        GIT_TIMEOUT,
95        Command::new("git")
96            .args(["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"])
97            .output(),
98    )
99    .await
100    .ok()?
101    .ok()?;
102    if output.status.success() {
103        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
104    } else {
105        None
106    }
107}
108
109/// Check if the working tree has uncommitted changes
110async fn fetch_dirty(dir: &str) -> bool {
111    let output = tokio::time::timeout(
112        GIT_TIMEOUT,
113        Command::new("git")
114            .args(["-C", dir, "status", "--porcelain"])
115            .output(),
116    )
117    .await;
118    match output {
119        Ok(Ok(o)) => !o.stdout.is_empty(),
120        _ => false,
121    }
122}
123
124/// Check if the directory is a git worktree and return the common dir
125///
126/// Returns `(is_worktree, common_dir)` where `common_dir` is the absolute
127/// path to the shared git directory. For worktrees this differs from git-dir;
128/// for the main repo they are the same.
129async fn fetch_worktree_info(dir: &str) -> (bool, Option<String>) {
130    let results = tokio::join!(
131        tokio::time::timeout(
132            GIT_TIMEOUT,
133            Command::new("git")
134                .args(["-C", dir, "rev-parse", "--git-dir"])
135                .output(),
136        ),
137        tokio::time::timeout(
138            GIT_TIMEOUT,
139            Command::new("git")
140                .args(["-C", dir, "rev-parse", "--git-common-dir"])
141                .output(),
142        ),
143    );
144    match results {
145        (Ok(Ok(gd)), Ok(Ok(cd))) => {
146            let gd_str = String::from_utf8_lossy(&gd.stdout).trim().to_string();
147            let cd_str = String::from_utf8_lossy(&cd.stdout).trim().to_string();
148            let is_worktree = gd_str != cd_str;
149
150            // Resolve common_dir to absolute path (git may return relative like ".")
151            let common_dir_path = std::path::Path::new(dir).join(&cd_str);
152            let common_dir = common_dir_path
153                .canonicalize()
154                .ok()
155                .map(|p| p.to_string_lossy().to_string());
156
157            (is_worktree, common_dir)
158        }
159        _ => (false, None),
160    }
161}
162
163/// Extract worktree name from a `.claude/worktrees/{name}` path segment
164///
165/// Claude Code creates worktrees under `<repo>/.claude/worktrees/<name>/`.
166/// This function extracts `<name>` if the cwd contains that pattern.
167pub fn extract_claude_worktree_name(cwd: &str) -> Option<String> {
168    let marker = "/.claude/worktrees/";
169    let idx = cwd.find(marker)?;
170    let after = &cwd[idx + marker.len()..];
171    // Take up to the next '/' or end of string
172    let name = after.split('/').next().filter(|s| !s.is_empty())?;
173    Some(name.to_string())
174}
175
176/// Extract repository name from a git common directory path
177///
178/// Strips the trailing `/.git` suffix and returns the last path component.
179/// Falls back to the full path if parsing fails.
180pub fn repo_name_from_common_dir(common_dir: &str) -> String {
181    let stripped = common_dir
182        .strip_suffix("/.git")
183        .or_else(|| common_dir.strip_suffix("/.git/"))
184        .unwrap_or(common_dir);
185    let trimmed = stripped.trim_end_matches('/');
186    trimmed
187        .rsplit('/')
188        .next()
189        .filter(|s| !s.is_empty())
190        .unwrap_or(trimmed)
191        .to_string()
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_extract_claude_worktree_name_valid() {
200        assert_eq!(
201            extract_claude_worktree_name("/home/user/my-app/.claude/worktrees/feature-a"),
202            Some("feature-a".to_string())
203        );
204        assert_eq!(
205            extract_claude_worktree_name("/home/user/my-app/.claude/worktrees/feature-a/src"),
206            Some("feature-a".to_string())
207        );
208    }
209
210    #[test]
211    fn test_extract_claude_worktree_name_invalid() {
212        assert_eq!(extract_claude_worktree_name("/home/user/my-app"), None);
213        assert_eq!(
214            extract_claude_worktree_name("/home/user/my-app/.claude/"),
215            None
216        );
217        // Trailing slash with nothing after name marker
218        assert_eq!(
219            extract_claude_worktree_name("/home/user/my-app/.claude/worktrees/"),
220            None
221        );
222    }
223
224    #[test]
225    fn test_repo_name_from_common_dir() {
226        assert_eq!(
227            repo_name_from_common_dir("/home/user/my-app/.git"),
228            "my-app"
229        );
230        assert_eq!(
231            repo_name_from_common_dir("/home/user/my-app/.git/"),
232            "my-app"
233        );
234    }
235
236    #[test]
237    fn test_repo_name_from_common_dir_no_git_suffix() {
238        // Fallback: just take last component
239        assert_eq!(repo_name_from_common_dir("/home/user/my-app"), "my-app");
240    }
241
242    #[test]
243    fn test_repo_name_from_common_dir_bare() {
244        assert_eq!(repo_name_from_common_dir("my-repo/.git"), "my-repo");
245    }
246}