Skip to main content

eval_magic/workspace/
snapshot.rs

1//! Skill snapshotting.
2//!
3//! Capture a skill's `SKILL.md` plus
4//! sibling assets into `.eval-magic/<skill>/snapshots/<label>/`, either from
5//! the working tree or — read straight from the git object database without
6//! touching the working tree — as it existed at a git ref. The
7//! `evals/` directory is always excluded; a `.snapshot-meta.json` records the
8//! source so `teardown` knows whether the snapshot is reproducible.
9
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use serde_json::json;
14
15use crate::core::run_git;
16use crate::workspace::teardown::SNAPSHOT_META;
17use crate::workspace::{WorkspaceError, write_json};
18
19/// Snapshot the skill under `skill_subdir` into
20/// `<workspace_root>/<skill_name>/snapshots/<label>/`. With `reference` set, the
21/// content is read from that git ref; otherwise from the working tree. Errors if
22/// a snapshot with this label already exists.
23pub fn snapshot(
24    workspace_root: &Path,
25    skill_name: &str,
26    skill_subdir: &Path,
27    label: &str,
28    reference: Option<&str>,
29) -> Result<PathBuf, WorkspaceError> {
30    let dest_dir = workspace_root
31        .join(skill_name)
32        .join("snapshots")
33        .join(label);
34    if dest_dir.exists() {
35        return Err(WorkspaceError::Message(format!(
36            "snapshot already exists: {}\n  Use a different --label or delete the existing snapshot first.",
37            dest_dir.display()
38        )));
39    }
40
41    match reference {
42        Some(reference) => snapshot_from_ref(reference, skill_subdir, &dest_dir)?,
43        None => snapshot_from_working_tree(skill_subdir, &dest_dir)?,
44    }
45    Ok(dest_dir)
46}
47
48/// Copy `SKILL.md` + sibling assets (excluding `evals/`) from the working tree,
49/// then record working-tree provenance.
50fn snapshot_from_working_tree(skill_subdir: &Path, dest_dir: &Path) -> Result<(), WorkspaceError> {
51    let skill_md = skill_subdir.join("SKILL.md");
52    if !skill_md.exists() {
53        return Err(WorkspaceError::Message(format!(
54            "skill not found: {}",
55            skill_md.display()
56        )));
57    }
58    fs::create_dir_all(dest_dir)?;
59    fs::copy(&skill_md, dest_dir.join("SKILL.md"))?;
60
61    for entry in fs::read_dir(skill_subdir)? {
62        let entry = entry?;
63        let name = entry.file_name().to_string_lossy().into_owned();
64        if name == "SKILL.md" || name == "evals" {
65            continue;
66        }
67        let src = entry.path();
68        let dst = dest_dir.join(&name);
69        if src.is_dir() {
70            copy_dir_recursive(&src, &dst)?;
71        } else {
72            fs::copy(&src, &dst)?;
73        }
74    }
75
76    // Record provenance so teardown keeps this (working-tree) snapshot — unlike a
77    // ref snapshot, it can't be regenerated from git.
78    write_json(
79        &dest_dir.join(SNAPSHOT_META),
80        &json!({ "source": "working-tree" }),
81    )
82}
83
84/// Materialize the skill (`SKILL.md` + sibling assets, excluding `evals/`) as it
85/// existed at `reference`, read from the git object database without touching the
86/// working tree, then record ref provenance. Git runs from `skill_subdir`, which
87/// must sit inside a repo; a bad ref or a skill absent at that ref errors with a
88/// clear message.
89fn snapshot_from_ref(
90    reference: &str,
91    skill_subdir: &Path,
92    dest_dir: &Path,
93) -> Result<(), WorkspaceError> {
94    let skill_md = match git_show_bytes(skill_subdir, reference, "SKILL.md") {
95        Some(bytes) => bytes,
96        None => {
97            return Err(WorkspaceError::Message(format!(
98                "skill not found at {reference}: {}\n  Check the ref exists and that the skill was present there (and that this is a git repo).",
99                skill_subdir.join("SKILL.md").display()
100            )));
101        }
102    };
103
104    fs::create_dir_all(dest_dir)?;
105    fs::write(dest_dir.join("SKILL.md"), &skill_md)?;
106
107    for rel in git_ls_tree(skill_subdir, reference)? {
108        if rel == "SKILL.md" || rel == "evals" || rel.starts_with("evals/") {
109            continue;
110        }
111        // Listed but unreadable (e.g. submodule/gitlink) — skip.
112        let Some(bytes) = git_show_bytes(skill_subdir, reference, &rel) else {
113            continue;
114        };
115        let dst = dest_dir.join(&rel);
116        if let Some(parent) = dst.parent() {
117            fs::create_dir_all(parent)?;
118        }
119        fs::write(&dst, &bytes)?;
120    }
121
122    write_json(
123        &dest_dir.join(SNAPSHOT_META),
124        &json!({ "source": "ref", "ref": reference }),
125    )
126}
127
128/// `git show <ref>:./<rel_path>` from `cwd`, returning the raw object bytes (so
129/// binary assets round-trip), or `None` when the object doesn't exist at that
130/// ref. Runs git directly (no shell), so the ref/path aren't interpolated into a
131/// shell string.
132fn git_show_bytes(cwd: &Path, reference: &str, rel_path: &str) -> Option<Vec<u8>> {
133    let spec = format!("{reference}:./{rel_path}");
134    let res = run_git(&["show", &spec], cwd);
135    (res.status == Some(0)).then_some(res.stdout)
136}
137
138/// List every file under `cwd` as it existed at `reference`, as paths relative to
139/// `cwd`. Errors with git's stderr on failure — a bad ref or a cwd outside any
140/// repo surfaces here.
141fn git_ls_tree(cwd: &Path, reference: &str) -> Result<Vec<String>, WorkspaceError> {
142    let res = run_git(&["ls-tree", "-r", "--name-only", reference, "."], cwd);
143    if res.status != Some(0) {
144        return Err(WorkspaceError::Message(format!(
145            "git ls-tree failed for ref {reference}: {}",
146            String::from_utf8_lossy(&res.stderr).trim()
147        )));
148    }
149    Ok(String::from_utf8_lossy(&res.stdout)
150        .lines()
151        .map(str::trim)
152        .filter(|s| !s.is_empty())
153        .map(str::to_owned)
154        .collect())
155}
156
157/// Recursively copy `src` directory into `dst`.
158fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), WorkspaceError> {
159    fs::create_dir_all(dst)?;
160    for entry in fs::read_dir(src)? {
161        let entry = entry?;
162        let from = entry.path();
163        let to = dst.join(entry.file_name());
164        if from.is_dir() {
165            copy_dir_recursive(&from, &to)?;
166        } else {
167            fs::copy(&from, &to)?;
168        }
169    }
170    Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use std::path::PathBuf;
177    use tempfile::TempDir;
178
179    fn write(path: &Path, body: &str) {
180        fs::create_dir_all(path.parent().unwrap()).unwrap();
181        fs::write(path, body).unwrap();
182    }
183
184    /// Run git with deterministic identity / no signing, asserting success.
185    fn git(args: &[&str], cwd: &Path) {
186        let mut full = vec![
187            "-c",
188            "user.email=eval@test",
189            "-c",
190            "user.name=eval",
191            "-c",
192            "commit.gpgsign=false",
193        ];
194        full.extend_from_slice(args);
195        let res = run_git(&full, cwd);
196        assert_eq!(
197            res.status,
198            Some(0),
199            "git {args:?} failed: {}",
200            String::from_utf8_lossy(&res.stderr)
201        );
202    }
203
204    struct Repo {
205        _tmp: TempDir,
206        skill_subdir: PathBuf,
207        workspace_root: PathBuf,
208    }
209
210    /// Build a git repo with a `mr-review` skill committed as v1 (plus any
211    /// `extra` committed files), then diverge the working-tree SKILL.md to v2.
212    fn setup_repo(extra: &[(&str, &str)]) -> Repo {
213        let tmp = TempDir::new().unwrap();
214        let root = tmp.path();
215        let skill_subdir = root.join("skill-dir").join("mr-review");
216        write(&skill_subdir.join("SKILL.md"), "v1 baseline\n");
217        for (rel, content) in extra {
218            write(&skill_subdir.join(rel), content);
219        }
220
221        git(&["init", "-q"], root);
222        git(&["add", "-A"], root);
223        git(&["commit", "-q", "-m", "v1"], root);
224
225        // Working tree diverges to v2; the commit still holds v1.
226        write(&skill_subdir.join("SKILL.md"), "v2 working tree\n");
227
228        let workspace_root = root.join("work").join(".eval-magic");
229        Repo {
230            _tmp: tmp,
231            skill_subdir,
232            workspace_root,
233        }
234    }
235
236    fn snap_path(repo: &Repo, label: &str, rel: &str) -> PathBuf {
237        repo.workspace_root
238            .join("mr-review")
239            .join("snapshots")
240            .join(label)
241            .join(rel)
242    }
243
244    #[test]
245    fn ref_snapshot_reads_committed_skill_md_leaving_working_tree_untouched() {
246        let repo = setup_repo(&[]);
247        snapshot(
248            &repo.workspace_root,
249            "mr-review",
250            &repo.skill_subdir,
251            "old",
252            Some("HEAD"),
253        )
254        .unwrap();
255
256        assert_eq!(
257            fs::read_to_string(snap_path(&repo, "old", "SKILL.md")).unwrap(),
258            "v1 baseline\n"
259        );
260        // Working tree still holds the edited v2 (no clobber).
261        assert_eq!(
262            fs::read_to_string(repo.skill_subdir.join("SKILL.md")).unwrap(),
263            "v2 working tree\n"
264        );
265    }
266
267    #[test]
268    fn ref_snapshot_captures_sibling_assets_but_excludes_evals() {
269        let repo = setup_repo(&[
270            ("assets/notes.md", "asset body\n"),
271            (
272                "evals/evals.json",
273                "{\"skill_name\":\"mr-review\",\"evals\":[]}",
274            ),
275        ]);
276        snapshot(
277            &repo.workspace_root,
278            "mr-review",
279            &repo.skill_subdir,
280            "old",
281            Some("HEAD"),
282        )
283        .unwrap();
284
285        assert_eq!(
286            fs::read_to_string(snap_path(&repo, "old", "assets/notes.md")).unwrap(),
287            "asset body\n"
288        );
289        assert!(!snap_path(&repo, "old", "evals").exists());
290    }
291
292    #[test]
293    fn ref_snapshot_records_ref_provenance() {
294        let repo = setup_repo(&[]);
295        snapshot(
296            &repo.workspace_root,
297            "mr-review",
298            &repo.skill_subdir,
299            "old",
300            Some("HEAD"),
301        )
302        .unwrap();
303
304        let meta: serde_json::Value = serde_json::from_str(
305            &fs::read_to_string(snap_path(&repo, "old", SNAPSHOT_META)).unwrap(),
306        )
307        .unwrap();
308        assert_eq!(meta["source"], "ref");
309        assert_eq!(meta["ref"], "HEAD");
310    }
311
312    #[test]
313    fn ref_that_does_not_exist_fails_with_clear_message() {
314        let repo = setup_repo(&[]);
315        let err = snapshot(
316            &repo.workspace_root,
317            "mr-review",
318            &repo.skill_subdir,
319            "old",
320            Some("does-not-exist"),
321        )
322        .unwrap_err();
323        assert!(err.to_string().contains("does-not-exist"));
324    }
325
326    #[test]
327    fn without_ref_snapshot_reads_the_working_tree_v2() {
328        let repo = setup_repo(&[]);
329        snapshot(
330            &repo.workspace_root,
331            "mr-review",
332            &repo.skill_subdir,
333            "wt",
334            None,
335        )
336        .unwrap();
337        assert_eq!(
338            fs::read_to_string(snap_path(&repo, "wt", "SKILL.md")).unwrap(),
339            "v2 working tree\n"
340        );
341    }
342
343    #[test]
344    fn working_tree_snapshot_records_working_tree_provenance() {
345        let repo = setup_repo(&[]);
346        snapshot(
347            &repo.workspace_root,
348            "mr-review",
349            &repo.skill_subdir,
350            "wt",
351            None,
352        )
353        .unwrap();
354        let meta: serde_json::Value = serde_json::from_str(
355            &fs::read_to_string(snap_path(&repo, "wt", SNAPSHOT_META)).unwrap(),
356        )
357        .unwrap();
358        assert_eq!(meta["source"], "working-tree");
359    }
360
361    #[test]
362    fn duplicate_label_fails() {
363        let repo = setup_repo(&[]);
364        snapshot(
365            &repo.workspace_root,
366            "mr-review",
367            &repo.skill_subdir,
368            "wt",
369            None,
370        )
371        .unwrap();
372        let err = snapshot(
373            &repo.workspace_root,
374            "mr-review",
375            &repo.skill_subdir,
376            "wt",
377            None,
378        )
379        .unwrap_err();
380        assert!(err.to_string().contains("snapshot already exists"));
381    }
382}