Skip to main content

aether_evals/
git_repo.rs

1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use thiserror::Error;
5
6/// Represents a git repository used for evaluation purposes
7pub struct GitRepo {
8    path: PathBuf,
9}
10
11impl GitRepo {
12    /// Create a `GitRepo` instance from an existing repository path
13    pub fn from_path(path: &Path) -> Self {
14        GitRepo { path: path.to_path_buf() }
15    }
16
17    /// Initialize an empty repository at `dest`.
18    #[tracing::instrument(skip(dest))]
19    pub fn init(dest: &Path) -> Result<Self, GitRepoError> {
20        run_git(None, [OsStr::new("init"), dest.as_os_str()], GitRepoError::InitFailed)?;
21        Ok(GitRepo { path: dest.to_path_buf() })
22    }
23
24    /// Clone `source` (a URL or a local bundle/path) into `dest` with `--no-checkout`.
25    ///
26    /// When `blobless` is true, uses a partial blobless clone (`--filter=blob:none`) that
27    /// defers downloading file blobs until they're needed -- cheaper for large repos. When
28    /// false, the full object set is downloaded so the clone is self-contained (e.g. so it
29    /// can be bundled, or because `source` is a bundle and not a promisor remote).
30    #[tracing::instrument(skip(source, dest))]
31    pub fn clone(source: impl AsRef<OsStr>, dest: &Path, blobless: bool) -> Result<Self, GitRepoError> {
32        let mut args = vec![OsStr::new("clone"), OsStr::new("--no-checkout")];
33        if blobless {
34            args.push(OsStr::new("--filter=blob:none"));
35        }
36        args.push(source.as_ref());
37        args.push(dest.as_os_str());
38        run_git(None, args, GitRepoError::CloneFailed)?;
39        Ok(GitRepo { path: dest.to_path_buf() })
40    }
41
42    /// Checkout a specific commit, branch, or tag
43    #[tracing::instrument(skip(self))]
44    pub fn checkout(&self, reference: &str) -> Result<(), GitRepoError> {
45        run_git(Some(&self.path), ["checkout", reference], |reason| GitRepoError::CheckoutFailed {
46            reference: reference.to_string(),
47            reason,
48        })?;
49        Ok(())
50    }
51
52    /// Fetch `revs` from `remote` (a named remote or a URL) into this repository.
53    #[tracing::instrument(skip(self))]
54    pub fn fetch(&self, remote: &str, revs: &[&str]) -> Result<(), GitRepoError> {
55        let mut args = vec!["fetch", remote];
56        args.extend_from_slice(revs);
57        run_git(Some(&self.path), args, GitRepoError::FetchFailed)?;
58        Ok(())
59    }
60
61    /// Point a ref at a commit (e.g. to create a branch tip to bundle).
62    #[tracing::instrument(skip(self))]
63    pub fn update_ref(&self, name: &str, commit: &str) -> Result<(), GitRepoError> {
64        run_git(Some(&self.path), ["update-ref", name, commit], GitRepoError::UpdateRefFailed)?;
65        Ok(())
66    }
67
68    /// Create a self-contained git bundle at `out` containing the given refs and their
69    /// reachable objects.
70    #[tracing::instrument(skip(self, out))]
71    pub fn bundle(&self, revs: &[&str], out: &Path) -> Result<(), GitRepoError> {
72        let mut args = vec![OsStr::new("bundle"), OsStr::new("create"), out.as_os_str()];
73        args.extend(revs.iter().map(OsStr::new));
74        run_git(Some(&self.path), args, GitRepoError::BundleFailed)?;
75        Ok(())
76    }
77
78    /// Get the diff from a commit to another commit or working directory
79    ///
80    /// # Arguments
81    /// * `from_commit` - Starting commit
82    /// * `to_commit` - Ending commit (None means working directory/unstaged changes)
83    ///
84    /// # Examples
85    /// * `diff_range("abc123", Some("def456"))` - diff between two commits
86    /// * `diff_range("abc123", None)` - changes from commit to working directory
87    /// * `diff_range("HEAD", None)` - unstaged changes (equivalent to `git diff`)
88    #[tracing::instrument(skip(self))]
89    pub fn diff_range(&self, from_commit: &str, to_commit: Option<&str>) -> Result<String, GitRepoError> {
90        let range = match to_commit {
91            Some(to) => format!("{from_commit}..{to}"),
92            None => from_commit.to_string(),
93        };
94        run_git(Some(&self.path), ["diff", range.as_str()], |reason| GitRepoError::DiffFailed {
95            from: from_commit.to_string(),
96            to: to_commit.unwrap_or("working directory").to_string(),
97            reason,
98        })
99    }
100
101    /// Get the diff between two commits
102    ///
103    /// Returns the output of `git diff from_commit..to_commit`
104    pub fn diff(&self, from_commit: &str, to_commit: &str) -> Result<String, GitRepoError> {
105        self.diff_range(from_commit, Some(to_commit))
106    }
107
108    /// Get the diff of unstaged changes in the working directory
109    ///
110    /// Returns the output of `git diff` which shows all unstaged changes
111    pub fn diff_unstaged(&self) -> Result<String, GitRepoError> {
112        self.diff_range("HEAD", None)
113    }
114}
115
116/// Run `git` (optionally inside `cwd`) and return its stdout.
117///
118/// Spawn failures are surfaced as [`GitRepoError::CommandFailed`]; a non-zero exit is mapped
119/// to a caller-chosen variant via `on_failure`, which receives the captured stderr.
120fn run_git<T: IntoIterator<Item = U>, U: AsRef<OsStr>>(
121    cwd: Option<&Path>,
122    args: T,
123    on_failure: impl FnOnce(String) -> GitRepoError,
124) -> Result<String, GitRepoError> {
125    let mut cmd = Command::new("git");
126    if let Some(dir) = cwd {
127        cmd.arg("-C").arg(dir);
128    }
129
130    let output =
131        cmd.args(args).output().map_err(|e| GitRepoError::CommandFailed(format!("Failed to execute git: {e}")))?;
132
133    if !output.status.success() {
134        return Err(on_failure(String::from_utf8_lossy(&output.stderr).into_owned()));
135    }
136
137    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
138}
139
140#[derive(Debug, Error)]
141pub enum GitRepoError {
142    #[error("Git command failed: {0}")]
143    CommandFailed(String),
144
145    #[error("Failed to initialize repository: {0}")]
146    InitFailed(String),
147
148    #[error("Failed to clone repository: {0}")]
149    CloneFailed(String),
150
151    #[error("Failed to fetch revisions: {0}")]
152    FetchFailed(String),
153
154    #[error("Failed to update ref: {0}")]
155    UpdateRefFailed(String),
156
157    #[error("Failed to create git bundle: {0}")]
158    BundleFailed(String),
159
160    #[error("Failed to checkout '{reference}': {reason}")]
161    CheckoutFailed { reference: String, reason: String },
162
163    #[error("Failed to diff '{from}..{to}': {reason}")]
164    DiffFailed { from: String, to: String, reason: String },
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use std::fs;
171
172    #[test]
173    fn test_git_diff_between_commits() {
174        let temp_dir = tempfile::tempdir().unwrap();
175        let repo_path = temp_dir.path();
176
177        Command::new("git").args(["init"]).current_dir(repo_path).output().unwrap();
178        Command::new("git").args(["config", "user.email", "test@example.com"]).current_dir(repo_path).output().unwrap();
179        Command::new("git").args(["config", "user.name", "Test User"]).current_dir(repo_path).output().unwrap();
180
181        fs::write(repo_path.join("test.txt"), "initial content\n").unwrap();
182        Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
183        Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(repo_path).output().unwrap();
184
185        let first_commit = String::from_utf8(
186            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
187        )
188        .unwrap()
189        .trim()
190        .to_string();
191
192        fs::write(repo_path.join("test.txt"), "modified content\n").unwrap();
193        fs::write(repo_path.join("new.txt"), "new file\n").unwrap();
194        Command::new("git").args(["add", "."]).current_dir(repo_path).output().unwrap();
195        Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(repo_path).output().unwrap();
196
197        let second_commit = String::from_utf8(
198            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
199        )
200        .unwrap()
201        .trim()
202        .to_string();
203
204        let git_repo = GitRepo::from_path(repo_path);
205        let diff = git_repo.diff(&first_commit, &second_commit).unwrap();
206
207        assert!(diff.contains("test.txt"), "Diff should mention test.txt");
208        assert!(diff.contains("new.txt"), "Diff should mention new.txt");
209        assert!(
210            diff.contains("modified content") || diff.contains("+modified content"),
211            "Diff should show modified content"
212        );
213    }
214
215    #[test]
216    fn test_unified_diff_function() {
217        let temp_dir = tempfile::tempdir().unwrap();
218        let repo_path = temp_dir.path();
219
220        Command::new("git").args(["init"]).current_dir(repo_path).output().unwrap();
221        Command::new("git").args(["config", "user.email", "test@example.com"]).current_dir(repo_path).output().unwrap();
222        Command::new("git").args(["config", "user.name", "Test User"]).current_dir(repo_path).output().unwrap();
223
224        fs::write(repo_path.join("test.txt"), "initial content\n").unwrap();
225        Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
226        Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(repo_path).output().unwrap();
227
228        let first_commit = String::from_utf8(
229            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
230        )
231        .unwrap()
232        .trim()
233        .to_string();
234
235        fs::write(repo_path.join("test.txt"), "modified content\n").unwrap();
236        Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
237        Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(repo_path).output().unwrap();
238
239        let second_commit = String::from_utf8(
240            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
241        )
242        .unwrap()
243        .trim()
244        .to_string();
245
246        fs::write(repo_path.join("test.txt"), "unstaged content\n").unwrap();
247
248        let git_repo = GitRepo::from_path(repo_path);
249
250        let diff = git_repo.diff_range(&first_commit, Some(&second_commit)).unwrap();
251        assert!(diff.contains("modified content") || diff.contains("+modified content"));
252
253        let unstaged_diff = git_repo.diff_range("HEAD", None).unwrap();
254        assert!(unstaged_diff.contains("unstaged content") || unstaged_diff.contains("+unstaged content"));
255
256        let from_commit_diff = git_repo.diff_range(&first_commit, None).unwrap();
257        assert!(from_commit_diff.contains("unstaged content") || from_commit_diff.contains("+unstaged content"));
258    }
259
260    #[test]
261    fn test_blobless_clone_and_checkout() {
262        let source_dir = tempfile::tempdir().unwrap();
263        let source_path = source_dir.path();
264
265        Command::new("git").args(["init"]).current_dir(source_path).output().unwrap();
266        Command::new("git")
267            .args(["config", "user.email", "test@example.com"])
268            .current_dir(source_path)
269            .output()
270            .unwrap();
271        Command::new("git").args(["config", "user.name", "Test User"]).current_dir(source_path).output().unwrap();
272
273        fs::write(source_path.join("test.txt"), "initial content\n").unwrap();
274        Command::new("git").args(["add", "test.txt"]).current_dir(source_path).output().unwrap();
275        Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(source_path).output().unwrap();
276
277        let first_commit = String::from_utf8(
278            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(source_path).output().unwrap().stdout,
279        )
280        .unwrap()
281        .trim()
282        .to_string();
283
284        fs::write(source_path.join("test.txt"), "modified content\n").unwrap();
285        Command::new("git").args(["add", "test.txt"]).current_dir(source_path).output().unwrap();
286        Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(source_path).output().unwrap();
287
288        let second_commit = String::from_utf8(
289            Command::new("git").args(["rev-parse", "HEAD"]).current_dir(source_path).output().unwrap().stdout,
290        )
291        .unwrap()
292        .trim()
293        .to_string();
294
295        let clone_dir = tempfile::tempdir().unwrap();
296        let repo = GitRepo::clone(source_path.to_str().unwrap(), clone_dir.path(), true).unwrap();
297
298        let entries: Vec<_> = fs::read_dir(clone_dir.path())
299            .unwrap()
300            .filter_map(std::result::Result::ok)
301            .filter(|e| e.file_name() != ".git")
302            .collect();
303        assert_eq!(entries.len(), 0, "Working directory should be empty after blobless clone");
304
305        repo.checkout(&first_commit).unwrap();
306
307        let content = fs::read_to_string(clone_dir.path().join("test.txt")).unwrap();
308        assert_eq!(content, "initial content\n");
309
310        let diff = repo.diff(&first_commit, &second_commit).unwrap();
311        assert!(diff.contains("modified content") || diff.contains("+modified content"));
312    }
313}