agcodex_core/
git_info.rs

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
14/// Timeout for git commands to prevent freezing on large repositories
15const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
16
17#[derive(Serialize, Deserialize, Clone, Debug)]
18pub struct GitInfo {
19    /// Current commit hash (SHA)
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub commit_hash: Option<String>,
22    /// Current branch name
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub branch: Option<String>,
25    /// Repository URL (if available from remote)
26    #[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
36/// Collect git repository information from the given working directory using command-line git.
37/// Returns None if no git repository is found or if git operations fail.
38/// Uses timeouts to prevent freezing on large repositories.
39/// All git commands (except the initial repo check) run in parallel for better performance.
40pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
41    // Check if we're in a git repository first
42    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    // Run all git info collection commands in parallel
52    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    // Process commit hash
65    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    // Process branch name
73    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    // Process repository URL
84    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
94/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
95pub 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
111/// Run a git command with a timeout to prevent blocking on large repositories
112async 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, // Timeout or error
122    }
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
142/// Attempt to determine the repository's default branch name.
143///
144/// Preference order:
145/// 1) The symbolic ref at `refs/remotes/<remote>/HEAD` for the first remote (origin prioritized)
146/// 2) `git remote show <remote>` parsed for "HEAD branch: <name>"
147/// 3) Local fallback to existing `main` or `master` if present
148async fn get_default_branch(cwd: &Path) -> Option<String> {
149    // Prefer the first remote (with origin prioritized)
150    let remotes = get_git_remotes(cwd).await.unwrap_or_default();
151    for remote in remotes {
152        // Try symbolic-ref, which returns something like: refs/remotes/origin/main
153        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        // Fall back to parsing `git remote show <remote>` output
172        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    // No remote-derived default; try common local defaults if they exist
190    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
210/// Build an ancestry of branches starting at the current branch and ending at the
211/// repository's default branch (if determinable)..
212async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
213    // Discover current branch (ignore detached HEAD by treating it as None)
214    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    // Discover default branch
227    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    // Expand candidates: include any remote branches that already contain HEAD.
243    // This addresses cases where we're on a new local-only branch forked from a
244    // remote branch that isn't the repository default. We prioritize remotes in
245    // the order returned by get_git_remotes (origin first).
246    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                // Expect format like: "origin/feature"; extract the branch path after "remote/"
264                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    // Ensure we return Some vector, even if empty, to allow caller logic to proceed
276    Some(ancestry)
277}
278
279// Helper for a single branch: return the remote SHA if present on any remote
280// and the distance (commits ahead of HEAD) for that branch. The first item is
281// None if the branch is not present on any remote. Returns None if distance
282// could not be computed due to git errors/timeouts.
283async fn branch_remote_and_distance(
284    cwd: &Path,
285    branch: &str,
286    remotes: &[String],
287) -> Option<(Option<GitSha>, usize)> {
288    // Try to find the first remote ref that exists for this branch (origin prioritized by caller).
289    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            // Mirror previous behavior: if the verify call times out/fails at the process level,
298            // treat the entire branch as unusable.
299            return None;
300        };
301        if !verify_output.status.success() {
302            continue;
303        }
304        let Ok(sha) = String::from_utf8(verify_output.stdout) else {
305            // Mirror previous behavior and skip the entire branch on parse failure.
306            return None;
307        };
308        found_remote_sha = Some(GitSha::new(sha.trim()));
309        found_remote_ref = Some(remote_ref);
310        break;
311    }
312
313    // Compute distance as the number of commits HEAD is ahead of the branch.
314    // Prefer local branch name if it exists; otherwise fall back to the remote ref (if any).
315    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
361// Finds the closest sha that exist on any of branches and also exists on any of the remotes.
362async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<GitSha> {
363    // A sha and how many commits away from HEAD it is.
364    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            // Preserve existing behavior: skip branches that are not present on a remote.
373            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    // 0 is success and no diff.
389    // 1 is success but there is a diff.
390    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    // Helper function to create a test git repository
437    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        // Initialize git repo
446        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        // Configure git user (required for commits)
455        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        // Create a test file and commit it
472        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        // Should have commit hash
546        assert!(git_info.commit_hash.is_some());
547        let commit_hash = git_info.commit_hash.unwrap();
548        assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters
549        assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit()));
550
551        // Should have branch (likely "main" or "master")
552        assert!(git_info.branch.is_some());
553        let branch = git_info.branch.unwrap();
554        assert!(branch == "main" || branch == "master");
555
556        // Repository URL might be None for local repos without remote
557        // This is acceptable behavior
558    }
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        // Add a remote origin
566        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        // Should have repository URL
583        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        // Get the current commit hash
595        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        // Checkout the commit directly (detached HEAD)
604        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        // Should have commit hash
616        assert!(git_info.commit_hash.is_some());
617        // Branch should be None for detached HEAD (since rev-parse --abbrev-ref HEAD returns "HEAD")
618        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        // Create and checkout a new branch
627        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        // Should have the new branch name
639        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        // Fields with None values should be omitted due to skip_serializing_if
803        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}