1use std::collections::{HashMap, HashSet};
19use std::future::Future;
20use std::sync::Arc;
21use std::time::Duration;
22
23use futures_util::StreamExt;
24
25use serde::{Deserialize, Serialize};
26
27use crate::backend::fs_ops::git::{parse_porcelain_v2, safe_git_line, safe_status_porcelain};
28use crate::backend::rpc_types::FsGitState;
29use crate::backend::storage::store::Store;
30
31use super::paths::{normalize_path, normalize_repo};
32
33const TICK: Duration = Duration::from_secs(5);
35
36pub const REFRESH_AFTER_MS: u64 = 20_000;
38
39const MAX_CONCURRENT_PROBES: usize = 4;
41
42const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
45
46const MAX_DIRTY_FILES: usize = 500;
49
50#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
52pub struct GitFacts {
53 pub root: String,
55 pub repo: Option<String>,
57 pub branch: Option<String>,
59 pub dirty_files: Vec<String>,
62 pub dirty_count: usize,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub error: Option<String>,
66 pub checked_ms: u64,
67}
68
69pub async fn probe(dir: &str, now_ms: u64) -> Option<GitFacts> {
72 let root = normalize_path(&safe_git_line(dir, &["rev-parse", "--show-toplevel"]).await?);
73 let repo = safe_git_line(&root, &["remote", "get-url", "origin"])
74 .await
75 .and_then(|url| normalize_repo(&url));
76 let mut facts = GitFacts { root, repo, checked_ms: now_ms, ..Default::default() };
77 match safe_status_porcelain(&facts.root, &[]).await {
78 Ok(out) => {
79 let p = parse_porcelain_v2(&out);
80 facts.branch = p.branch;
81 let dirty: Vec<String> =
82 p.records.into_iter().filter(|r| r.state != FsGitState::Ignored).map(|r| r.path).collect();
83 facts.dirty_count = dirty.len();
84 facts.dirty_files = dirty.into_iter().take(MAX_DIRTY_FILES).collect();
85 }
86 Err(e) => {
87 facts.branch = safe_git_line(&facts.root, &["branch", "--show-current"]).await;
88 facts.error = Some(match e {
89 crate::backend::fs_ops::git::StatusError::Settings(what) => what.to_string(),
90 crate::backend::fs_ops::git::StatusError::Git(m) => m,
91 });
92 }
93 }
94 Some(facts)
95}
96
97#[derive(Debug, Clone)]
98struct Entry {
99 dir: String,
100 facts: Option<GitFacts>,
101 checked_ms: u64,
102}
103
104#[derive(Debug, Default)]
106pub struct GitCache {
107 entries: HashMap<String, Entry>,
108}
109
110impl GitCache {
111 pub fn needs_probe(&self, block: &str, dir: &str, now_ms: u64) -> bool {
113 match self.entries.get(block) {
114 None => true,
115 Some(e) => e.dir != dir || now_ms.saturating_sub(e.checked_ms) >= REFRESH_AFTER_MS,
116 }
117 }
118
119 pub fn put(&mut self, block: &str, dir: &str, facts: Option<GitFacts>, now_ms: u64) {
120 self.entries.insert(block.to_string(), Entry { dir: dir.to_string(), facts, checked_ms: now_ms });
121 }
122
123 pub fn get(&self, block: &str, dir: &str) -> Option<GitFacts> {
126 self.entries.get(block).filter(|e| e.dir == dir).and_then(|e| e.facts.clone())
127 }
128
129 pub fn retain(&mut self, live: &HashSet<String>) {
130 self.entries.retain(|b, _| live.contains(b));
131 }
132}
133
134fn cache() -> &'static parking_lot::RwLock<GitCache> {
135 static CACHE: std::sync::LazyLock<parking_lot::RwLock<GitCache>> = std::sync::LazyLock::new(Default::default);
136 &CACHE
137}
138
139pub fn cached(block: &str, dir: &str) -> Option<GitFacts> {
141 cache().read().get(block, dir)
142}
143
144pub async fn refresh_once<F, Fut>(
148 cache: &parking_lot::RwLock<GitCache>,
149 folders: Vec<(String, String)>,
150 now_ms: u64,
151 timeout: Duration,
152 probe: F,
153) where
154 F: Fn(String) -> Fut,
155 Fut: Future<Output = Option<GitFacts>>,
156{
157 let due: Vec<(String, String)> = {
158 let c = cache.read();
159 folders.into_iter().filter(|(b, d)| c.needs_probe(b, d, now_ms)).collect()
160 };
161 let mut dirs: Vec<String> = due.iter().map(|(_, d)| d.clone()).collect();
162 dirs.sort();
163 dirs.dedup();
164 let results: HashMap<String, Option<GitFacts>> = futures_util::stream::iter(dirs)
165 .map(|dir| {
166 let fut = probe(dir.clone());
167 async move { (dir, tokio::time::timeout(timeout, fut).await.ok().flatten()) }
168 })
169 .buffer_unordered(MAX_CONCURRENT_PROBES)
170 .collect()
171 .await;
172 let mut c = cache.write();
173 for (block, dir) in due {
174 c.put(&block, &dir, results.get(&dir).cloned().flatten(), now_ms);
175 }
176}
177
178pub async fn run_git_refresh_loop(mstore: Arc<Store>) {
180 let mut ticker = tokio::time::interval(TICK);
181 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
182 loop {
183 ticker.tick().await;
184 let agents = crate::backend::reactive::get_global_handler().list_agents();
185 let live: HashSet<String> = agents.iter().map(|a| a.block_id.clone()).collect();
186 cache().write().retain(&live);
187
188 let store = mstore.clone();
190 let Ok(folders) = tokio::task::spawn_blocking(move || {
191 let home = dirs::home_dir();
192 agents
193 .iter()
194 .filter_map(|a| super::folder_of(&store, home.as_deref(), a).map(|d| (a.block_id.clone(), d)))
195 .collect::<Vec<_>>()
196 })
197 .await
198 else {
199 continue;
200 };
201
202 let now = agentmux_common::time::now_ms_u64();
203 refresh_once(cache(), folders, now, PROBE_TIMEOUT, |dir| async move { probe(&dir, now).await }).await;
204 }
205}
206
207fn plain_branch(name: &str) -> bool {
210 !name.is_empty() && !name.starts_with('-') && !name.contains("..") && !name.contains(char::is_whitespace)
211}
212
213async fn first_ref(root: &str, candidates: &[String]) -> Option<String> {
215 for c in candidates {
216 if safe_git_line(root, &["rev-parse", "--verify", "--quiet", &format!("{c}^{{commit}}")]).await.is_some() {
217 return Some(c.clone());
218 }
219 }
220 None
221}
222
223pub async fn merge_base_setting(root: &str, branch: &str) -> Option<String> {
225 if !plain_branch(branch) {
226 return None;
227 }
228 safe_git_line(root, &["config", "--get", &format!("branch.{branch}.gh-merge-base")]).await.filter(|b| plain_branch(b))
229}
230
231pub async fn branch_files(root: &str, base: Option<&str>, head: &str) -> Vec<String> {
238 if !plain_branch(head) || base.is_some_and(|b| !plain_branch(b)) {
239 return Vec::new();
240 }
241 let files = async {
242 let bases: Vec<String> = match base {
243 Some(b) => vec![format!("origin/{b}"), b.to_string()],
244 None => ["origin/HEAD", "origin/main", "origin/master"].iter().map(|s| s.to_string()).collect(),
245 };
246 let base = first_ref(root, &bases).await?;
247 let head = first_ref(root, &[format!("refs/heads/{head}"), format!("origin/{head}")]).await?;
248 let out = safe_git_line(root, &["diff", "--name-only", &format!("{base}...{head}")]).await?;
249 Some(out.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect::<Vec<_>>())
250 };
251 tokio::time::timeout(PROBE_TIMEOUT, files).await.ok().flatten().unwrap_or_default()
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn git(dir: &std::path::Path, args: &[&str]) {
259 use agentmux_common::win32::NoWindow;
260 let ok = std::process::Command::new("git")
261 .args(args)
262 .current_dir(dir)
263 .env("GIT_AUTHOR_NAME", "t")
264 .env("GIT_AUTHOR_EMAIL", "t@t")
265 .env("GIT_COMMITTER_NAME", "t")
266 .env("GIT_COMMITTER_EMAIL", "t@t")
267 .no_window()
268 .output()
269 .map(|o| o.status.success())
270 .unwrap_or(false);
271 assert!(ok, "git {args:?} failed");
272 }
273
274 #[tokio::test]
276 async fn a_temp_repo_gives_repo_branch_and_dirty_files() {
277 let tmp = tempfile::tempdir().unwrap();
278 let dir = tmp.path();
279 git(dir, &["init", "-q", "-b", "feat/x"]);
280 git(dir, &["remote", "add", "origin", "https://github.com/AgentMuxAI/Example.git"]);
281 std::fs::create_dir_all(dir.join("src")).unwrap();
282 std::fs::write(dir.join("src/a.rs"), "a").unwrap();
283 git(dir, &["add", "."]);
284 git(dir, &["-c", "commit.gpgsign=false", "commit", "-q", "-m", "init"]);
285 std::fs::write(dir.join("src/a.rs"), "changed").unwrap();
286 std::fs::create_dir_all(dir.join("new")).unwrap();
287 std::fs::write(dir.join("new/b.rs"), "b").unwrap();
288
289 let sub = dir.join("src");
290 let facts = probe(&sub.to_string_lossy(), 42).await.expect("inside a repository");
291 assert_eq!(facts.repo.as_deref(), Some("agentmuxai/example"));
292 assert_eq!(facts.branch.as_deref(), Some("feat/x"));
293 let mut dirty = facts.dirty_files.clone();
294 dirty.sort();
295 assert_eq!(dirty, vec!["new/".to_string(), "src/a.rs".to_string()]);
296 assert_eq!(facts.dirty_count, 2);
297 assert_eq!(facts.checked_ms, 42);
298 let name = dir.file_name().unwrap().to_string_lossy().into_owned();
299 assert!(facts.root.ends_with(&name), "root {} is the temp repository, not its src folder", facts.root);
300 }
301
302 #[tokio::test]
303 async fn a_folder_outside_any_repository_has_no_git_facts() {
304 let tmp = tempfile::tempdir().unwrap();
305 if let Some(f) = probe(&tmp.path().to_string_lossy(), 1).await {
308 assert!(!f.root.is_empty());
309 }
310 }
311
312 #[test]
315 fn the_cache_is_reused_until_stale_or_the_folder_changes() {
316 let mut c = GitCache::default();
317 assert!(c.needs_probe("b1", "/r", 0), "never probed");
318 let facts = GitFacts { root: "/r".into(), checked_ms: 1_000, ..Default::default() };
319 c.put("b1", "/r", Some(facts.clone()), 1_000);
320 assert!(!c.needs_probe("b1", "/r", 1_000 + REFRESH_AFTER_MS - 1));
321 assert!(c.needs_probe("b1", "/r", 1_000 + REFRESH_AFTER_MS));
322 assert!(c.needs_probe("b1", "/other", 1_001), "a cd re-probes at once");
323 assert_eq!(c.get("b1", "/r"), Some(facts));
324 assert_eq!(c.get("b1", "/other"), None, "facts from the old folder aren't shown");
325
326 c.put("b2", "/n", None, 1_000);
327 assert!(!c.needs_probe("b2", "/n", 2_000), "a folder outside git is cached too");
328 c.retain(&HashSet::from(["b2".to_string()]));
329 assert_eq!(c.get("b1", "/r"), None, "a gone agent's entry is dropped");
330 }
331
332 fn facts_for(dir: &str) -> GitFacts {
333 GitFacts { root: dir.to_string(), ..Default::default() }
334 }
335
336 #[tokio::test(start_paused = true)]
340 async fn a_pass_probes_due_folders_concurrently_and_abandons_a_hung_one() {
341 use std::sync::atomic::{AtomicUsize, Ordering};
342 let cache = parking_lot::RwLock::new(GitCache::default());
343 cache.write().put("fresh", "/fresh", Some(facts_for("/fresh")), 1_000);
344
345 let mut folders: Vec<(String, String)> = (0..8).map(|i| (format!("b{i}"), format!("/r{i}"))).collect();
346 folders.push(("shares-r0".into(), "/r0".into()));
347 folders.push(("fresh".into(), "/fresh".into()));
348 folders.push(("hung".into(), "/hung".into()));
349
350 let (running, peak, calls) = (AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0));
351 let start = tokio::time::Instant::now();
352 refresh_once(&cache, folders, 2_000, Duration::from_secs(8), |dir| {
353 let (running, peak, calls) = (&running, &peak, &calls);
354 async move {
355 calls.fetch_add(1, Ordering::SeqCst);
356 let now = running.fetch_add(1, Ordering::SeqCst) + 1;
357 peak.fetch_max(now, Ordering::SeqCst);
358 let wait = if dir == "/hung" { 600 } else { 2 };
359 tokio::time::sleep(Duration::from_secs(wait)).await;
360 running.fetch_sub(1, Ordering::SeqCst);
361 Some(facts_for(&dir))
362 }
363 })
364 .await;
365
366 assert_eq!(calls.load(Ordering::SeqCst), 9, "8 folders + the hung one; shared and fresh ones skipped");
367 assert_eq!(peak.load(Ordering::SeqCst), MAX_CONCURRENT_PROBES);
368 assert!(start.elapsed() <= Duration::from_secs(9), "took {:?}", start.elapsed());
371 let c = cache.read();
372 assert_eq!(c.get("b3", "/r3"), Some(facts_for("/r3")));
373 assert_eq!(c.get("shares-r0", "/r0"), Some(facts_for("/r0")));
374 assert_eq!(c.get("hung", "/hung"), None, "abandoned");
375 assert!(!c.needs_probe("hung", "/hung", 2_001), "and not retried until it is due again");
376 assert_eq!(c.get("fresh", "/fresh"), Some(facts_for("/fresh")));
377 }
378
379 #[tokio::test]
380 async fn a_branch_lists_the_files_it_changes_since_the_default_branch() {
381 let tmp = std::env::temp_dir().join(format!("wf-branch-files-{}-{}", std::process::id(), agentmux_common::time::now_ms()));
382 std::fs::create_dir_all(&tmp).unwrap();
383 git(&tmp, &["init", "-q", "-b", "main"]);
384 git(&tmp, &["config", "user.email", "t@t"]);
385 git(&tmp, &["config", "user.name", "t"]);
386 std::fs::write(tmp.join("base.rs"), "a").unwrap();
387 git(&tmp, &["add", "."]);
388 git(&tmp, &["commit", "-q", "-m", "base"]);
389 git(&tmp, &["update-ref", "refs/remotes/origin/main", "HEAD"]);
390 let root = tmp.to_string_lossy().to_string();
391 assert!(branch_files(&root, None, "main").await.is_empty(), "the default branch itself: nothing");
392 git(&tmp, &["checkout", "-q", "-b", "agent/x"]);
393 std::fs::create_dir_all(tmp.join("src")).unwrap();
394 std::fs::write(tmp.join("src/new.rs"), "b").unwrap();
395 std::fs::write(tmp.join("base.rs"), "changed").unwrap();
396 git(&tmp, &["add", "."]);
397 git(&tmp, &["commit", "-q", "-m", "work"]);
398 let both = vec!["base.rs".to_string(), "src/new.rs".to_string()];
399 assert_eq!(branch_files(&root, None, "agent/x").await, both);
400 git(&tmp, &["checkout", "-q", "main"]);
402 assert_eq!(branch_files(&root, None, "agent/x").await, both, "--head, not the checked-out branch");
403 git(&tmp, &["checkout", "-q", "-b", "agent/y", "agent/x"]);
405 std::fs::write(tmp.join("y.rs"), "y").unwrap();
406 git(&tmp, &["add", "."]);
407 git(&tmp, &["commit", "-q", "-m", "stacked"]);
408 assert_eq!(branch_files(&root, Some("agent/x"), "agent/y").await, vec!["y.rs".to_string()]);
409 git(&tmp, &["config", "branch.agent/y.gh-merge-base", "agent/x"]);
410 assert_eq!(merge_base_setting(&root, "agent/y").await.as_deref(), Some("agent/x"));
411 assert!(branch_files(&root, None, "no/such/branch").await.is_empty(), "a head that can't be found");
412 assert!(branch_files(&root, None, "--output=x").await.is_empty(), "never a flag");
413 let _ = std::fs::remove_dir_all(&tmp);
414 }
415}