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}