1use std::path::Path;
4
5#[derive(Debug, PartialEq, Eq)]
7pub enum DiffArg {
8 Snapshot(std::path::PathBuf),
10 GitRef(String),
12}
13
14pub fn classify_diff_arg(arg: &str, repo_root: &Path) -> Result<DiffArg, crate::error::Error> {
22 let path = Path::new(arg);
23
24 if path.is_file() {
26 return Ok(DiffArg::Snapshot(path.to_path_buf()));
27 }
28
29 let looks_like_path = arg.contains('/')
31 || arg.contains(std::path::MAIN_SEPARATOR)
32 || path
33 .extension()
34 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
35 if looks_like_path {
36 return Err(crate::error::Error::DiffFileNotFound(arg.to_string()));
37 }
38
39 let output = std::process::Command::new("git")
41 .args(["rev-parse", "--verify", arg])
42 .current_dir(repo_root)
43 .output()
44 .map_err(|e| crate::error::Error::GitError(format!("failed to run git: {e}")))?;
45
46 if output.status.success() {
47 return Ok(DiffArg::GitRef(arg.to_string()));
48 }
49
50 Err(crate::error::Error::NotSnapshotOrRef(arg.to_string()))
52}
53
54pub struct TempWorktree {
56 dir: tempfile::TempDir,
57 repo_root: std::path::PathBuf,
58}
59
60impl TempWorktree {
61 pub fn path(&self) -> &Path {
63 self.dir.path()
64 }
65}
66
67impl Drop for TempWorktree {
68 fn drop(&mut self) {
69 let _ = std::process::Command::new("git")
72 .args(["worktree", "remove", "--force"])
73 .arg(self.dir.path())
74 .current_dir(&self.repo_root)
75 .output();
76 }
77}
78
79pub fn create_worktree(
81 repo_root: &Path,
82 git_ref: &str,
83) -> Result<TempWorktree, crate::error::Error> {
84 let dir = tempfile::tempdir()
85 .map_err(|e| crate::error::Error::GitError(format!("failed to create temp dir: {e}")))?;
86
87 let output = std::process::Command::new("git")
88 .args(["worktree", "add", "--detach"])
89 .arg(dir.path())
90 .arg(git_ref)
91 .current_dir(repo_root)
92 .output()
93 .map_err(|e| crate::error::Error::GitError(format!("failed to run git: {e}")))?;
94
95 if !output.status.success() {
96 let stderr = String::from_utf8_lossy(&output.stderr);
97 return Err(crate::error::Error::GitError(format!(
98 "git worktree add failed: {}",
99 stderr.trim(),
100 )));
101 }
102
103 Ok(TempWorktree {
104 dir,
105 repo_root: repo_root.to_path_buf(),
106 })
107}
108
109pub fn is_git_repo(path: &Path) -> bool {
111 std::process::Command::new("git")
112 .args(["rev-parse", "--git-dir"])
113 .current_dir(path)
114 .output()
115 .is_ok_and(|o| o.status.success())
116}
117
118pub fn repo_root(path: &Path) -> Result<std::path::PathBuf, crate::error::Error> {
120 let output = std::process::Command::new("git")
121 .args(["rev-parse", "--show-toplevel"])
122 .current_dir(path)
123 .output()
124 .map_err(|e| crate::error::Error::GitError(format!("failed to run git: {e}")))?;
125
126 if !output.status.success() {
127 return Err(crate::error::Error::NotAGitRepo);
128 }
129
130 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
131 Ok(std::path::PathBuf::from(root))
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use std::process::Command;
138
139 fn git_repo() -> (tempfile::TempDir, String) {
141 let tmp = tempfile::tempdir().unwrap();
142 let dir = tmp.path();
143 Command::new("git")
144 .args(["init"])
145 .current_dir(dir)
146 .output()
147 .unwrap();
148 Command::new("git")
149 .args(["config", "user.email", "test@test.com"])
150 .current_dir(dir)
151 .output()
152 .unwrap();
153 Command::new("git")
154 .args(["config", "user.name", "Test"])
155 .current_dir(dir)
156 .output()
157 .unwrap();
158 std::fs::write(dir.join("file.txt"), "hello").unwrap();
159 Command::new("git")
160 .args(["add", "."])
161 .current_dir(dir)
162 .output()
163 .unwrap();
164 Command::new("git")
165 .args(["commit", "-m", "init"])
166 .current_dir(dir)
167 .output()
168 .unwrap();
169 let sha = String::from_utf8(
170 Command::new("git")
171 .args(["rev-parse", "HEAD"])
172 .current_dir(dir)
173 .output()
174 .unwrap()
175 .stdout,
176 )
177 .unwrap()
178 .trim()
179 .to_string();
180 (tmp, sha)
181 }
182
183 #[test]
184 fn existing_file_is_snapshot() {
185 let (tmp, _) = git_repo();
186 let snap = tmp.path().join("snap.json");
187 std::fs::write(&snap, "{}").unwrap();
188 let result = classify_diff_arg(snap.to_str().unwrap(), tmp.path());
189 assert_eq!(result.unwrap(), DiffArg::Snapshot(snap));
190 }
191
192 #[test]
193 fn branch_name_is_git_ref() {
194 let (tmp, _) = git_repo();
195 let branch = String::from_utf8(
197 Command::new("git")
198 .args(["branch", "--show-current"])
199 .current_dir(tmp.path())
200 .output()
201 .unwrap()
202 .stdout,
203 )
204 .unwrap()
205 .trim()
206 .to_string();
207 let result = classify_diff_arg(&branch, tmp.path());
208 assert!(matches!(result, Ok(DiffArg::GitRef(_))));
209 }
210
211 #[test]
212 fn sha_is_git_ref() {
213 let (tmp, sha) = git_repo();
214 let result = classify_diff_arg(&sha, tmp.path());
215 assert_eq!(result.unwrap(), DiffArg::GitRef(sha));
216 }
217
218 #[test]
219 fn short_sha_is_git_ref() {
220 let (tmp, sha) = git_repo();
221 let short = &sha[..7];
222 let result = classify_diff_arg(short, tmp.path());
223 assert!(matches!(result, Ok(DiffArg::GitRef(_))));
224 }
225
226 #[test]
227 fn head_tilde_is_git_ref() {
228 let (tmp, _) = git_repo();
229 let result = classify_diff_arg("HEAD~0", tmp.path());
230 assert!(matches!(result, Ok(DiffArg::GitRef(_))));
231 }
232
233 #[test]
234 fn path_like_nonexistent_is_file_not_found() {
235 let (tmp, _) = git_repo();
236 let result = classify_diff_arg("./missing.json", tmp.path());
237 assert!(result.is_err());
238 let err = result.unwrap_err().to_string();
239 assert!(err.contains("file not found"), "got: {err}");
240 }
241
242 #[test]
243 fn json_extension_nonexistent_is_file_not_found() {
244 let (tmp, _) = git_repo();
245 let result = classify_diff_arg("missing.json", tmp.path());
246 assert!(result.is_err());
247 let err = result.unwrap_err().to_string();
248 assert!(err.contains("file not found"), "got: {err}");
249 }
250
251 #[test]
252 fn path_with_slash_nonexistent_is_file_not_found() {
253 let (tmp, _) = git_repo();
254 let result = classify_diff_arg("some/path/file", tmp.path());
255 assert!(result.is_err());
256 let err = result.unwrap_err().to_string();
257 assert!(err.contains("file not found"), "got: {err}");
258 }
259
260 #[test]
261 fn nonsense_is_not_snapshot_or_ref() {
262 let (tmp, _) = git_repo();
263 let result = classify_diff_arg("xyzzy-not-a-ref", tmp.path());
264 assert!(result.is_err());
265 let err = result.unwrap_err().to_string();
266 assert!(
267 err.contains("not a snapshot file or a valid git ref"),
268 "got: {err}"
269 );
270 }
271
272 #[test]
273 fn file_named_main_beats_branch() {
274 let (tmp, _) = git_repo();
275 let main_file = tmp.path().join("main");
276 std::fs::write(&main_file, "{}").unwrap();
277 let result = classify_diff_arg(main_file.to_str().unwrap(), tmp.path());
279 assert!(matches!(result, Ok(DiffArg::Snapshot(_))));
280 }
281
282 #[test]
283 fn tag_is_git_ref() {
284 let (tmp, _) = git_repo();
285 Command::new("git")
286 .args(["tag", "v1.0.0"])
287 .current_dir(tmp.path())
288 .output()
289 .unwrap();
290 let result = classify_diff_arg("v1.0.0", tmp.path());
291 assert_eq!(result.unwrap(), DiffArg::GitRef("v1.0.0".to_string()));
292 }
293
294 #[test]
295 fn worktree_roundtrip() {
296 let (tmp, sha) = git_repo();
297 std::fs::write(tmp.path().join("marker.txt"), "original").unwrap();
298 Command::new("git")
299 .args(["add", "."])
300 .current_dir(tmp.path())
301 .output()
302 .unwrap();
303 Command::new("git")
304 .args(["commit", "-m", "marker"])
305 .current_dir(tmp.path())
306 .output()
307 .unwrap();
308
309 let wt = create_worktree(tmp.path(), &sha).unwrap();
310 assert!(wt.path().join("file.txt").exists());
312 assert!(!wt.path().join("marker.txt").exists());
313 drop(wt);
315 }
316
317 #[test]
318 fn integration_diff_two_refs() {
319 let tmp = tempfile::tempdir().unwrap();
320 let dir = tmp.path();
321
322 Command::new("git")
324 .args(["init"])
325 .current_dir(dir)
326 .output()
327 .unwrap();
328 Command::new("git")
329 .args(["config", "user.email", "t@t.com"])
330 .current_dir(dir)
331 .output()
332 .unwrap();
333 Command::new("git")
334 .args(["config", "user.name", "T"])
335 .current_dir(dir)
336 .output()
337 .unwrap();
338
339 std::fs::write(dir.join("index.ts"), "import './a';\n").unwrap();
341 std::fs::write(dir.join("a.ts"), "export const a = 1;\n").unwrap();
342 Command::new("git")
343 .args(["add", "."])
344 .current_dir(dir)
345 .output()
346 .unwrap();
347 Command::new("git")
348 .args(["commit", "-m", "v1"])
349 .current_dir(dir)
350 .output()
351 .unwrap();
352 let sha1 = String::from_utf8(
353 Command::new("git")
354 .args(["rev-parse", "HEAD"])
355 .current_dir(dir)
356 .output()
357 .unwrap()
358 .stdout,
359 )
360 .unwrap()
361 .trim()
362 .to_string();
363
364 std::fs::write(dir.join("index.ts"), "import './a';\nimport './b';\n").unwrap();
366 std::fs::write(
367 dir.join("b.ts"),
368 "export const b = 'hello world this is extra weight';\n",
369 )
370 .unwrap();
371 Command::new("git")
372 .args(["add", "."])
373 .current_dir(dir)
374 .output()
375 .unwrap();
376 Command::new("git")
377 .args(["commit", "-m", "v2"])
378 .current_dir(dir)
379 .output()
380 .unwrap();
381 let sha2 = String::from_utf8(
382 Command::new("git")
383 .args(["rev-parse", "HEAD"])
384 .current_dir(dir)
385 .output()
386 .unwrap()
387 .stdout,
388 )
389 .unwrap()
390 .trim()
391 .to_string();
392
393 let entry = std::path::Path::new("index.ts");
395 let opts =
396 crate::query::TraceOptions { include_dynamic: false, top_n: 0, ignore: vec![] };
397
398 let wt1 = create_worktree(dir, &sha1).unwrap();
399 let (loaded1, _cw1) =
400 crate::loader::load_graph(&wt1.path().join(entry), true).unwrap();
401 let eid1 = *loaded1.graph.path_to_id.get(&loaded1.entry).unwrap();
402 let snap1 = crate::query::trace(&loaded1.graph, eid1, &opts).to_snapshot("v1");
403
404 let wt2 = create_worktree(dir, &sha2).unwrap();
405 let (loaded2, _cw2) =
406 crate::loader::load_graph(&wt2.path().join(entry), true).unwrap();
407 let eid2 = *loaded2.graph.path_to_id.get(&loaded2.entry).unwrap();
408 let snap2 = crate::query::trace(&loaded2.graph, eid2, &opts).to_snapshot("v2");
409
410 let diff = crate::query::diff_snapshots(&snap1, &snap2);
412 assert!(
413 diff.weight_delta > 0,
414 "v2 should be heavier than v1, delta={}",
415 diff.weight_delta
416 );
417 }
418}