Skip to main content

aether_evals/evals/
git_fixture.rs

1use super::diff::GitDiff;
2use super::workspace::{Workspace, new_temp_dir};
3use crate::WorkspaceError;
4use crate::git_repo::GitRepo;
5use schemars::JsonSchema;
6use std::{
7    ffi::OsString,
8    path::{Path, PathBuf},
9};
10use tempfile::TempDir;
11
12#[derive(Debug, Clone, serde::Deserialize, JsonSchema)]
13#[serde(rename_all = "camelCase", deny_unknown_fields)]
14pub struct GitSpec {
15    pub source: GitSource,
16    pub start_commit: String,
17    pub gold_commit: String,
18    #[serde(default)]
19    pub subdir: Option<PathBuf>,
20    #[serde(default = "default_branch")]
21    pub branch: String,
22    #[serde(default)]
23    pub diff_exclude: Vec<String>,
24}
25
26#[derive(Debug, Clone, serde::Deserialize, JsonSchema)]
27#[serde(rename_all = "camelCase")]
28pub enum GitSource {
29    Url(String),
30    Bundle(PathBuf),
31}
32
33pub struct GitFixture {
34    repo: GitRepo,
35    spec: GitSpec,
36    reference_diff: GitDiff,
37    _repo_dir: TempDir,
38}
39
40impl GitSpec {
41    pub fn new(source: GitSource, start_commit: impl Into<String>, gold_commit: impl Into<String>) -> Self {
42        Self {
43            source,
44            start_commit: start_commit.into(),
45            gold_commit: gold_commit.into(),
46            subdir: None,
47            branch: default_branch(),
48            diff_exclude: Vec::new(),
49        }
50    }
51}
52
53impl GitFixture {
54    pub fn fetch(spec: GitSpec) -> Result<Self, WorkspaceError> {
55        let GitSpec { source, start_commit, gold_commit, diff_exclude, .. } = &spec;
56        let remote = source.remote()?;
57        tracing::debug!("Fetching {:?} at commits {} and {}", remote, start_commit, gold_commit);
58
59        let repo_dir = new_temp_dir()?;
60        let repo = GitRepo::init(repo_dir.path())?;
61        repo.fetch(&remote, &[start_commit, gold_commit])?;
62        repo.update_ref(&format!("refs/heads/{EVAL_START_REF}"), start_commit)?;
63        repo.update_ref(&format!("refs/heads/{EVAL_GOLD_REF}"), gold_commit)?;
64        let reference_diff = GitDiff::new(repo.diff(start_commit, gold_commit, diff_exclude)?);
65
66        Ok(Self { repo, spec, reference_diff, _repo_dir: repo_dir })
67    }
68
69    pub fn reference_diff(&self) -> &GitDiff {
70        &self.reference_diff
71    }
72
73    pub fn workspace(&self) -> Result<Workspace, WorkspaceError> {
74        let temp_dir = new_temp_dir()?;
75        let repo = GitRepo::init(temp_dir.path())?;
76        repo.fetch(self.repo.path(), &[&self.spec.start_commit])?;
77        repo.checkout_branch(&self.spec.branch, &self.spec.start_commit)?;
78        Workspace::from_temp_dir(temp_dir, self.spec.subdir.clone())
79    }
80
81    pub fn agent_diff(&self, workspace: &Workspace) -> Result<GitDiff, WorkspaceError> {
82        let repo = GitRepo::from_path(workspace.root_path());
83        let diff = repo.diff_working_tree(&self.spec.start_commit, &self.spec.diff_exclude)?;
84        Ok(GitDiff::new(diff))
85    }
86
87    pub fn bundle(&self, out: &Path) -> Result<(), WorkspaceError> {
88        self.repo.bundle(&[EVAL_START_REF, EVAL_GOLD_REF], &absolute(out)?)?;
89        Ok(())
90    }
91}
92
93const EVAL_START_REF: &str = "eval-start";
94const EVAL_GOLD_REF: &str = "eval-gold";
95
96impl GitSource {
97    fn remote(&self) -> Result<OsString, WorkspaceError> {
98        match self {
99            Self::Url(url) => Ok(url.into()),
100            Self::Bundle(path) => {
101                let path = absolute(path)?;
102                if !path.exists() {
103                    return Err(WorkspaceError::MissingBundle { path });
104                }
105                Ok(path.into_os_string())
106            }
107        }
108    }
109}
110
111fn default_branch() -> String {
112    "main".to_string()
113}
114
115fn absolute(path: &Path) -> Result<PathBuf, WorkspaceError> {
116    std::path::absolute(path).map_err(|source| WorkspaceError::ResolvePath { path: path.to_path_buf(), source })
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::fs::{create_dir_all, read_to_string, remove_file, write};
123    use utils::testing::{TestWorkspace, git};
124
125    #[test]
126    fn a_bundled_fixture_round_trips_checkout_subdir_and_diffs() {
127        let (_repo, spec) = init_repo();
128        let bundle_dir = tempfile::tempdir().unwrap();
129        let (fixture, workspace) =
130            checkout(GitSpec { subdir: Some("pkg".into()), ..bundle_spec(&spec, bundle_dir.path()) });
131
132        assert_eq!(read_to_string(workspace.root_path().join("root.txt")).unwrap(), "root v1\n");
133        assert_eq!(workspace.relative_cwd(), Some(Path::new("pkg")));
134        assert_eq!(workspace.path(), workspace.root_path().join("pkg"));
135        assert!(fixture.reference_diff().diff.contains("root v2"), "reference diff should span start..gold");
136        assert!(agent_diff(&fixture, &workspace).diff.is_empty(), "no agent edits yet");
137
138        write(workspace.root_path().join("root.txt"), "root edited\n").unwrap();
139
140        assert!(agent_diff(&fixture, &workspace).diff.contains("root edited"), "agent diff should capture the edit");
141    }
142
143    #[test]
144    fn bundles_round_trip_through_relative_paths() {
145        let (_repo, spec) = init_repo();
146        let bundle_dir = tempfile::tempdir_in(".").unwrap();
147        let cwd = std::env::current_dir().unwrap();
148        let bundle_path = bundle_dir.path().strip_prefix(cwd).unwrap().join("repo.bundle");
149
150        fixture(spec.clone()).bundle(&bundle_path).unwrap();
151        let (fixture, workspace) = checkout(GitSpec { source: GitSource::Bundle(bundle_path), ..spec.clone() });
152
153        assert_eq!(git(workspace.root_path(), &["rev-parse", "HEAD"]), spec.start_commit);
154        assert!(fixture.reference_diff().diff.contains("+root v2"));
155    }
156
157    #[test]
158    fn workspaces_hold_only_the_start_commit() {
159        let (_repo, spec) = init_repo();
160        let bundle_dir = tempfile::tempdir().unwrap();
161        let fixtures = [fixture(spec.clone()), fixture(bundle_spec(&spec, bundle_dir.path()))];
162
163        for fixture in fixtures {
164            let workspace = fixture.workspace().unwrap();
165            let root = workspace.root_path();
166            assert_eq!(git(root, &["rev-parse", "HEAD"]), spec.start_commit);
167            assert_eq!(git(root, &["for-each-ref", "--format=%(refname)"]), "refs/heads/main");
168            assert_eq!(git(root, &["remote"]), "");
169            assert!(
170                utils::git::git(Some(root), &["cat-file", "-e", &spec.gold_commit]).is_err(),
171                "the gold commit must not be in the agent's repository"
172            );
173            assert!(fixture.reference_diff().diff.contains("+root v2"));
174        }
175    }
176
177    #[test]
178    fn each_workspace_starts_fresh() {
179        let (_repo, spec) = init_repo();
180        let (fixture, first) = checkout(spec);
181        write(first.join("root.txt"), "agent\n").unwrap();
182
183        let second = fixture.workspace().unwrap();
184
185        assert_ne!(first.root_path(), second.root_path());
186        assert_eq!(read_to_string(second.join("root.txt")).unwrap(), "root v1\n");
187    }
188
189    #[test]
190    fn workspaces_check_out_the_requested_branch() {
191        let (_repo, spec) = init_repo();
192        let (_fixture, workspace) = checkout(GitSpec { branch: "trunk".into(), ..spec });
193
194        assert_eq!(git(workspace.root_path(), &["branch", "--show-current"]), "trunk");
195    }
196
197    #[test]
198    fn diff_exclude_leaves_paths_out_of_the_reference_and_agent_diffs() {
199        let (_repo, spec) = init_repo();
200        let (fixture, workspace) = checkout(GitSpec { diff_exclude: vec!["*root.txt".into()], ..spec });
201        write(workspace.join("root.txt"), "agent\n").unwrap();
202        write(workspace.join("pkg/inner.txt"), "agent inner\n").unwrap();
203
204        let agent_diff = agent_diff(&fixture, &workspace);
205
206        assert_eq!(fixture.reference_diff().diff, "");
207        assert!(agent_diff.diff.contains("+agent inner"));
208        assert!(!agent_diff.diff.contains("root.txt"));
209    }
210
211    #[test]
212    fn agent_diff_includes_committed_changes() {
213        let (_repo, spec) = init_repo();
214        let (fixture, workspace) = checkout(spec);
215
216        write(workspace.join("root.txt"), "agent committed\n").unwrap();
217        git(workspace.root_path(), &["add", "."]);
218        git(
219            workspace.root_path(),
220            &["-c", "user.email=agent@example.com", "-c", "user.name=Agent", "commit", "-m", "agent change"],
221        );
222
223        let agent_diff = agent_diff(&fixture, &workspace);
224
225        assert!(agent_diff.diff.contains("+agent committed"));
226        assert_eq!(agent_diff.stats.files_changed, 1);
227        assert_eq!(agent_diff.stats.lines_added, 1);
228        assert_eq!(agent_diff.stats.lines_removed, 1);
229    }
230
231    #[test]
232    fn agent_diff_includes_staged_unstaged_added_deleted_and_untracked_changes() {
233        let (_repo, spec) = init_repo();
234        let (fixture, workspace) = checkout(spec);
235
236        write(workspace.join("root.txt"), "staged change\n").unwrap();
237        write(workspace.join("added.txt"), "added\n").unwrap();
238        git(workspace.root_path(), &["add", "root.txt", "added.txt"]);
239        write(workspace.join("pkg/inner.txt"), "unstaged change\n").unwrap();
240        remove_file(workspace.join("deleted.txt")).unwrap();
241        create_dir_all(workspace.join("dir")).unwrap();
242        write(workspace.join("dir/untracked.txt"), "untracked\n").unwrap();
243
244        let agent_diff = agent_diff(&fixture, &workspace);
245
246        assert!(agent_diff.diff.contains("root.txt"));
247        assert!(agent_diff.diff.contains("+staged change"));
248        assert!(agent_diff.diff.contains("pkg/inner.txt"));
249        assert!(agent_diff.diff.contains("+unstaged change"));
250        assert!(agent_diff.diff.contains("added.txt"));
251        assert!(agent_diff.diff.contains("+added"));
252        assert!(agent_diff.diff.contains("deleted.txt"));
253        assert!(agent_diff.diff.contains("-before deleted"));
254        assert!(agent_diff.diff.contains("+untracked"));
255        assert_eq!(agent_diff.stats.files_changed, 5);
256        assert_eq!(agent_diff.stats.lines_added, 4);
257        assert_eq!(agent_diff.stats.lines_removed, 3);
258        assert!(git(workspace.root_path(), &["status", "--porcelain"]).contains("?? dir/"), "the index is untouched");
259    }
260
261    #[test]
262    fn agent_diff_keeps_files_tracked_despite_gitignore() {
263        let repo = TestWorkspace::new().file(".gitignore", "*.env\n").git_init();
264        repo.write("app.env", "start\n");
265        repo.git(&["add", "--force", "app.env"]);
266        let start_commit = repo.commit("start");
267        let source = GitSource::Url(format!("file://{}", repo.root().display()));
268        let (fixture, workspace) = checkout(GitSpec::new(source, start_commit.clone(), start_commit));
269
270        let agent_diff = agent_diff(&fixture, &workspace);
271
272        assert_eq!(agent_diff.diff, "");
273    }
274
275    #[test]
276    fn agent_diff_rebuilds_a_deleted_index() {
277        let (_repo, spec) = init_repo();
278        let (fixture, workspace) = checkout(spec);
279        write(workspace.join("root.txt"), "agent\n").unwrap();
280        remove_file(workspace.root_path().join(".git/index")).unwrap();
281
282        let agent_diff = agent_diff(&fixture, &workspace);
283
284        assert!(agent_diff.diff.contains("+agent"));
285        assert_eq!(agent_diff.stats.files_changed, 1);
286    }
287
288    #[test]
289    fn agent_diff_rebuilds_a_deleted_index_keeping_files_tracked_despite_gitignore() {
290        let repo = TestWorkspace::new().file(".gitignore", "*.env\n").git_init();
291        repo.write("app.env", "start\n");
292        repo.git(&["add", "--force", "app.env"]);
293        let start_commit = repo.commit("start");
294        let source = GitSource::Url(format!("file://{}", repo.root().display()));
295        let (fixture, workspace) = checkout(GitSpec::new(source, start_commit.clone(), start_commit));
296        remove_file(workspace.root_path().join(".git/index")).unwrap();
297
298        assert_eq!(agent_diff(&fixture, &workspace).diff, "");
299    }
300
301    #[test]
302    fn agent_diff_errors_when_git_fails() {
303        let (_repo, spec) = init_repo();
304        let (fixture, workspace) = checkout(spec);
305        write(workspace.root_path().join(".git/index"), "corrupt").unwrap();
306
307        assert!(matches!(fixture.agent_diff(&workspace), Err(WorkspaceError::Git(_))));
308    }
309
310    #[test]
311    fn fetching_a_missing_bundle_errors() {
312        let spec = GitSpec::new(GitSource::Bundle(PathBuf::from("/nonexistent/repo.bundle")), "abc", "def");
313
314        assert!(matches!(GitFixture::fetch(spec), Err(WorkspaceError::MissingBundle { .. })));
315    }
316
317    fn fixture(spec: GitSpec) -> GitFixture {
318        GitFixture::fetch(spec).unwrap()
319    }
320
321    fn checkout(spec: GitSpec) -> (GitFixture, Workspace) {
322        let fixture = fixture(spec);
323        let workspace = fixture.workspace().unwrap();
324        (fixture, workspace)
325    }
326
327    fn agent_diff(fixture: &GitFixture, workspace: &Workspace) -> GitDiff {
328        fixture.agent_diff(workspace).unwrap()
329    }
330
331    fn bundle_spec(spec: &GitSpec, dir: &Path) -> GitSpec {
332        let bundle_path = dir.join("repo.bundle");
333        fixture(spec.clone()).bundle(&bundle_path).unwrap();
334        GitSpec { source: GitSource::Bundle(bundle_path), ..spec.clone() }
335    }
336
337    fn init_repo() -> (TestWorkspace, GitSpec) {
338        let repo = TestWorkspace::new()
339            .file("root.txt", "root v1\n")
340            .file("deleted.txt", "before deleted\n")
341            .file("pkg/inner.txt", "inner v1\n")
342            .git_init();
343        let start_commit = repo.commit("start");
344        repo.write("root.txt", "root v2\n");
345        let gold_commit = repo.commit("gold");
346        let source = GitSource::Url(format!("file://{}", repo.root().display()));
347        (repo, GitSpec::new(source, start_commit, gold_commit))
348    }
349}