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
110fn spawn_error(e: io::Error) -> GitError {
111    if e.kind() == io::ErrorKind::NotFound {
112        GitError::NotInstalled
113    } else {
114        GitError::Io(e)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn init_repo() -> tempfile::TempDir {
123        let dir = tempfile::tempdir().unwrap();
124        let p = dir.path();
125
126        let run = |args: &[&str]| {
127            let status = Command::new("git")
128                .args(args)
129                .current_dir(p)
130                .status()
131                .unwrap();
132            assert!(status.success(), "git {args:?} failed");
133        };
134
135        run(&["init", "--quiet", "--initial-branch=main"]);
136        run(&["config", "user.email", "test@example.com"]);
137        run(&["config", "user.name", "test"]);
138        run(&["config", "commit.gpgsign", "false"]);
139        std::fs::write(p.join("README"), "hello").unwrap();
140        run(&["add", "."]);
141        run(&["commit", "--quiet", "-m", "initial"]);
142
143        dir
144    }
145
146    #[test]
147    fn head_commit_returns_short_hash() {
148        let repo = init_repo();
149        let commit = head_commit(repo.path()).unwrap();
150        assert!(
151            commit.len() >= 7 && commit.len() <= 40,
152            "expected short hash, got {commit:?}"
153        );
154        assert!(
155            commit.chars().all(|c| c.is_ascii_hexdigit()),
156            "expected hex, got {commit:?}"
157        );
158    }
159
160    #[test]
161    fn head_commit_fails_outside_a_repo() {
162        let dir = tempfile::tempdir().unwrap();
163        let err = head_commit(dir.path()).unwrap_err();
164        assert!(matches!(
165            err,
166            GitError::Failed {
167                operation: "rev-parse HEAD",
168                ..
169            }
170        ));
171    }
172
173    #[test]
174    fn upstream_commit_fails_without_upstream() {
175        let repo = init_repo();
176        let err = upstream_commit(repo.path()).unwrap_err();
177        assert!(matches!(
178            err,
179            GitError::Failed {
180                operation: "rev-parse @{u}",
181                ..
182            }
183        ));
184    }
185}