1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3use tokio::process::Command;
4
5const GIT_TIMEOUT: Duration = Duration::from_secs(5);
7
8#[derive(Debug, Clone)]
10pub struct GitInfo {
11 pub branch: String,
13 pub dirty: bool,
15 pub is_worktree: bool,
17 pub common_dir: Option<String>,
19}
20
21pub 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 pub fn new() -> Self {
36 Self {
37 cache: HashMap::new(),
38 ttl_secs: 10,
39 }
40 }
41
42 pub async fn get_info(&mut self, dir: &str) -> Option<GitInfo> {
45 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 let info = fetch_git_info(dir).await;
54 self.cache
55 .insert(dir.to_string(), (info.clone(), Instant::now()));
56 info
57 }
58
59 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 pub fn cleanup(&mut self) {
72 self.cache
73 .retain(|_, (_, ts)| ts.elapsed().as_secs() < self.ttl_secs * 3);
74 }
75}
76
77async fn fetch_git_info(dir: &str) -> Option<GitInfo> {
79 let branch = fetch_branch(dir).await?;
80 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
91async 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
109async 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
124async 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 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
163pub 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 let name = after.split('/').next().filter(|s| !s.is_empty())?;
173 Some(name.to_string())
174}
175
176pub 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 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 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}