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}