Skip to main content

aether_evals/
git_repo.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3use thiserror::Error;
4
5/// Represents a git repository used for evaluation purposes
6pub struct GitRepo {
7    path: PathBuf,
8}
9
10impl GitRepo {
11    /// Create a `GitRepo` instance from an existing repository path
12    pub fn from_path(path: &Path) -> Self {
13        GitRepo { path: path.to_path_buf() }
14    }
15
16    /// Clone a repository to the specified destination
17    ///
18    /// Uses a blobless partial clone (--filter=blob:none) with --no-checkout for efficiency.
19    /// This downloads commit history and tree structures but defers downloading file blobs
20    /// until they're needed (e.g., during checkout or diff operations).
21    ///
22    /// This significantly reduces clone time and disk space usage, especially for large repos,
23    /// while still allowing full git operations. Blobs are automatically fetched on-demand.
24    #[tracing::instrument(skip(dest))]
25    pub fn clone(url: &str, dest: &Path) -> Result<Self, GitRepoError> {
26        tracing::debug!("Cloning repository from {} (blobless clone)", url);
27        let output = Command::new("git")
28            .arg("clone")
29            .arg("--no-checkout")
30            .arg("--filter=blob:none")
31            .arg(url)
32            .arg(dest)
33            .output()
34            .map_err(|e| GitRepoError::CommandFailed(format!("Failed to execute git clone: {e}")))?;
35
36        if !output.status.success() {
37            let stderr = String::from_utf8_lossy(&output.stderr);
38            return Err(GitRepoError::CloneFailed(stderr.to_string()));
39        }
40
41        Ok(GitRepo { path: dest.to_path_buf() })
42    }
43
44    /// Checkout a specific commit, branch, or tag
45    #[tracing::instrument(skip(self))]
46    pub fn checkout(&self, reference: &str) -> Result<(), GitRepoError> {
47        tracing::debug!("Checking out ref: {}", reference);
48        let output = Command::new("git")
49            .arg("-C")
50            .arg(&self.path)
51            .arg("checkout")
52            .arg(reference)
53            .output()
54            .map_err(|e| GitRepoError::CommandFailed(format!("Failed to execute git checkout: {e}")))?;
55
56        if !output.status.success() {
57            let stderr = String::from_utf8_lossy(&output.stderr);
58            return Err(GitRepoError::CheckoutFailed { reference: reference.to_string(), reason: stderr.to_string() });
59        }
60
61        Ok(())
62    }
63
64    /// Get the diff from a commit to another commit or working directory
65    ///
66    /// # Arguments
67    /// * `from_commit` - Starting commit
68    /// * `to_commit` - Ending commit (None means working directory/unstaged changes)
69    ///
70    /// # Examples
71    /// * `diff_range("abc123", Some("def456"))` - diff between two commits
72    /// * `diff_range("abc123", None)` - changes from commit to working directory
73    /// * `diff_range("HEAD", None)` - unstaged changes (equivalent to `git diff`)
74    #[tracing::instrument(skip(self))]
75    pub fn diff_range(&self, from_commit: &str, to_commit: Option<&str>) -> Result<String, GitRepoError> {
76        let mut cmd = Command::new("git");
77        cmd.arg("-C").arg(&self.path).arg("diff");
78
79        if let Some(to) = to_commit {
80            tracing::debug!("Getting diff from {} to {}", from_commit, to);
81            cmd.arg(format!("{from_commit}..{to}"));
82        } else {
83            tracing::debug!("Getting diff from {} to working directory", from_commit);
84            cmd.arg(from_commit);
85        }
86
87        let output =
88            cmd.output().map_err(|e| GitRepoError::CommandFailed(format!("Failed to execute git diff: {e}")))?;
89
90        if !output.status.success() {
91            let stderr = String::from_utf8_lossy(&output.stderr);
92            return Err(GitRepoError::DiffFailed {
93                from: from_commit.to_string(),
94                to: to_commit.unwrap_or("working directory").to_string(),
95                reason: stderr.to_string(),
96            });
97        }
98
99        let diff = String::from_utf8_lossy(&output.stdout).to_string();
100        Ok(diff)
101    }
102
103    /// Get the diff between two commits
104    ///
105    /// Returns the output of `git diff from_commit..to_commit`
106    pub fn diff(&self, from_commit: &str, to_commit: &str) -> Result<String, GitRepoError> {
107        self.diff_range(from_commit, Some(to_commit))
108    }
109
110    /// Get the diff of unstaged changes in the working directory
111    ///
112    /// Returns the output of `git diff` which shows all unstaged changes
113    pub fn diff_unstaged(&self) -> Result<String, GitRepoError> {
114        self.diff_range("HEAD", None)
115    }
116}
117
118#[derive(Debug, Error)]
119pub enum GitRepoError {
120    #[error("Git command failed: {0}")]
121    CommandFailed(String),
122
123    #[error("Failed to clone repository: {0}")]
124    CloneFailed(String),
125
126    #[error("Failed to checkout '{reference}': {reason}")]
127    CheckoutFailed { reference: String, reason: String },
128
129    #[error("Failed to diff '{from}..{to}': {reason}")]
130    DiffFailed { from: String, to: String, reason: String },
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use std::fs;
137
138    #[test]
139    fn test_git_diff_between_commits() {
140        let temp_dir = tempfile::tempdir().unwrap();
141        let repo_path = temp_dir.path();
142
143        Command::new("git").args(["init"]).current_dir(repo_path).output().unwrap();
144        Command::new("git").args(["config", "user.email", "test@example.com"]).current_dir(repo_path).output().unwrap();
145        Command::new("git").args(["config", "user.name", "Test User"]).current_dir(repo_path).output().unwrap();
146
147        fs::write(repo_path.join("test.txt"), "initial content\n").unwrap();
148        Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
149        Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(repo_path).output().unwrap();
150
151        let first_commit = String::from_utf8(
152            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
153        )
154        .unwrap()
155        .trim()
156        .to_string();
157
158        fs::write(repo_path.join("test.txt"), "modified content\n").unwrap();
159        fs::write(repo_path.join("new.txt"), "new file\n").unwrap();
160        Command::new("git").args(["add", "."]).current_dir(repo_path).output().unwrap();
161        Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(repo_path).output().unwrap();
162
163        let second_commit = String::from_utf8(
164            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
165        )
166        .unwrap()
167        .trim()
168        .to_string();
169
170        let git_repo = GitRepo::from_path(repo_path);
171        let diff = git_repo.diff(&first_commit, &second_commit).unwrap();
172
173        assert!(diff.contains("test.txt"), "Diff should mention test.txt");
174        assert!(diff.contains("new.txt"), "Diff should mention new.txt");
175        assert!(
176            diff.contains("modified content") || diff.contains("+modified content"),
177            "Diff should show modified content"
178        );
179    }
180
181    #[test]
182    fn test_unified_diff_function() {
183        let temp_dir = tempfile::tempdir().unwrap();
184        let repo_path = temp_dir.path();
185
186        Command::new("git").args(["init"]).current_dir(repo_path).output().unwrap();
187        Command::new("git").args(["config", "user.email", "test@example.com"]).current_dir(repo_path).output().unwrap();
188        Command::new("git").args(["config", "user.name", "Test User"]).current_dir(repo_path).output().unwrap();
189
190        fs::write(repo_path.join("test.txt"), "initial content\n").unwrap();
191        Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
192        Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(repo_path).output().unwrap();
193
194        let first_commit = String::from_utf8(
195            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
196        )
197        .unwrap()
198        .trim()
199        .to_string();
200
201        fs::write(repo_path.join("test.txt"), "modified content\n").unwrap();
202        Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
203        Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(repo_path).output().unwrap();
204
205        let second_commit = String::from_utf8(
206            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
207        )
208        .unwrap()
209        .trim()
210        .to_string();
211
212        fs::write(repo_path.join("test.txt"), "unstaged content\n").unwrap();
213
214        let git_repo = GitRepo::from_path(repo_path);
215
216        let diff = git_repo.diff_range(&first_commit, Some(&second_commit)).unwrap();
217        assert!(diff.contains("modified content") || diff.contains("+modified content"));
218
219        let unstaged_diff = git_repo.diff_range("HEAD", None).unwrap();
220        assert!(unstaged_diff.contains("unstaged content") || unstaged_diff.contains("+unstaged content"));
221
222        let from_commit_diff = git_repo.diff_range(&first_commit, None).unwrap();
223        assert!(from_commit_diff.contains("unstaged content") || from_commit_diff.contains("+unstaged content"));
224    }
225
226    #[test]
227    fn test_blobless_clone_and_checkout() {
228        let source_dir = tempfile::tempdir().unwrap();
229        let source_path = source_dir.path();
230
231        Command::new("git").args(["init"]).current_dir(source_path).output().unwrap();
232        Command::new("git")
233            .args(["config", "user.email", "test@example.com"])
234            .current_dir(source_path)
235            .output()
236            .unwrap();
237        Command::new("git").args(["config", "user.name", "Test User"]).current_dir(source_path).output().unwrap();
238
239        fs::write(source_path.join("test.txt"), "initial content\n").unwrap();
240        Command::new("git").args(["add", "test.txt"]).current_dir(source_path).output().unwrap();
241        Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(source_path).output().unwrap();
242
243        let first_commit = String::from_utf8(
244            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(source_path).output().unwrap().stdout,
245        )
246        .unwrap()
247        .trim()
248        .to_string();
249
250        fs::write(source_path.join("test.txt"), "modified content\n").unwrap();
251        Command::new("git").args(["add", "test.txt"]).current_dir(source_path).output().unwrap();
252        Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(source_path).output().unwrap();
253
254        let second_commit = String::from_utf8(
255            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(source_path).output().unwrap().stdout,
256        )
257        .unwrap()
258        .trim()
259        .to_string();
260
261        let clone_dir = tempfile::tempdir().unwrap();
262        let repo = GitRepo::clone(source_path.to_str().unwrap(), clone_dir.path()).unwrap();
263
264        let entries: Vec<_> = fs::read_dir(clone_dir.path())
265            .unwrap()
266            .filter_map(std::result::Result::ok)
267            .filter(|e| e.file_name() != ".git")
268            .collect();
269        assert_eq!(entries.len(), 0, "Working directory should be empty after blobless clone");
270
271        repo.checkout(&first_commit).unwrap();
272
273        let content = fs::read_to_string(clone_dir.path().join("test.txt")).unwrap();
274        assert_eq!(content, "initial content\n");
275
276        let diff = repo.diff(&first_commit, &second_commit).unwrap();
277        assert!(diff.contains("modified content") || diff.contains("+modified content"));
278    }
279}