Skip to main content

affected_core/
git.rs

1use anyhow::{Context, Result};
2use git2::Repository;
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use tracing::debug;
6
7#[non_exhaustive]
8pub struct GitDiff {
9    pub changed_files: Vec<PathBuf>,
10    pub repo_root: PathBuf,
11}
12
13/// Compute changed files between a base ref and HEAD (plus uncommitted changes).
14pub fn changed_files(repo_path: &Path, base_ref: &str) -> Result<GitDiff> {
15    let repo = Repository::discover(repo_path).context("Not a git repository")?;
16
17    let repo_root = repo
18        .workdir()
19        .context("Bare repositories are not supported")?
20        .to_path_buf();
21
22    debug!("Found git repository at {}", repo_root.display());
23
24    let base_obj = repo
25        .revparse_single(base_ref)
26        .with_context(|| format!("Could not resolve base ref '{base_ref}'"))?;
27
28    debug!("Resolved base ref '{}' to {}", base_ref, base_obj.id());
29
30    let base_tree = base_obj
31        .peel_to_tree()
32        .with_context(|| format!("Could not peel '{base_ref}' to a tree"))?;
33
34    let head_ref = repo.head().context("Could not get HEAD")?;
35    let head_tree = head_ref
36        .peel_to_tree()
37        .context("Could not peel HEAD to a tree")?;
38
39    // Refresh index from disk to pick up changes made by the git CLI
40    let mut index = repo.index().context("Could not read index")?;
41    index
42        .read(true)
43        .context("Could not refresh index from disk")?;
44
45    let mut files = HashSet::new();
46
47    // Committed changes: base..HEAD
48    let diff_committed = repo
49        .diff_tree_to_tree(Some(&base_tree), Some(&head_tree), None)
50        .context("Failed to diff base..HEAD")?;
51
52    for delta in diff_committed.deltas() {
53        if let Some(p) = delta.new_file().path() {
54            files.insert(p.to_path_buf());
55        }
56        if let Some(p) = delta.old_file().path() {
57            files.insert(p.to_path_buf());
58        }
59    }
60
61    // Uncommitted changes: HEAD vs working tree + index
62    let diff_uncommitted = repo
63        .diff_tree_to_workdir_with_index(Some(&head_tree), None)
64        .context("Failed to diff HEAD vs working tree")?;
65
66    for delta in diff_uncommitted.deltas() {
67        if let Some(p) = delta.new_file().path() {
68            files.insert(p.to_path_buf());
69        }
70        if let Some(p) = delta.old_file().path() {
71            files.insert(p.to_path_buf());
72        }
73    }
74
75    let mut changed_files: Vec<PathBuf> = files.into_iter().collect();
76    changed_files.sort();
77
78    debug!("Detected {} changed files", changed_files.len());
79
80    Ok(GitDiff {
81        changed_files,
82        repo_root,
83    })
84}
85
86/// Compute the merge-base between HEAD and the given branch.
87/// Returns the commit SHA as a string. Used when `--merge-base` is passed.
88pub fn merge_base(repo_path: &Path, branch: &str) -> Result<String> {
89    let repo = Repository::discover(repo_path).context("Not a git repository")?;
90
91    debug!("Computing merge-base between HEAD and '{}'", branch);
92
93    let head_oid = repo
94        .head()
95        .context("Could not get HEAD")?
96        .target()
97        .context("HEAD is not a direct reference")?;
98
99    let branch_obj = repo
100        .revparse_single(branch)
101        .with_context(|| format!("Could not resolve branch '{branch}'"))?;
102    let branch_oid = branch_obj.id();
103
104    let merge_base_oid = repo
105        .merge_base(head_oid, branch_oid)
106        .with_context(|| format!("Could not find merge-base between HEAD and '{branch}'"))?;
107
108    let sha = merge_base_oid.to_string();
109    debug!("Merge-base resolved to {}", sha);
110
111    Ok(sha)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::process::Command;
118
119    fn git(dir: &Path, args: &[&str]) -> String {
120        let output = Command::new("git")
121            .args(args)
122            .current_dir(dir)
123            .env("GIT_AUTHOR_NAME", "test")
124            .env("GIT_AUTHOR_EMAIL", "test@test.com")
125            .env("GIT_COMMITTER_NAME", "test")
126            .env("GIT_COMMITTER_EMAIL", "test@test.com")
127            .output()
128            .expect("git command failed");
129        assert!(
130            output.status.success(),
131            "git {:?} failed: {}",
132            args,
133            String::from_utf8_lossy(&output.stderr)
134        );
135        String::from_utf8_lossy(&output.stdout).trim().to_string()
136    }
137
138    fn setup_repo(dir: &Path) {
139        git(dir, &["init"]);
140        git(dir, &["config", "user.email", "test@test.com"]);
141        git(dir, &["config", "user.name", "test"]);
142        std::fs::write(dir.join("file.txt"), "initial").unwrap();
143        git(dir, &["add", "."]);
144        git(dir, &["commit", "-m", "initial"]);
145    }
146
147    #[test]
148    fn test_changed_files_committed() {
149        let dir = tempfile::tempdir().unwrap();
150        setup_repo(dir.path());
151        let base = git(dir.path(), &["rev-parse", "HEAD"]);
152
153        std::fs::write(dir.path().join("new.txt"), "new content").unwrap();
154        git(dir.path(), &["add", "."]);
155        git(dir.path(), &["commit", "-m", "add new file"]);
156
157        let diff = changed_files(dir.path(), &base).unwrap();
158        assert!(diff.changed_files.iter().any(|f| f.ends_with("new.txt")));
159    }
160
161    #[test]
162    fn test_changed_files_uncommitted() {
163        let dir = tempfile::tempdir().unwrap();
164        setup_repo(dir.path());
165        let base = git(dir.path(), &["rev-parse", "HEAD"]);
166
167        // Modify an existing tracked file (untracked files don't show in tree diff)
168        std::fs::write(dir.path().join("file.txt"), "modified content").unwrap();
169
170        let diff = changed_files(dir.path(), &base).unwrap();
171        assert!(diff.changed_files.iter().any(|f| f.ends_with("file.txt")));
172    }
173
174    #[test]
175    fn test_changed_files_invalid_ref() {
176        let dir = tempfile::tempdir().unwrap();
177        setup_repo(dir.path());
178
179        let result = changed_files(dir.path(), "nonexistent_ref_xyz");
180        assert!(result.is_err());
181    }
182
183    #[test]
184    fn test_merge_base_computation() {
185        let dir = tempfile::tempdir().unwrap();
186        setup_repo(dir.path());
187
188        // Get the default branch name (may be "main", "master", etc.)
189        let default_branch = git(dir.path(), &["branch", "--show-current"]);
190        let main_sha = git(dir.path(), &["rev-parse", "HEAD"]);
191        git(dir.path(), &["checkout", "-b", "feature"]);
192        std::fs::write(dir.path().join("feature.txt"), "feature").unwrap();
193        git(dir.path(), &["add", "."]);
194        git(dir.path(), &["commit", "-m", "feature commit"]);
195
196        let result = merge_base(dir.path(), &default_branch).unwrap();
197        assert_eq!(result, main_sha);
198    }
199
200    #[test]
201    fn test_not_a_git_repo() {
202        let dir = tempfile::tempdir().unwrap();
203        let result = changed_files(dir.path(), "HEAD");
204        assert!(result.is_err());
205    }
206}