1use std::collections::HashMap;
24use std::time::Duration;
25
26use agentmux_common::win32::NoWindow;
27
28use crate::backend::rpc_types::{FsGitEntry, FsGitState, FsGitStatus};
29
30use super::platform::display_path;
31
32const GIT_TIMEOUT: Duration = Duration::from_secs(4);
35
36const SAFE_CONFIG: &[&str] = &[
38 "-c",
40 "core.fsmonitor=false",
41 "-c",
43 "core.untrackedCache=false",
44];
45
46pub async fn git_status(raw: &str) -> FsGitStatus {
48 let dir = match super::resolve_request_path(raw).and_then(|p| p.canonicalize().map_err(|e| e.to_string())) {
49 Ok(d) if d.is_dir() => d,
50 _ => return FsGitStatus::default(),
51 };
52 let dir_arg = display_path(&dir);
53
54 let prefix = match run_git(&dir_arg, &["rev-parse", "--show-prefix"]).await {
57 Ok(out) => String::from_utf8_lossy(&out).trim_end_matches(['\r', '\n']).to_string(),
58 Err(_) => return FsGitStatus::default(),
59 };
60 let status = ["--ignored=matching", "--", "."];
61 let out = match safe_status_porcelain(&dir_arg, &status).await {
62 Ok(out) => out,
63 Err(StatusError::Settings(what)) => {
64 return FsGitStatus {
65 in_repo: true,
66 error: Some(format!("{what}, so git markers are off here.")),
67 ..Default::default()
68 };
69 }
70 Err(StatusError::Git(e)) => return FsGitStatus { in_repo: true, error: Some(e), ..Default::default() },
71 };
72 summarize(&parse_porcelain_v2(&out), &prefix)
73}
74
75#[derive(Debug)]
77pub(crate) enum StatusError {
78 Settings(&'static str),
81 Git(String),
83}
84
85pub(crate) async fn safe_status_porcelain(dir: &str, extra: &[&str]) -> Result<Vec<u8>, StatusError> {
90 let Ok(filters) = repo_filters(dir).await else {
94 return Err(StatusError::Settings("Couldn't read this repository's settings"));
95 };
96 if filters.iter().any(|n| n.contains('=')) {
100 return Err(StatusError::Settings("This repository's settings can't be read safely"));
101 }
102 let mut args: Vec<String> = Vec::new();
103 for name in filters {
104 for key in ["clean", "smudge", "process"] {
105 args.push("-c".into());
106 args.push(format!("filter.{name}.{key}="));
107 }
108 args.push("-c".into());
109 args.push(format!("filter.{name}.required=false"));
110 }
111 args.extend(
112 ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=normal", "--ignore-submodules=all"]
113 .map(String::from),
114 );
115 args.extend(extra.iter().map(|s| s.to_string()));
116 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
117 run_git(dir, &arg_refs).await.map_err(|e| StatusError::Git(e.message()))
118}
119
120pub(crate) async fn safe_git_line(dir: &str, args: &[&str]) -> Option<String> {
124 let out = run_git(dir, args).await.ok()?;
125 let line = String::from_utf8_lossy(&out).trim_end_matches(['\r', '\n']).to_string();
126 (!line.is_empty()).then_some(line)
127}
128
129async fn repo_filters(dir: &str) -> Result<Vec<String>, ()> {
136 let scoped = run_git(dir, &["config", "--show-scope", "--name-only", "--get-regexp", r"^filter\."]).await;
137 let (out, scoped) = match scoped {
138 Ok(out) => (out, true),
139 Err(GitError::Exit(1)) => return Ok(Vec::new()),
140 Err(_) => match run_git(dir, &["config", "--name-only", "--get-regexp", r"^filter\."]).await {
142 Ok(out) => (out, false),
143 Err(GitError::Exit(1)) => return Ok(Vec::new()),
144 Err(_) => return Err(()),
145 },
146 };
147 Ok(filter_names(&String::from_utf8_lossy(&out), scoped))
148}
149
150pub fn filter_names(out: &str, scoped: bool) -> Vec<String> {
153 let mut names: Vec<String> = Vec::new();
154 for line in out.lines() {
155 let (scope, key) = if scoped {
156 match line.split_once(|c: char| c.is_whitespace()) {
157 Some((s, k)) => (s, k.trim()),
158 None => continue,
159 }
160 } else {
161 ("", line.trim())
162 };
163 if scoped && (scope == "system" || scope == "global") {
164 continue;
165 }
166 let Some(rest) = key.strip_prefix("filter.") else { continue };
168 let Some((name, _)) = rest.rsplit_once('.') else { continue };
169 if !name.is_empty() && !names.iter().any(|n| n == name) {
170 names.push(name.to_string());
171 }
172 }
173 names
174}
175
176async fn run_git(dir: &str, args: &[&str]) -> Result<Vec<u8>, GitError> {
178 let mut cmd = tokio::process::Command::new("git");
179 cmd.arg("-C").arg(dir).args(SAFE_CONFIG).arg("--no-optional-locks").args(args);
180 cmd.env("GIT_OPTIONAL_LOCKS", "0").env("GIT_TERMINAL_PROMPT", "0");
181 cmd.stdin(std::process::Stdio::null())
182 .stdout(std::process::Stdio::piped())
183 .stderr(std::process::Stdio::piped())
184 .kill_on_drop(true)
185 .no_window();
186 crate::backend::pane_env::sanitize_external_command(&mut cmd);
188 let child = cmd.spawn().map_err(|e| GitError::Failed(format!("Couldn't run git: {e}")))?;
189 match tokio::time::timeout(GIT_TIMEOUT, child.wait_with_output()).await {
190 Err(_) => Err(GitError::Failed("git took too long to answer.".to_string())),
191 Ok(Err(e)) => Err(GitError::Failed(format!("git failed: {e}"))),
192 Ok(Ok(out)) if out.status.success() => Ok(out.stdout),
193 Ok(Ok(out)) => match out.status.code() {
194 Some(code) if out.stderr.is_empty() => Err(GitError::Exit(code)),
195 _ => Err(GitError::Failed(String::from_utf8_lossy(&out.stderr).trim().to_string())),
196 },
197 }
198}
199
200#[derive(Debug)]
201enum GitError {
202 Exit(i32),
204 Failed(String),
205}
206
207impl GitError {
208 fn message(self) -> String {
209 match self {
210 GitError::Exit(code) => format!("git exited with status {code}."),
211 GitError::Failed(m) => m,
212 }
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct GitRecord {
219 pub path: String,
220 pub state: FsGitState,
221}
222
223#[derive(Debug, Default, PartialEq, Eq)]
225pub struct Porcelain {
226 pub branch: Option<String>,
227 pub ahead: Option<u64>,
228 pub behind: Option<u64>,
229 pub records: Vec<GitRecord>,
230}
231
232fn state_of_xy(xy: &str) -> FsGitState {
235 let mut state = FsGitState::Added;
236 let mut seen = false;
237 for c in xy.chars() {
238 let s = match c {
239 'M' | 'T' => FsGitState::Modified,
240 'D' => FsGitState::Deleted,
241 'R' | 'C' => FsGitState::Renamed,
242 'A' => FsGitState::Added,
243 _ => continue,
244 };
245 state = if seen { state.max(s) } else { s };
246 seen = true;
247 }
248 state
249}
250
251pub fn parse_porcelain_v2(out: &[u8]) -> Porcelain {
254 let mut p = Porcelain::default();
255 let mut fields = out.split(|b| *b == 0).map(|f| String::from_utf8_lossy(f).into_owned());
256 while let Some(line) = fields.next() {
257 if line.is_empty() {
258 continue;
259 }
260 let kind = line.as_bytes()[0];
261 match kind {
262 b'#' => {
263 if let Some(head) = line.strip_prefix("# branch.head ") {
264 if head != "(detached)" {
265 p.branch = Some(head.to_string());
266 }
267 } else if let Some(ab) = line.strip_prefix("# branch.ab ") {
268 let mut it = ab.split(' ');
269 p.ahead = it.next().and_then(|a| a.trim_start_matches('+').parse().ok());
270 p.behind = it.next().and_then(|b| b.trim_start_matches('-').parse().ok());
271 }
272 }
273 b'1' => {
274 let parts: Vec<&str> = line.splitn(9, ' ').collect();
276 if parts.len() == 9 {
277 p.records.push(GitRecord { path: parts[8].to_string(), state: state_of_xy(parts[1]) });
278 }
279 }
280 b'2' => {
281 let parts: Vec<&str> = line.splitn(10, ' ').collect();
283 let _orig = fields.next();
284 if parts.len() == 10 {
285 p.records.push(GitRecord { path: parts[9].to_string(), state: state_of_xy(parts[1]).max(FsGitState::Renamed) });
286 }
287 }
288 b'u' => {
289 let parts: Vec<&str> = line.splitn(11, ' ').collect();
291 if parts.len() == 11 {
292 p.records.push(GitRecord { path: parts[10].to_string(), state: FsGitState::Conflicted });
293 }
294 }
295 b'?' | b'!' => {
296 if let Some(path) = line.get(2..) {
297 let state = if kind == b'?' { FsGitState::Untracked } else { FsGitState::Ignored };
298 p.records.push(GitRecord { path: path.to_string(), state });
299 }
300 }
301 _ => {}
302 }
303 }
304 p
305}
306
307pub fn summarize(p: &Porcelain, prefix: &str) -> FsGitStatus {
313 let mut by_child: HashMap<String, FsGitState> = HashMap::new();
314 let mut changes = 0u64;
315 for r in &p.records {
316 let Some(rel) = r.path.strip_prefix(prefix) else { continue };
317 let rel = rel.trim_end_matches('/');
318 if rel.is_empty() {
319 continue;
320 }
321 if r.state != FsGitState::Ignored {
322 changes += 1;
323 }
324 let (child, nested) = match rel.split_once('/') {
325 Some((c, _)) => (c, true),
326 None => (rel, false),
327 };
328 if nested && r.state == FsGitState::Ignored {
330 continue;
331 }
332 by_child.entry(child.to_string()).and_modify(|s| *s = (*s).max(r.state)).or_insert(r.state);
333 }
334 let mut entries: Vec<FsGitEntry> = by_child.into_iter().map(|(name, state)| FsGitEntry { name, state }).collect();
335 entries.sort_by(|a, b| a.name.cmp(&b.name));
336 FsGitStatus {
337 in_repo: true,
338 branch: p.branch.clone(),
339 ahead: p.ahead,
340 behind: p.behind,
341 entries,
342 changes,
343 error: None,
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn z(records: &[&str]) -> Vec<u8> {
352 let mut out = Vec::new();
353 for r in records {
354 out.extend_from_slice(r.as_bytes());
355 out.push(0);
356 }
357 out
358 }
359
360 #[test]
361 fn parses_branch_ahead_and_behind() {
362 let p = parse_porcelain_v2(&z(&["# branch.oid abc", "# branch.head main", "# branch.upstream origin/main", "# branch.ab +2 -5"]));
363 assert_eq!(p.branch.as_deref(), Some("main"));
364 assert_eq!((p.ahead, p.behind), (Some(2), Some(5)));
365 let detached = parse_porcelain_v2(&z(&["# branch.head (detached)"]));
366 assert_eq!(detached.branch, None);
367 }
368
369 #[test]
370 fn parses_every_record_kind_including_spaces_and_renames() {
371 let out = z(&[
372 "1 .M N... 100644 100644 100644 aaa bbb src/a file.ts",
373 "1 A. N... 000000 100644 100644 000 ccc new.ts",
374 "1 D. N... 100644 000000 000000 ddd 000 gone.ts",
375 "2 R. N... 100644 100644 100644 eee eee R100 docs/new name.md",
376 "docs/old name.md",
377 "u UU N... 100644 100644 100644 100644 a b c conflict.rs",
378 "? notes.txt",
379 "! target/",
380 ]);
381 let p = parse_porcelain_v2(&out);
382 let got: Vec<(&str, FsGitState)> = p.records.iter().map(|r| (r.path.as_str(), r.state)).collect();
383 assert_eq!(
384 got,
385 vec![
386 ("src/a file.ts", FsGitState::Modified),
387 ("new.ts", FsGitState::Added),
388 ("gone.ts", FsGitState::Deleted),
389 ("docs/new name.md", FsGitState::Renamed),
390 ("conflict.rs", FsGitState::Conflicted),
391 ("notes.txt", FsGitState::Untracked),
392 ("target/", FsGitState::Ignored),
393 ]
394 );
395 }
396
397 #[test]
398 fn a_staged_add_with_later_edits_reads_as_modified() {
399 assert_eq!(state_of_xy("AM"), FsGitState::Modified);
400 assert_eq!(state_of_xy("MD"), FsGitState::Modified);
401 assert_eq!(state_of_xy("R."), FsGitState::Renamed);
402 }
403
404 #[test]
405 fn folds_records_into_the_listed_folders_children() {
406 let p = parse_porcelain_v2(&z(&[
407 "# branch.head main",
408 "1 .M N... 1 1 1 a b app/src/x.ts",
409 "? app/src/new/",
410 "1 .M N... 1 1 1 a b app/README.md",
411 "! app/node_modules/",
412 "! app/src/cache/",
413 "? app/notes.txt",
414 "1 .M N... 1 1 1 a b other/y.ts",
415 ]));
416 let s = summarize(&p, "app/");
417 let got: Vec<(&str, FsGitState)> = s.entries.iter().map(|e| (e.name.as_str(), e.state)).collect();
418 assert_eq!(
419 got,
420 vec![
421 ("README.md", FsGitState::Modified),
422 ("node_modules", FsGitState::Ignored),
423 ("notes.txt", FsGitState::Untracked),
424 ("src", FsGitState::Modified),
426 ]
427 );
428 assert_eq!(s.changes, 4);
429 assert_eq!(s.branch.as_deref(), Some("main"));
430 }
431
432 #[test]
433 fn at_the_top_the_prefix_is_empty() {
434 let p = parse_porcelain_v2(&z(&["? a.txt", "1 .M N... 1 1 1 a b dir/b.txt"]));
435 let s = summarize(&p, "");
436 assert_eq!(s.entries.len(), 2);
437 }
438
439 #[tokio::test]
442 async fn runs_git_without_the_repositorys_fsmonitor() {
443 let dir = tempfile::tempdir().unwrap();
444 let git = |args: &[&str]| {
445 std::process::Command::new("git")
446 .args(["-c", "user.email=a@b", "-c", "user.name=a"])
447 .args(args)
448 .current_dir(dir.path())
449 .no_window()
450 .output()
451 };
452 if git(&["init", "-q"]).map(|o| !o.status.success()).unwrap_or(true) {
453 return; }
455 std::fs::write(dir.path().join("tracked.txt"), "a").unwrap();
456 let _ = git(&["add", "."]);
457 let _ = git(&["commit", "-qm", "x"]);
458 std::fs::write(dir.path().join("tracked.txt"), "b").unwrap();
459 std::fs::write(dir.path().join("new.txt"), "n").unwrap();
460 let mark = dir.path().join("fsmonitor-ran");
462 let _ = git(&["config", "core.fsmonitor", &format!("touch '{}'", mark.display())]);
463
464 let s = git_status(&dir.path().to_string_lossy()).await;
465 assert!(s.in_repo, "{s:?}");
466 let get = |n: &str| s.entries.iter().find(|e| e.name == n).map(|e| e.state);
467 assert_eq!(get("tracked.txt"), Some(FsGitState::Modified));
468 assert_eq!(get("new.txt"), Some(FsGitState::Untracked));
469 assert!(!mark.exists(), "the repository's fsmonitor program ran");
470 }
471
472 #[test]
473 fn blanks_the_repositorys_filters_and_keeps_the_users() {
474 let out = "system\tfilter.lfs.clean\nglobal\tfilter.lfs.process\nlocal\tfilter.evil.clean\nlocal\tfilter.evil.process\nworktree\tfilter.a.b.smudge\n";
475 assert_eq!(filter_names(out, true), vec!["evil".to_string(), "a.b".to_string()]);
476 assert_eq!(filter_names("filter.lfs.clean\nfilter.x.process\n", false), vec!["lfs".to_string(), "x".to_string()]);
478 }
479
480 #[tokio::test]
484 async fn runs_git_without_the_repositorys_clean_filter() {
485 let dir = tempfile::tempdir().unwrap();
486 let git = |args: &[&str]| {
487 std::process::Command::new("git")
488 .args(["-c", "user.email=a@b", "-c", "user.name=a"])
489 .args(args)
490 .current_dir(dir.path())
491 .no_window()
492 .output()
493 };
494 if git(&["init", "-q"]).map(|o| !o.status.success()).unwrap_or(true) {
495 return; }
497 std::fs::write(dir.path().join(".gitattributes"), "*.txt filter=evil\n").unwrap();
498 std::fs::write(dir.path().join("t.txt"), "a\n").unwrap();
499 let _ = git(&["add", "."]);
500 let _ = git(&["commit", "-qm", "x"]);
501 let mark = dir.path().join("filter-ran");
502 let mark_arg = mark.display().to_string().replace('\\', "/");
503 let _ = git(&["config", "filter.evil.clean", &format!("sh -c 'touch \"{mark_arg}\"; cat'")]);
504 std::thread::sleep(std::time::Duration::from_millis(1100));
507 std::fs::write(dir.path().join("t.txt"), "a\n").unwrap();
508
509 let s = git_status(&dir.path().to_string_lossy()).await;
510 assert!(s.in_repo && s.error.is_none(), "{s:?}");
511 assert!(!mark.exists(), "the repository's clean filter ran");
512 }
513
514 #[tokio::test]
517 async fn refuses_a_filter_name_that_c_cannot_blank() {
518 let dir = tempfile::tempdir().unwrap();
519 let git = |args: &[&str]| {
520 std::process::Command::new("git")
521 .args(["-c", "user.email=a@b", "-c", "user.name=a"])
522 .args(args)
523 .current_dir(dir.path())
524 .no_window()
525 .output()
526 };
527 if git(&["init", "-q"]).map(|o| !o.status.success()).unwrap_or(true) {
528 return; }
530 std::fs::write(dir.path().join(".gitattributes"), "*.txt filter=x=y\n").unwrap();
531 std::fs::write(dir.path().join("t.txt"), "a\n").unwrap();
532 let _ = git(&["add", "."]);
533 let _ = git(&["commit", "-qm", "x"]);
534 let mark = dir.path().join("filter-ran");
535 let mark_arg = mark.display().to_string().replace('\\', "/");
536 let _ = git(&["config", "filter.x=y.clean", &format!("sh -c 'touch \"{mark_arg}\"; cat'")]);
537 std::thread::sleep(std::time::Duration::from_millis(1100));
538 std::fs::write(dir.path().join("t.txt"), "a\n").unwrap();
539
540 let s = git_status(&dir.path().to_string_lossy()).await;
541 assert!(s.entries.is_empty() && s.error.is_some(), "{s:?}");
542 assert!(!mark.exists(), "the repository's clean filter ran");
543 }
544
545 #[tokio::test]
548 async fn an_unreadable_config_turns_markers_off() {
549 let dir = tempfile::tempdir().unwrap();
550 let ok = std::process::Command::new("git").args(["init", "-q"]).current_dir(dir.path()).no_window().output();
551 if ok.map(|o| !o.status.success()).unwrap_or(true) {
552 return; }
554 let config = dir.path().join(".git").join("config");
555 let mut text = std::fs::read_to_string(&config).unwrap();
556 text.push_str("\n[filter \"evil\"\n\tclean = broken\n");
557 std::fs::write(&config, text).unwrap();
558 let s = git_status(&dir.path().to_string_lossy()).await;
561 assert!(s.entries.is_empty(), "{s:?}");
562 assert!(!s.in_repo || s.error.is_some(), "{s:?}");
563 }
564
565 #[tokio::test]
566 async fn a_folder_outside_any_repository_has_no_markers() {
567 let dir = tempfile::tempdir().unwrap();
568 let s = git_status(&dir.path().to_string_lossy()).await;
569 assert!(s.error.is_none(), "{s:?}");
572 }
573}