1use 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
19pub 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
48fn 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 write_json(
79 &dest_dir.join(SNAPSHOT_META),
80 &json!({ "source": "working-tree" }),
81 )
82}
83
84fn 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 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
128fn 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
138fn 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
157fn 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 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 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 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 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}