Skip to main content

lean_ctx/core/eval_ab/testbench/
clone.rs

1//! Repo materialization for the testbench (#611).
2//!
3//! Turns a [`RepoEntry`] into an on-disk directory the eval can read:
4//!
5//! * **local fixture** (`path`) — resolved against the lockfile dir and returned as-is.
6//!   This is what the committed deterministic CI subset uses, so it never touches the
7//!   network.
8//! * **remote** (`url` + `commit`) — cloned into `cache/<name>` once, then checked out
9//!   at the pinned commit on every run. The clone is idempotent (reused across runs)
10//!   and the checked-out `HEAD` is verified to equal the pin, so a moved tag or a
11//!   force-pushed branch can never silently change what the public run measured.
12
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16use anyhow::{Context, Result, bail};
17
18use super::lockfile::RepoEntry;
19
20/// Materializes `repo` and returns the directory its task workspaces resolve against.
21pub fn materialize(repo: &RepoEntry, lock_dir: &Path, cache_dir: &Path) -> Result<PathBuf> {
22    if let Some(rel) = &repo.path {
23        let dir = lock_dir.join(rel);
24        if !dir.is_dir() {
25            bail!(
26                "repo {}: local fixture {} is not a directory",
27                repo.name,
28                dir.display()
29            );
30        }
31        return Ok(dir);
32    }
33
34    // Remote: url + commit are guaranteed present by lockfile validation.
35    let url = repo
36        .url
37        .as_deref()
38        .context("remote repo entry without url (should be unreachable)")?;
39    let commit = repo
40        .commit
41        .as_deref()
42        .context("remote repo entry without commit (should be unreachable)")?;
43
44    std::fs::create_dir_all(cache_dir)
45        .with_context(|| format!("creating cache dir {}", cache_dir.display()))?;
46    let dest = cache_dir.join(&repo.name);
47
48    if !dest.join(".git").is_dir() {
49        // Fresh clone. A full clone is heavier than a shallow one but lets us check
50        // out an arbitrary pinned commit reliably across git versions; it is paid
51        // once and reused on every subsequent run.
52        git(&["clone", "--quiet", url, &dest.to_string_lossy()], None)
53            .with_context(|| format!("cloning {url} for repo {}", repo.name))?;
54    }
55
56    // Check out the pin; fetch once if the commit is not present yet (e.g. a newer
57    // pin against an existing cache), then retry. A still-missing commit is fatal.
58    if git(&["checkout", "--quiet", commit], Some(&dest)).is_err() {
59        git(&["fetch", "--quiet", "--all", "--tags"], Some(&dest))
60            .with_context(|| format!("fetching {url} for repo {}", repo.name))?;
61        git(&["checkout", "--quiet", commit], Some(&dest)).with_context(|| {
62            format!("checking out pinned commit {commit} in repo {}", repo.name)
63        })?;
64    }
65
66    let head = git(&["rev-parse", "HEAD"], Some(&dest))?;
67    let head = head.trim();
68    if head != commit && !head.starts_with(commit) {
69        bail!(
70            "repo {}: checked-out HEAD {head} does not match pinned commit {commit}",
71            repo.name
72        );
73    }
74    Ok(dest)
75}
76
77/// Runs `git ARGS` (optionally in `dir`), returning trimmed stdout or an error that
78/// includes git's stderr. Never inherits a shell — args are passed verbatim.
79fn git(args: &[&str], dir: Option<&Path>) -> Result<String> {
80    let mut cmd = Command::new("git");
81    if let Some(d) = dir {
82        cmd.current_dir(d);
83    }
84    cmd.args(args);
85    let out = cmd
86        .output()
87        .with_context(|| format!("spawning git {}", args.join(" ")))?;
88    if !out.status.success() {
89        let stderr = String::from_utf8_lossy(&out.stderr);
90        bail!("git {} failed: {}", args.join(" "), stderr.trim());
91    }
92    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn local_fixture_resolves_against_lock_dir() {
101        let root = tempfile::tempdir().unwrap();
102        std::fs::create_dir_all(root.path().join("repos/qa")).unwrap();
103        let entry = RepoEntry {
104            name: "qa".into(),
105            url: None,
106            commit: None,
107            path: Some("repos/qa".into()),
108            suite: "qa.ndjson".into(),
109        };
110        let got = materialize(&entry, root.path(), &root.path().join("cache")).unwrap();
111        assert_eq!(got, root.path().join("repos/qa"));
112    }
113
114    #[test]
115    fn missing_local_fixture_errors() {
116        let root = tempfile::tempdir().unwrap();
117        let entry = RepoEntry {
118            name: "qa".into(),
119            url: None,
120            commit: None,
121            path: Some("nope".into()),
122            suite: "qa.ndjson".into(),
123        };
124        assert!(materialize(&entry, root.path(), &root.path().join("cache")).is_err());
125    }
126
127    #[cfg(unix)]
128    #[test]
129    fn clones_and_checks_out_a_local_git_repo() {
130        // A local "remote" git repo is enough to exercise clone + pinned checkout
131        // without any network access.
132        let root = tempfile::tempdir().unwrap();
133        let origin = root.path().join("origin");
134        std::fs::create_dir_all(&origin).unwrap();
135        let run = |args: &[&str]| {
136            assert!(
137                Command::new("git")
138                    .current_dir(&origin)
139                    .args(args)
140                    .output()
141                    .unwrap()
142                    .status
143                    .success(),
144                "git {args:?} failed"
145            );
146        };
147        run(&["init", "--quiet"]);
148        run(&["config", "user.email", "t@t"]);
149        run(&["config", "user.name", "t"]);
150        std::fs::write(origin.join("file.txt"), "hello").unwrap();
151        run(&["add", "."]);
152        run(&["commit", "--quiet", "-m", "init"]);
153        let commit = String::from_utf8_lossy(
154            &Command::new("git")
155                .current_dir(&origin)
156                .args(["rev-parse", "HEAD"])
157                .output()
158                .unwrap()
159                .stdout,
160        )
161        .trim()
162        .to_string();
163
164        let entry = RepoEntry {
165            name: "fix".into(),
166            url: Some(origin.to_string_lossy().into_owned()),
167            commit: Some(commit),
168            path: None,
169            suite: "s.ndjson".into(),
170        };
171        let cache = root.path().join("cache");
172        let dest = materialize(&entry, root.path(), &cache).unwrap();
173        assert!(dest.join("file.txt").exists());
174        // Second call is idempotent (reuses the clone).
175        let dest2 = materialize(&entry, root.path(), &cache).unwrap();
176        assert_eq!(dest, dest2);
177    }
178
179    #[cfg(unix)]
180    #[test]
181    fn wrong_pinned_commit_errors() {
182        let root = tempfile::tempdir().unwrap();
183        let origin = root.path().join("origin");
184        std::fs::create_dir_all(&origin).unwrap();
185        for args in [
186            vec!["init", "--quiet"],
187            vec!["config", "user.email", "t@t"],
188            vec!["config", "user.name", "t"],
189        ] {
190            Command::new("git")
191                .current_dir(&origin)
192                .args(&args)
193                .output()
194                .unwrap();
195        }
196        std::fs::write(origin.join("f"), "x").unwrap();
197        for args in [vec!["add", "."], vec!["commit", "--quiet", "-m", "i"]] {
198            Command::new("git")
199                .current_dir(&origin)
200                .args(&args)
201                .output()
202                .unwrap();
203        }
204        let entry = RepoEntry {
205            name: "fix".into(),
206            url: Some(origin.to_string_lossy().into_owned()),
207            commit: Some("0000000000000000000000000000000000000000".into()),
208            path: None,
209            suite: "s.ndjson".into(),
210        };
211        assert!(materialize(&entry, root.path(), &root.path().join("cache")).is_err());
212    }
213}