1use std::collections::HashSet;
2use std::path::Path;
3
4use agcodex_protocol::mcp_protocol::GitSha;
5use futures::future::join_all;
6use serde::Deserialize;
7use serde::Serialize;
8use tokio::process::Command;
9use tokio::time::Duration as TokioDuration;
10use tokio::time::timeout;
11
12use crate::util::is_inside_git_repo;
13
14const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
16
17#[derive(Serialize, Deserialize, Clone, Debug)]
18pub struct GitInfo {
19 #[serde(skip_serializing_if = "Option::is_none")]
21 pub commit_hash: Option<String>,
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub branch: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub repository_url: Option<String>,
28}
29
30#[derive(Serialize, Deserialize, Clone, Debug)]
31pub struct GitDiffToRemote {
32 pub sha: GitSha,
33 pub diff: String,
34}
35
36pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
41 let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd)
43 .await?
44 .status
45 .success();
46
47 if !is_git_repo {
48 return None;
49 }
50
51 let (commit_result, branch_result, url_result) = tokio::join!(
53 run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd),
54 run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd),
55 run_git_command_with_timeout(&["remote", "get-url", "origin"], cwd)
56 );
57
58 let mut git_info = GitInfo {
59 commit_hash: None,
60 branch: None,
61 repository_url: None,
62 };
63
64 if let Some(output) = commit_result
66 && output.status.success()
67 && let Ok(hash) = String::from_utf8(output.stdout)
68 {
69 git_info.commit_hash = Some(hash.trim().to_string());
70 }
71
72 if let Some(output) = branch_result
74 && output.status.success()
75 && let Ok(branch) = String::from_utf8(output.stdout)
76 {
77 let branch = branch.trim();
78 if branch != "HEAD" {
79 git_info.branch = Some(branch.to_string());
80 }
81 }
82
83 if let Some(output) = url_result
85 && output.status.success()
86 && let Ok(url) = String::from_utf8(output.stdout)
87 {
88 git_info.repository_url = Some(url.trim().to_string());
89 }
90
91 Some(git_info)
92}
93
94pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
96 if !is_inside_git_repo(cwd) {
97 return None;
98 }
99
100 let remotes = get_git_remotes(cwd).await?;
101 let branches = branch_ancestry(cwd).await?;
102 let base_sha = find_closest_sha(cwd, &branches, &remotes).await?;
103 let diff = diff_against_sha(cwd, &base_sha).await?;
104
105 Some(GitDiffToRemote {
106 sha: base_sha,
107 diff,
108 })
109}
110
111async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
113 let result = timeout(
114 GIT_COMMAND_TIMEOUT,
115 Command::new("git").args(args).current_dir(cwd).output(),
116 )
117 .await;
118
119 match result {
120 Ok(Ok(output)) => Some(output),
121 _ => None, }
123}
124
125async fn get_git_remotes(cwd: &Path) -> Option<Vec<String>> {
126 let output = run_git_command_with_timeout(&["remote"], cwd).await?;
127 if !output.status.success() {
128 return None;
129 }
130 let mut remotes: Vec<String> = String::from_utf8(output.stdout)
131 .ok()?
132 .lines()
133 .map(|s| s.to_string())
134 .collect();
135 if let Some(pos) = remotes.iter().position(|r| r == "origin") {
136 let origin = remotes.remove(pos);
137 remotes.insert(0, origin);
138 }
139 Some(remotes)
140}
141
142async fn get_default_branch(cwd: &Path) -> Option<String> {
149 let remotes = get_git_remotes(cwd).await.unwrap_or_default();
151 for remote in remotes {
152 if let Some(symref_output) = run_git_command_with_timeout(
154 &[
155 "symbolic-ref",
156 "--quiet",
157 &format!("refs/remotes/{remote}/HEAD"),
158 ],
159 cwd,
160 )
161 .await
162 && symref_output.status.success()
163 && let Ok(sym) = String::from_utf8(symref_output.stdout)
164 {
165 let trimmed = sym.trim();
166 if let Some((_, name)) = trimmed.rsplit_once('/') {
167 return Some(name.to_string());
168 }
169 }
170
171 if let Some(show_output) =
173 run_git_command_with_timeout(&["remote", "show", &remote], cwd).await
174 && show_output.status.success()
175 && let Ok(text) = String::from_utf8(show_output.stdout)
176 {
177 for line in text.lines() {
178 let line = line.trim();
179 if let Some(rest) = line.strip_prefix("HEAD branch:") {
180 let name = rest.trim();
181 if !name.is_empty() {
182 return Some(name.to_string());
183 }
184 }
185 }
186 }
187 }
188
189 for candidate in ["main", "master"] {
191 if let Some(verify) = run_git_command_with_timeout(
192 &[
193 "rev-parse",
194 "--verify",
195 "--quiet",
196 &format!("refs/heads/{candidate}"),
197 ],
198 cwd,
199 )
200 .await
201 && verify.status.success()
202 {
203 return Some(candidate.to_string());
204 }
205 }
206
207 None
208}
209
210async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
213 let current_branch = run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)
215 .await
216 .and_then(|o| {
217 if o.status.success() {
218 String::from_utf8(o.stdout).ok()
219 } else {
220 None
221 }
222 })
223 .map(|s| s.trim().to_string())
224 .filter(|s| s != "HEAD");
225
226 let default_branch = get_default_branch(cwd).await;
228
229 let mut ancestry: Vec<String> = Vec::new();
230 let mut seen: HashSet<String> = HashSet::new();
231 if let Some(cb) = current_branch.clone() {
232 seen.insert(cb.clone());
233 ancestry.push(cb);
234 }
235 if let Some(db) = default_branch
236 && !seen.contains(&db)
237 {
238 seen.insert(db.clone());
239 ancestry.push(db);
240 }
241
242 let remotes = get_git_remotes(cwd).await.unwrap_or_default();
247 for remote in remotes {
248 if let Some(output) = run_git_command_with_timeout(
249 &[
250 "for-each-ref",
251 "--format=%(refname:short)",
252 "--contains=HEAD",
253 &format!("refs/remotes/{remote}"),
254 ],
255 cwd,
256 )
257 .await
258 && output.status.success()
259 && let Ok(text) = String::from_utf8(output.stdout)
260 {
261 for line in text.lines() {
262 let short = line.trim();
263 if let Some(stripped) = short.strip_prefix(&format!("{remote}/"))
265 && !stripped.is_empty()
266 && !seen.contains(stripped)
267 {
268 seen.insert(stripped.to_string());
269 ancestry.push(stripped.to_string());
270 }
271 }
272 }
273 }
274
275 Some(ancestry)
277}
278
279async fn branch_remote_and_distance(
284 cwd: &Path,
285 branch: &str,
286 remotes: &[String],
287) -> Option<(Option<GitSha>, usize)> {
288 let mut found_remote_sha: Option<GitSha> = None;
290 let mut found_remote_ref: Option<String> = None;
291 for remote in remotes {
292 let remote_ref = format!("refs/remotes/{remote}/{branch}");
293 let Some(verify_output) =
294 run_git_command_with_timeout(&["rev-parse", "--verify", "--quiet", &remote_ref], cwd)
295 .await
296 else {
297 return None;
300 };
301 if !verify_output.status.success() {
302 continue;
303 }
304 let Ok(sha) = String::from_utf8(verify_output.stdout) else {
305 return None;
307 };
308 found_remote_sha = Some(GitSha::new(sha.trim()));
309 found_remote_ref = Some(remote_ref);
310 break;
311 }
312
313 let count_output = if let Some(local_count) =
316 run_git_command_with_timeout(&["rev-list", "--count", &format!("{branch}..HEAD")], cwd)
317 .await
318 {
319 if local_count.status.success() {
320 local_count
321 } else if let Some(remote_ref) = &found_remote_ref {
322 match run_git_command_with_timeout(
323 &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
324 cwd,
325 )
326 .await
327 {
328 Some(remote_count) => remote_count,
329 None => return None,
330 }
331 } else {
332 return None;
333 }
334 } else if let Some(remote_ref) = &found_remote_ref {
335 match run_git_command_with_timeout(
336 &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
337 cwd,
338 )
339 .await
340 {
341 Some(remote_count) => remote_count,
342 None => return None,
343 }
344 } else {
345 return None;
346 };
347
348 if !count_output.status.success() {
349 return None;
350 }
351 let Ok(distance_str) = String::from_utf8(count_output.stdout) else {
352 return None;
353 };
354 let Ok(distance) = distance_str.trim().parse::<usize>() else {
355 return None;
356 };
357
358 Some((found_remote_sha, distance))
359}
360
361async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<GitSha> {
363 let mut closest_sha: Option<(GitSha, usize)> = None;
365 for branch in branches {
366 let Some((maybe_remote_sha, distance)) =
367 branch_remote_and_distance(cwd, branch, remotes).await
368 else {
369 continue;
370 };
371 let Some(remote_sha) = maybe_remote_sha else {
372 continue;
374 };
375 match &closest_sha {
376 None => closest_sha = Some((remote_sha, distance)),
377 Some((_, best_distance)) if distance < *best_distance => {
378 closest_sha = Some((remote_sha, distance));
379 }
380 _ => {}
381 }
382 }
383 closest_sha.map(|(sha, _)| sha)
384}
385
386async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
387 let output = run_git_command_with_timeout(&["diff", &sha.0], cwd).await?;
388 let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1);
391 if !exit_ok {
392 return None;
393 }
394 let mut diff = String::from_utf8(output.stdout).ok()?;
395
396 if let Some(untracked_output) =
397 run_git_command_with_timeout(&["ls-files", "--others", "--exclude-standard"], cwd).await
398 && untracked_output.status.success()
399 {
400 let untracked: Vec<String> = String::from_utf8(untracked_output.stdout)
401 .ok()?
402 .lines()
403 .map(|s| s.to_string())
404 .filter(|s| !s.is_empty())
405 .collect();
406
407 if !untracked.is_empty() {
408 let futures_iter = untracked.into_iter().map(|file| async move {
409 let file_owned = file;
410 let args_vec: Vec<&str> =
411 vec!["diff", "--binary", "--no-index", "/dev/null", &file_owned];
412 run_git_command_with_timeout(&args_vec, cwd).await
413 });
414 let results = join_all(futures_iter).await;
415 for extra in results.into_iter().flatten() {
416 if extra.status.code().is_some_and(|c| c == 0 || c == 1)
417 && let Ok(s) = String::from_utf8(extra.stdout)
418 {
419 diff.push_str(&s);
420 }
421 }
422 }
423 }
424
425 Some(diff)
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 use std::fs;
433 use std::path::PathBuf;
434 use tempfile::TempDir;
435
436 async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {
438 let repo_path = temp_dir.path().join("repo");
439 fs::create_dir(&repo_path).expect("Failed to create repo dir");
440 let envs = vec![
441 ("GIT_CONFIG_GLOBAL", "/dev/null"),
442 ("GIT_CONFIG_NOSYSTEM", "1"),
443 ];
444
445 Command::new("git")
447 .envs(envs.clone())
448 .args(["init"])
449 .current_dir(&repo_path)
450 .output()
451 .await
452 .expect("Failed to init git repo");
453
454 Command::new("git")
456 .envs(envs.clone())
457 .args(["config", "user.name", "Test User"])
458 .current_dir(&repo_path)
459 .output()
460 .await
461 .expect("Failed to set git user name");
462
463 Command::new("git")
464 .envs(envs.clone())
465 .args(["config", "user.email", "test@example.com"])
466 .current_dir(&repo_path)
467 .output()
468 .await
469 .expect("Failed to set git user email");
470
471 let test_file = repo_path.join("test.txt");
473 fs::write(&test_file, "test content").expect("Failed to write test file");
474
475 Command::new("git")
476 .envs(envs.clone())
477 .args(["add", "."])
478 .current_dir(&repo_path)
479 .output()
480 .await
481 .expect("Failed to add files");
482
483 Command::new("git")
484 .envs(envs.clone())
485 .args(["commit", "-m", "Initial commit"])
486 .current_dir(&repo_path)
487 .output()
488 .await
489 .expect("Failed to commit");
490
491 repo_path
492 }
493
494 async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) {
495 let repo_path = create_test_git_repo(temp_dir).await;
496 let remote_path = temp_dir.path().join("remote.git");
497
498 Command::new("git")
499 .args(["init", "--bare", remote_path.to_str().unwrap()])
500 .output()
501 .await
502 .expect("Failed to init bare remote");
503
504 Command::new("git")
505 .args(["remote", "add", "origin", remote_path.to_str().unwrap()])
506 .current_dir(&repo_path)
507 .output()
508 .await
509 .expect("Failed to add remote");
510
511 let output = Command::new("git")
512 .args(["rev-parse", "--abbrev-ref", "HEAD"])
513 .current_dir(&repo_path)
514 .output()
515 .await
516 .expect("Failed to get branch");
517 let branch = String::from_utf8(output.stdout).unwrap().trim().to_string();
518
519 Command::new("git")
520 .args(["push", "-u", "origin", &branch])
521 .current_dir(&repo_path)
522 .output()
523 .await
524 .expect("Failed to push initial commit");
525
526 (repo_path, branch)
527 }
528
529 #[tokio::test]
530 async fn test_collect_git_info_non_git_directory() {
531 let temp_dir = TempDir::new().expect("Failed to create temp dir");
532 let result = collect_git_info(temp_dir.path()).await;
533 assert!(result.is_none());
534 }
535
536 #[tokio::test]
537 async fn test_collect_git_info_git_repository() {
538 let temp_dir = TempDir::new().expect("Failed to create temp dir");
539 let repo_path = create_test_git_repo(&temp_dir).await;
540
541 let git_info = collect_git_info(&repo_path)
542 .await
543 .expect("Should collect git info from repo");
544
545 assert!(git_info.commit_hash.is_some());
547 let commit_hash = git_info.commit_hash.unwrap();
548 assert_eq!(commit_hash.len(), 40); assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit()));
550
551 assert!(git_info.branch.is_some());
553 let branch = git_info.branch.unwrap();
554 assert!(branch == "main" || branch == "master");
555
556 }
559
560 #[tokio::test]
561 async fn test_collect_git_info_with_remote() {
562 let temp_dir = TempDir::new().expect("Failed to create temp dir");
563 let repo_path = create_test_git_repo(&temp_dir).await;
564
565 Command::new("git")
567 .args([
568 "remote",
569 "add",
570 "origin",
571 "https://github.com/example/repo.git",
572 ])
573 .current_dir(&repo_path)
574 .output()
575 .await
576 .expect("Failed to add remote");
577
578 let git_info = collect_git_info(&repo_path)
579 .await
580 .expect("Should collect git info from repo");
581
582 assert_eq!(
584 git_info.repository_url,
585 Some("https://github.com/example/repo.git".to_string())
586 );
587 }
588
589 #[tokio::test]
590 async fn test_collect_git_info_detached_head() {
591 let temp_dir = TempDir::new().expect("Failed to create temp dir");
592 let repo_path = create_test_git_repo(&temp_dir).await;
593
594 let output = Command::new("git")
596 .args(["rev-parse", "HEAD"])
597 .current_dir(&repo_path)
598 .output()
599 .await
600 .expect("Failed to get HEAD");
601 let commit_hash = String::from_utf8(output.stdout).unwrap().trim().to_string();
602
603 Command::new("git")
605 .args(["checkout", &commit_hash])
606 .current_dir(&repo_path)
607 .output()
608 .await
609 .expect("Failed to checkout commit");
610
611 let git_info = collect_git_info(&repo_path)
612 .await
613 .expect("Should collect git info from repo");
614
615 assert!(git_info.commit_hash.is_some());
617 assert!(git_info.branch.is_none());
619 }
620
621 #[tokio::test]
622 async fn test_collect_git_info_with_branch() {
623 let temp_dir = TempDir::new().expect("Failed to create temp dir");
624 let repo_path = create_test_git_repo(&temp_dir).await;
625
626 Command::new("git")
628 .args(["checkout", "-b", "feature-branch"])
629 .current_dir(&repo_path)
630 .output()
631 .await
632 .expect("Failed to create branch");
633
634 let git_info = collect_git_info(&repo_path)
635 .await
636 .expect("Should collect git info from repo");
637
638 assert_eq!(git_info.branch, Some("feature-branch".to_string()));
640 }
641
642 #[tokio::test]
643 async fn test_get_git_working_tree_state_clean_repo() {
644 let temp_dir = TempDir::new().expect("Failed to create temp dir");
645 let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
646
647 let remote_sha = Command::new("git")
648 .args(["rev-parse", &format!("origin/{branch}")])
649 .current_dir(&repo_path)
650 .output()
651 .await
652 .expect("Failed to rev-parse remote");
653 let remote_sha = String::from_utf8(remote_sha.stdout)
654 .unwrap()
655 .trim()
656 .to_string();
657
658 let state = git_diff_to_remote(&repo_path)
659 .await
660 .expect("Should collect working tree state");
661 assert_eq!(state.sha, GitSha::new(&remote_sha));
662 assert!(state.diff.is_empty());
663 }
664
665 #[tokio::test]
666 async fn test_get_git_working_tree_state_with_changes() {
667 let temp_dir = TempDir::new().expect("Failed to create temp dir");
668 let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
669
670 let tracked = repo_path.join("test.txt");
671 fs::write(&tracked, "modified").unwrap();
672 fs::write(repo_path.join("untracked.txt"), "new").unwrap();
673
674 let remote_sha = Command::new("git")
675 .args(["rev-parse", &format!("origin/{branch}")])
676 .current_dir(&repo_path)
677 .output()
678 .await
679 .expect("Failed to rev-parse remote");
680 let remote_sha = String::from_utf8(remote_sha.stdout)
681 .unwrap()
682 .trim()
683 .to_string();
684
685 let state = git_diff_to_remote(&repo_path)
686 .await
687 .expect("Should collect working tree state");
688 assert_eq!(state.sha, GitSha::new(&remote_sha));
689 assert!(state.diff.contains("test.txt"));
690 assert!(state.diff.contains("untracked.txt"));
691 }
692
693 #[tokio::test]
694 async fn test_get_git_working_tree_state_branch_fallback() {
695 let temp_dir = TempDir::new().expect("Failed to create temp dir");
696 let (repo_path, _branch) = create_test_git_repo_with_remote(&temp_dir).await;
697
698 Command::new("git")
699 .args(["checkout", "-b", "feature"])
700 .current_dir(&repo_path)
701 .output()
702 .await
703 .expect("Failed to create feature branch");
704 Command::new("git")
705 .args(["push", "-u", "origin", "feature"])
706 .current_dir(&repo_path)
707 .output()
708 .await
709 .expect("Failed to push feature branch");
710
711 Command::new("git")
712 .args(["checkout", "-b", "local-branch"])
713 .current_dir(&repo_path)
714 .output()
715 .await
716 .expect("Failed to create local branch");
717
718 let remote_sha = Command::new("git")
719 .args(["rev-parse", "origin/feature"])
720 .current_dir(&repo_path)
721 .output()
722 .await
723 .expect("Failed to rev-parse remote");
724 let remote_sha = String::from_utf8(remote_sha.stdout)
725 .unwrap()
726 .trim()
727 .to_string();
728
729 let state = git_diff_to_remote(&repo_path)
730 .await
731 .expect("Should collect working tree state");
732 assert_eq!(state.sha, GitSha::new(&remote_sha));
733 }
734
735 #[tokio::test]
736 async fn test_get_git_working_tree_state_unpushed_commit() {
737 let temp_dir = TempDir::new().expect("Failed to create temp dir");
738 let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
739
740 let remote_sha = Command::new("git")
741 .args(["rev-parse", &format!("origin/{branch}")])
742 .current_dir(&repo_path)
743 .output()
744 .await
745 .expect("Failed to rev-parse remote");
746 let remote_sha = String::from_utf8(remote_sha.stdout)
747 .unwrap()
748 .trim()
749 .to_string();
750
751 fs::write(repo_path.join("test.txt"), "updated").unwrap();
752 Command::new("git")
753 .args(["add", "test.txt"])
754 .current_dir(&repo_path)
755 .output()
756 .await
757 .expect("Failed to add file");
758 Command::new("git")
759 .args(["commit", "-m", "local change"])
760 .current_dir(&repo_path)
761 .output()
762 .await
763 .expect("Failed to commit");
764
765 let state = git_diff_to_remote(&repo_path)
766 .await
767 .expect("Should collect working tree state");
768 assert_eq!(state.sha, GitSha::new(&remote_sha));
769 assert!(state.diff.contains("updated"));
770 }
771
772 #[test]
773 fn test_git_info_serialization() {
774 let git_info = GitInfo {
775 commit_hash: Some("abc123def456".to_string()),
776 branch: Some("main".to_string()),
777 repository_url: Some("https://github.com/example/repo.git".to_string()),
778 };
779
780 let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo");
781 let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON");
782
783 assert_eq!(parsed["commit_hash"], "abc123def456");
784 assert_eq!(parsed["branch"], "main");
785 assert_eq!(
786 parsed["repository_url"],
787 "https://github.com/example/repo.git"
788 );
789 }
790
791 #[test]
792 fn test_git_info_serialization_with_nones() {
793 let git_info = GitInfo {
794 commit_hash: None,
795 branch: None,
796 repository_url: None,
797 };
798
799 let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo");
800 let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON");
801
802 assert!(!parsed.as_object().unwrap().contains_key("commit_hash"));
804 assert!(!parsed.as_object().unwrap().contains_key("branch"));
805 assert!(!parsed.as_object().unwrap().contains_key("repository_url"));
806 }
807}