Skip to main content

chainsaw/
git.rs

1//! Git integration for ref-based diffs.
2
3use std::path::Path;
4
5/// What a diff argument resolved to.
6#[derive(Debug, PartialEq, Eq)]
7pub enum DiffArg {
8    /// An existing snapshot file on disk.
9    Snapshot(std::path::PathBuf),
10    /// A valid git ref (branch, tag, SHA).
11    GitRef(String),
12}
13
14/// Classify a diff argument as a snapshot file or git ref.
15///
16/// Detection order:
17/// 1. Existing file on disk -> Snapshot
18/// 2. Path-like arg that doesn't exist (contains `/` or `.json`) -> error (file not found)
19/// 3. `git rev-parse --verify <arg>` succeeds -> `GitRef`
20/// 4. Neither -> error
21pub fn classify_diff_arg(arg: &str, repo_root: &Path) -> Result<DiffArg, crate::error::Error> {
22    let path = Path::new(arg);
23
24    // 1. Existing file on disk?
25    if path.is_file() {
26        return Ok(DiffArg::Snapshot(path.to_path_buf()));
27    }
28
29    // 2. Path-like arg that doesn't exist? Error as "file not found".
30    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    // 3. Valid git ref?
40    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    // 4. Neither
51    Err(crate::error::Error::NotSnapshotOrRef(arg.to_string()))
52}
53
54/// A temporary git worktree that cleans up on drop.
55pub struct TempWorktree {
56    dir: tempfile::TempDir,
57    repo_root: std::path::PathBuf,
58}
59
60impl TempWorktree {
61    /// Path to the worktree checkout.
62    pub fn path(&self) -> &Path {
63        self.dir.path()
64    }
65}
66
67impl Drop for TempWorktree {
68    fn drop(&mut self) {
69        // Best-effort cleanup: remove worktree from git's tracking.
70        // If this fails, `git worktree prune` will clean it up later.
71        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
79/// Create a temporary worktree checked out at the given git ref.
80pub 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
109/// Check whether the given path is inside a git repository.
110pub 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
118/// Find the git repository root from any path inside it.
119pub 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    /// Create a git repo in a tempdir with one commit, return the tempdir and the SHA.
140    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        // Determine actual default branch name
196        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        // Pass absolute path — file exists, should be Snapshot
278        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        // Worktree should have file.txt from the first commit but NOT marker.txt
311        assert!(wt.path().join("file.txt").exists());
312        assert!(!wt.path().join("marker.txt").exists());
313        // Cleanup should not panic
314        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        // Init repo
323        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        // Commit 1: index.ts imports one file
340        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        // Commit 2: index.ts imports two files (more weight)
365        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        // Build snapshots from each ref via worktrees
394        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        // Diff: v2 should be heavier than v1 (added b.ts)
411        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}