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. `git rev-parse --verify <arg>^{commit}` succeeds -> `GitRef`
19/// 3. Path-like arg that doesn't exist (contains `/` or `.json`) -> error (file not found)
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. Valid git ref? Use ^{commit} to peel annotated tags to their commit.
30    let output = std::process::Command::new("git")
31        .args(["rev-parse", "--verify", &format!("{arg}^{{commit}}")])
32        .current_dir(repo_root)
33        .output()
34        .map_err(|e| crate::error::Error::GitError(format!("failed to run git: {e}")))?;
35
36    if output.status.success() {
37        return Ok(DiffArg::GitRef(arg.to_string()));
38    }
39
40    // 3. Path-like arg that doesn't exist? Error as "file not found".
41    let looks_like_path = arg.contains('/')
42        || arg.contains(std::path::MAIN_SEPARATOR)
43        || path
44            .extension()
45            .is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
46    if looks_like_path {
47        return Err(crate::error::Error::DiffFileNotFound(arg.to_string()));
48    }
49
50    // 4. Neither
51    Err(crate::error::Error::NotSnapshotOrRef(arg.to_string()))
52}
53
54/// Check whether the given path is inside a git repository.
55pub fn is_git_repo(path: &Path) -> bool {
56    std::process::Command::new("git")
57        .args(["rev-parse", "--git-dir"])
58        .current_dir(path)
59        .output()
60        .is_ok_and(|o| o.status.success())
61}
62
63/// Find the git repository root from any path inside it.
64pub fn repo_root(path: &Path) -> Result<std::path::PathBuf, crate::error::Error> {
65    let output = std::process::Command::new("git")
66        .args(["rev-parse", "--show-toplevel"])
67        .current_dir(path)
68        .output()
69        .map_err(|e| crate::error::Error::GitError(format!("failed to run git: {e}")))?;
70
71    if !output.status.success() {
72        return Err(crate::error::Error::NotAGitRepo);
73    }
74
75    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
76    Ok(std::path::PathBuf::from(root))
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use std::process::Command;
83
84    /// Create a git repo in a tempdir with one commit, return the tempdir and the SHA.
85    fn git_repo() -> (tempfile::TempDir, String) {
86        let tmp = tempfile::tempdir().unwrap();
87        let dir = tmp.path();
88        Command::new("git")
89            .args(["init"])
90            .current_dir(dir)
91            .output()
92            .unwrap();
93        Command::new("git")
94            .args(["config", "user.email", "test@test.com"])
95            .current_dir(dir)
96            .output()
97            .unwrap();
98        Command::new("git")
99            .args(["config", "user.name", "Test"])
100            .current_dir(dir)
101            .output()
102            .unwrap();
103        std::fs::write(dir.join("file.txt"), "hello").unwrap();
104        Command::new("git")
105            .args(["add", "."])
106            .current_dir(dir)
107            .output()
108            .unwrap();
109        Command::new("git")
110            .args(["commit", "-m", "init"])
111            .current_dir(dir)
112            .output()
113            .unwrap();
114        let sha = String::from_utf8(
115            Command::new("git")
116                .args(["rev-parse", "HEAD"])
117                .current_dir(dir)
118                .output()
119                .unwrap()
120                .stdout,
121        )
122        .unwrap()
123        .trim()
124        .to_string();
125        (tmp, sha)
126    }
127
128    #[test]
129    fn existing_file_is_snapshot() {
130        let (tmp, _) = git_repo();
131        let snap = tmp.path().join("snap.json");
132        std::fs::write(&snap, "{}").unwrap();
133        let result = classify_diff_arg(snap.to_str().unwrap(), tmp.path());
134        assert_eq!(result.unwrap(), DiffArg::Snapshot(snap));
135    }
136
137    #[test]
138    fn branch_name_is_git_ref() {
139        let (tmp, _) = git_repo();
140        // Determine actual default branch name
141        let branch = String::from_utf8(
142            Command::new("git")
143                .args(["branch", "--show-current"])
144                .current_dir(tmp.path())
145                .output()
146                .unwrap()
147                .stdout,
148        )
149        .unwrap()
150        .trim()
151        .to_string();
152        let result = classify_diff_arg(&branch, tmp.path());
153        assert!(matches!(result, Ok(DiffArg::GitRef(_))));
154    }
155
156    #[test]
157    fn sha_is_git_ref() {
158        let (tmp, sha) = git_repo();
159        let result = classify_diff_arg(&sha, tmp.path());
160        assert_eq!(result.unwrap(), DiffArg::GitRef(sha));
161    }
162
163    #[test]
164    fn short_sha_is_git_ref() {
165        let (tmp, sha) = git_repo();
166        let short = &sha[..7];
167        let result = classify_diff_arg(short, tmp.path());
168        assert!(matches!(result, Ok(DiffArg::GitRef(_))));
169    }
170
171    #[test]
172    fn head_tilde_is_git_ref() {
173        let (tmp, _) = git_repo();
174        let result = classify_diff_arg("HEAD~0", tmp.path());
175        assert!(matches!(result, Ok(DiffArg::GitRef(_))));
176    }
177
178    #[test]
179    fn path_like_nonexistent_is_file_not_found() {
180        let (tmp, _) = git_repo();
181        let result = classify_diff_arg("./missing.json", tmp.path());
182        assert!(result.is_err());
183        let err = result.unwrap_err().to_string();
184        assert!(err.contains("file not found"), "got: {err}");
185    }
186
187    #[test]
188    fn json_extension_nonexistent_is_file_not_found() {
189        let (tmp, _) = git_repo();
190        let result = classify_diff_arg("missing.json", tmp.path());
191        assert!(result.is_err());
192        let err = result.unwrap_err().to_string();
193        assert!(err.contains("file not found"), "got: {err}");
194    }
195
196    #[test]
197    fn path_with_slash_nonexistent_is_file_not_found() {
198        let (tmp, _) = git_repo();
199        let result = classify_diff_arg("some/path/file", tmp.path());
200        assert!(result.is_err());
201        let err = result.unwrap_err().to_string();
202        assert!(err.contains("file not found"), "got: {err}");
203    }
204
205    #[test]
206    fn nonsense_is_not_snapshot_or_ref() {
207        let (tmp, _) = git_repo();
208        let result = classify_diff_arg("xyzzy-not-a-ref", tmp.path());
209        assert!(result.is_err());
210        let err = result.unwrap_err().to_string();
211        assert!(
212            err.contains("not a snapshot file or a valid git ref"),
213            "got: {err}"
214        );
215    }
216
217    #[test]
218    fn file_named_main_beats_branch() {
219        let (tmp, _) = git_repo();
220        let main_file = tmp.path().join("main");
221        std::fs::write(&main_file, "{}").unwrap();
222        // Pass absolute path — file exists, should be Snapshot
223        let result = classify_diff_arg(main_file.to_str().unwrap(), tmp.path());
224        assert!(matches!(result, Ok(DiffArg::Snapshot(_))));
225    }
226
227    #[test]
228    fn tag_is_git_ref() {
229        let (tmp, _) = git_repo();
230        Command::new("git")
231            .args(["tag", "v1.0.0"])
232            .current_dir(tmp.path())
233            .output()
234            .unwrap();
235        let result = classify_diff_arg("v1.0.0", tmp.path());
236        assert_eq!(result.unwrap(), DiffArg::GitRef("v1.0.0".to_string()));
237    }
238
239    #[test]
240    fn annotated_tag_is_git_ref() {
241        let (tmp, _) = git_repo();
242        Command::new("git")
243            .args(["tag", "-a", "v2.0.0", "-m", "release"])
244            .current_dir(tmp.path())
245            .output()
246            .unwrap();
247        let result = classify_diff_arg("v2.0.0", tmp.path());
248        assert_eq!(result.unwrap(), DiffArg::GitRef("v2.0.0".to_string()));
249    }
250
251    #[test]
252    fn branch_with_slash_is_git_ref() {
253        let (tmp, _) = git_repo();
254        Command::new("git")
255            .args(["branch", "feature/auth"])
256            .current_dir(tmp.path())
257            .output()
258            .unwrap();
259        let result = classify_diff_arg("feature/auth", tmp.path());
260        assert!(matches!(result, Ok(DiffArg::GitRef(_))));
261    }
262
263    #[test]
264    fn nonexistent_path_with_slash_still_errors() {
265        let (tmp, _) = git_repo();
266        let result = classify_diff_arg("some/nonexistent/path", tmp.path());
267        assert!(result.is_err());
268        let err = result.unwrap_err().to_string();
269        assert!(err.contains("file not found"), "got: {err}");
270    }
271
272    #[test]
273    fn integration_diff_two_refs() {
274        use std::sync::Arc;
275
276        let tmp = tempfile::tempdir().unwrap();
277        let dir = tmp.path();
278
279        // Init repo
280        Command::new("git")
281            .args(["init"])
282            .current_dir(dir)
283            .output()
284            .unwrap();
285        Command::new("git")
286            .args(["config", "user.email", "t@t.com"])
287            .current_dir(dir)
288            .output()
289            .unwrap();
290        Command::new("git")
291            .args(["config", "user.name", "T"])
292            .current_dir(dir)
293            .output()
294            .unwrap();
295
296        // Commit 1: index.ts imports one file
297        std::fs::write(dir.join("index.ts"), "import './a';\n").unwrap();
298        std::fs::write(dir.join("a.ts"), "export const a = 1;\n").unwrap();
299        Command::new("git")
300            .args(["add", "."])
301            .current_dir(dir)
302            .output()
303            .unwrap();
304        Command::new("git")
305            .args(["commit", "-m", "v1"])
306            .current_dir(dir)
307            .output()
308            .unwrap();
309        let sha1 = String::from_utf8(
310            Command::new("git")
311                .args(["rev-parse", "HEAD"])
312                .current_dir(dir)
313                .output()
314                .unwrap()
315                .stdout,
316        )
317        .unwrap()
318        .trim()
319        .to_string();
320
321        // Commit 2: index.ts imports two files (more weight)
322        std::fs::write(dir.join("index.ts"), "import './a';\nimport './b';\n").unwrap();
323        std::fs::write(
324            dir.join("b.ts"),
325            "export const b = 'hello world this is extra weight';\n",
326        )
327        .unwrap();
328        Command::new("git")
329            .args(["add", "."])
330            .current_dir(dir)
331            .output()
332            .unwrap();
333        Command::new("git")
334            .args(["commit", "-m", "v2"])
335            .current_dir(dir)
336            .output()
337            .unwrap();
338        let sha2 = String::from_utf8(
339            Command::new("git")
340                .args(["rev-parse", "HEAD"])
341                .current_dir(dir)
342                .output()
343                .unwrap()
344                .stdout,
345        )
346        .unwrap()
347        .trim()
348        .to_string();
349
350        // Build snapshots from each ref via GitTreeVfs
351        let entry = std::path::Path::new("index.ts");
352        let opts = crate::query::TraceOptions {
353            include_dynamic: false,
354            top_n: 0,
355            ignore: vec![],
356        };
357
358        let vfs1 = Arc::new(crate::vfs::GitTreeVfs::new(dir, &sha1, dir).unwrap());
359        let (loaded1, _cw1) =
360            crate::loader::load_graph_with_vfs(&dir.join(entry), true, vfs1).unwrap();
361        let eid1 = *loaded1.graph.path_to_id.get(&loaded1.entry).unwrap();
362        let snap1 = crate::query::trace(&loaded1.graph, eid1, &opts).to_snapshot("v1");
363
364        let vfs2 = Arc::new(crate::vfs::GitTreeVfs::new(dir, &sha2, dir).unwrap());
365        let (loaded2, _cw2) =
366            crate::loader::load_graph_with_vfs(&dir.join(entry), true, vfs2).unwrap();
367        let eid2 = *loaded2.graph.path_to_id.get(&loaded2.entry).unwrap();
368        let snap2 = crate::query::trace(&loaded2.graph, eid2, &opts).to_snapshot("v2");
369
370        // Diff: v2 should be heavier than v1 (added b.ts)
371        let diff = crate::query::diff_snapshots(&snap1, &snap2);
372        assert!(
373            diff.weight_delta > 0,
374            "v2 should be heavier than v1, delta={}",
375            diff.weight_delta
376        );
377    }
378}