Skip to main content

dolly_cli/
git.rs

1use std::io;
2use std::path::Path;
3use std::process::Command;
4
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum GitError {
9    #[error("`git` is not installed or not on PATH")]
10    NotInstalled,
11
12    #[error("git {operation} failed (exit {code:?})")]
13    Failed {
14        operation: &'static str,
15        code: Option<i32>,
16    },
17
18    #[error(transparent)]
19    Io(io::Error),
20}
21
22pub fn clone(url: &str, dest: &Path) -> Result<(), GitError> {
23    let status = Command::new("git")
24        .arg("clone")
25        .arg("--quiet")
26        .arg(url)
27        .arg(dest)
28        .status()
29        .map_err(spawn_error)?;
30
31    if !status.success() {
32        return Err(GitError::Failed {
33            operation: "clone",
34            code: status.code(),
35        });
36    }
37    Ok(())
38}
39
40pub fn pull(repo_dir: &Path) -> Result<(), GitError> {
41    let status = Command::new("git")
42        .arg("pull")
43        .arg("--ff-only")
44        .arg("--quiet")
45        .current_dir(repo_dir)
46        .status()
47        .map_err(spawn_error)?;
48
49    if !status.success() {
50        return Err(GitError::Failed {
51            operation: "pull",
52            code: status.code(),
53        });
54    }
55    Ok(())
56}
57
58pub fn fetch(repo_dir: &Path) -> Result<(), GitError> {
59    let status = Command::new("git")
60        .arg("fetch")
61        .arg("--quiet")
62        .current_dir(repo_dir)
63        .status()
64        .map_err(spawn_error)?;
65    if !status.success() {
66        return Err(GitError::Failed {
67            operation: "fetch",
68            code: status.code(),
69        });
70    }
71    Ok(())
72}
73
74pub fn head_commit(repo_dir: &Path) -> Result<String, GitError> {
75    let output = Command::new("git")
76        .arg("rev-parse")
77        .arg("--short")
78        .arg("HEAD")
79        .current_dir(repo_dir)
80        .output()
81        .map_err(spawn_error)?;
82
83    if !output.status.success() {
84        return Err(GitError::Failed {
85            operation: "rev-parse HEAD",
86            code: output.status.code(),
87        });
88    }
89
90    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
91}
92
93pub fn upstream_commit(repo_dir: &Path) -> Result<String, GitError> {
94    let output = Command::new("git")
95        .arg("rev-parse")
96        .arg("--short")
97        .arg("@{u}")
98        .current_dir(repo_dir)
99        .output()
100        .map_err(spawn_error)?;
101    if !output.status.success() {
102        return Err(GitError::Failed {
103            operation: "rev-parse @{u}",
104            code: output.status.code(),
105        });
106    }
107    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
108}
109
110pub fn log_between(repo_dir: &Path, from: &str, to: &str) -> Result<Vec<String>, GitError> {
111    let output = Command::new("git")
112        .arg("log")
113        .arg(format!("{from}..{to}"))
114        .arg("--pretty=format:%s")
115        .arg("--no-decorate")
116        .current_dir(repo_dir)
117        .output()
118        .map_err(spawn_error)?;
119
120    if !output.status.success() {
121        return Err(GitError::Failed {
122            operation: "log",
123            code: output.status.code(),
124        });
125    }
126
127    let stdout = String::from_utf8_lossy(&output.stdout);
128    Ok(stdout.lines().map(str::to_string).collect())
129}
130
131pub fn diffstat(repo_dir: &Path, from: &str, to: &str) -> Result<(usize, usize, usize), GitError> {
132    let output = Command::new("git")
133        .arg("diff")
134        .arg("--numstat")
135        .arg(format!("{from}..{to}"))
136        .current_dir(repo_dir)
137        .output()
138        .map_err(spawn_error)?;
139
140    if !output.status.success() {
141        return Err(GitError::Failed {
142            operation: "diff --numstat",
143            code: output.status.code(),
144        });
145    }
146
147    let stdout = String::from_utf8_lossy(&output.stdout);
148    let mut files = 0usize;
149    let mut insertions = 0usize;
150    let mut deletions = 0usize;
151    for line in stdout.lines() {
152        let mut parts = line.split('\t');
153        let adds = parts.next().unwrap_or("");
154        let dels = parts.next().unwrap_or("");
155        // numstat shows "-" for binary files; skip those line counts but still count the file.
156        if let Ok(n) = adds.parse::<usize>() {
157            insertions += n;
158        }
159        if let Ok(n) = dels.parse::<usize>() {
160            deletions += n;
161        }
162        files += 1;
163    }
164
165    Ok((files, insertions, deletions))
166}
167
168fn spawn_error(e: io::Error) -> GitError {
169    if e.kind() == io::ErrorKind::NotFound {
170        GitError::NotInstalled
171    } else {
172        GitError::Io(e)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn init_repo() -> tempfile::TempDir {
181        let dir = tempfile::tempdir().unwrap();
182        let p = dir.path();
183
184        let run = |args: &[&str]| {
185            let status = Command::new("git")
186                .args(args)
187                .current_dir(p)
188                .status()
189                .unwrap();
190            assert!(status.success(), "git {args:?} failed");
191        };
192
193        run(&["init", "--quiet", "--initial-branch=main"]);
194        run(&["config", "user.email", "test@example.com"]);
195        run(&["config", "user.name", "test"]);
196        run(&["config", "commit.gpgsign", "false"]);
197        std::fs::write(p.join("README"), "hello").unwrap();
198        run(&["add", "."]);
199        run(&["commit", "--quiet", "-m", "initial"]);
200
201        dir
202    }
203
204    #[test]
205    fn head_commit_returns_short_hash() {
206        let repo = init_repo();
207        let commit = head_commit(repo.path()).unwrap();
208        assert!(
209            commit.len() >= 7 && commit.len() <= 40,
210            "expected short hash, got {commit:?}"
211        );
212        assert!(
213            commit.chars().all(|c| c.is_ascii_hexdigit()),
214            "expected hex, got {commit:?}"
215        );
216    }
217
218    #[test]
219    fn head_commit_fails_outside_a_repo() {
220        let dir = tempfile::tempdir().unwrap();
221        let err = head_commit(dir.path()).unwrap_err();
222        assert!(matches!(
223            err,
224            GitError::Failed {
225                operation: "rev-parse HEAD",
226                ..
227            }
228        ));
229    }
230
231    #[test]
232    fn upstream_commit_fails_without_upstream() {
233        let repo = init_repo();
234        let err = upstream_commit(repo.path()).unwrap_err();
235        assert!(matches!(
236            err,
237            GitError::Failed {
238                operation: "rev-parse @{u}",
239                ..
240            }
241        ));
242    }
243
244    fn git_in(p: &Path, args: &[&str]) {
245        let status = Command::new("git")
246            .args(args)
247            .current_dir(p)
248            .status()
249            .unwrap();
250        assert!(status.success(), "git {args:?} failed");
251    }
252
253    fn commit_file(p: &Path, name: &str, contents: &str, message: &str) -> String {
254        std::fs::write(p.join(name), contents).unwrap();
255        git_in(p, &["add", "."]);
256        git_in(p, &["commit", "--quiet", "-m", message]);
257        head_commit(p).unwrap()
258    }
259
260    #[test]
261    fn log_between_is_empty_when_from_equals_to() {
262        let repo = init_repo();
263        let head = head_commit(repo.path()).unwrap();
264        let commits = log_between(repo.path(), &head, &head).unwrap();
265        assert!(commits.is_empty());
266    }
267
268    #[test]
269    fn log_between_returns_subjects_in_reverse_chronological_order() {
270        let repo = init_repo();
271        let initial = head_commit(repo.path()).unwrap();
272        let _ = commit_file(repo.path(), "a.txt", "a\n", "second");
273        let third = commit_file(repo.path(), "b.txt", "b\n", "third");
274
275        let subjects = log_between(repo.path(), &initial, &third).unwrap();
276        assert_eq!(subjects, vec!["third".to_string(), "second".to_string()]);
277    }
278
279    #[test]
280    fn diffstat_is_all_zeros_when_from_equals_to() {
281        let repo = init_repo();
282        let head = head_commit(repo.path()).unwrap();
283        let (files, ins, dels) = diffstat(repo.path(), &head, &head).unwrap();
284        assert_eq!((files, ins, dels), (0, 0, 0));
285    }
286
287    #[test]
288    fn diffstat_counts_added_files_and_lines() {
289        let repo = init_repo();
290        let initial = head_commit(repo.path()).unwrap();
291        let head = commit_file(repo.path(), "new.txt", "line1\nline2\nline3\n", "add new");
292
293        let (files, ins, dels) = diffstat(repo.path(), &initial, &head).unwrap();
294        assert_eq!(files, 1);
295        assert_eq!(ins, 3);
296        assert_eq!(dels, 0);
297    }
298}