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