1use camino::Utf8Path;
7use tokio::process::Command;
8
9use crate::error::{Error, Result};
10
11pub async fn clone_at(url: &str, dest: &Utf8Path) -> Result<()> {
21 let output = Command::new("git")
22 .arg("clone")
23 .arg("--")
24 .arg(url)
25 .arg(dest.as_str())
26 .output()
27 .await
28 .map_err(|e| Error::Git(format!("spawn `git clone {url}`: {e}")))?;
29 if !output.status.success() {
30 return Err(Error::Git(format!(
31 "git clone {url}: {}",
32 String::from_utf8_lossy(&output.stderr).trim()
33 )));
34 }
35 Ok(())
36}
37
38pub async fn fetch(dir: &Utf8Path) -> Result<()> {
42 let output = Command::new("git")
43 .current_dir(dir.as_std_path())
44 .arg("fetch")
45 .arg("--prune")
46 .output()
47 .await
48 .map_err(|e| Error::Git(format!("spawn `git fetch`: {e}")))?;
49 if !output.status.success() {
50 return Err(Error::Git(format!(
51 "git fetch in {dir}: {}",
52 String::from_utf8_lossy(&output.stderr).trim()
53 )));
54 }
55 Ok(())
56}
57
58pub async fn checkout(dir: &Utf8Path, rev: &str) -> Result<()> {
82 if rev.starts_with('-') {
83 return Err(Error::Git(format!(
84 "rev `{rev}` starts with '-' (looks like a CLI option); refusing to pass to git checkout"
85 )));
86 }
87
88 let literal_err = match try_checkout(dir, rev).await {
89 Ok(()) => return Ok(()),
90 Err(e) => e,
91 };
92
93 if rev == "HEAD" || rev.starts_with("origin/") || rev.starts_with("refs/") {
96 return Err(literal_err);
97 }
98
99 let upstream = format!("origin/{rev}");
100 match try_checkout(dir, &upstream).await {
101 Ok(()) => Ok(()),
102 Err(_) => Err(literal_err),
106 }
107}
108
109async fn try_checkout(dir: &Utf8Path, rev: &str) -> Result<()> {
110 let output = Command::new("git")
111 .current_dir(dir.as_std_path())
112 .arg("-c")
113 .arg("advice.detachedHead=false")
114 .arg("checkout")
115 .arg(rev)
116 .output()
117 .await
118 .map_err(|e| Error::Git(format!("spawn `git checkout {rev}`: {e}")))?;
119 if !output.status.success() {
120 return Err(Error::Git(format!(
121 "git checkout {rev} in {dir}: {}",
122 String::from_utf8_lossy(&output.stderr).trim()
123 )));
124 }
125 Ok(())
126}
127
128pub async fn rev_parse(dir: &Utf8Path, rev: &str) -> Result<String> {
131 let output = Command::new("git")
132 .current_dir(dir.as_std_path())
133 .arg("rev-parse")
134 .arg(rev)
135 .output()
136 .await
137 .map_err(|e| Error::Git(format!("spawn `git rev-parse {rev}`: {e}")))?;
138 if !output.status.success() {
139 return Err(Error::Git(format!(
140 "git rev-parse {rev} in {dir}: {}",
141 String::from_utf8_lossy(&output.stderr).trim()
142 )));
143 }
144 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
145}
146
147pub async fn current_head(dir: &Utf8Path) -> Result<String> {
149 rev_parse(dir, "HEAD").await
150}
151
152pub async fn repo_name_from_remote(dir: &Utf8Path) -> Option<String> {
163 let output = Command::new("git")
164 .current_dir(dir.as_std_path())
165 .args(["config", "--get", "remote.origin.url"])
166 .output()
167 .await
168 .ok()?;
169 if !output.status.success() {
170 return None;
171 }
172 let url = String::from_utf8(output.stdout).ok()?;
173 parse_repo_basename(url.trim())
174}
175
176fn parse_repo_basename(url: &str) -> Option<String> {
186 let url = url.trim();
187 if url.is_empty() {
188 return None;
189 }
190 let last = url.rsplit(['/', ':']).next()?;
194 let trimmed = last.trim_end_matches(".git").trim();
195 if trimmed.is_empty() {
196 None
197 } else {
198 Some(trimmed.to_string())
199 }
200}
201
202pub async fn is_available() -> bool {
204 Command::new("git")
205 .arg("--version")
206 .stdout(std::process::Stdio::null())
207 .stderr(std::process::Stdio::null())
208 .status()
209 .await
210 .map(|s| s.success())
211 .unwrap_or(false)
212}
213
214#[cfg(test)]
215mod tests {
216 use super::parse_repo_basename;
217
218 #[test]
219 fn parse_repo_basename_handles_https_with_dot_git() {
220 assert_eq!(
221 parse_repo_basename("https://github.com/yukimemi/kata.git").as_deref(),
222 Some("kata"),
223 );
224 }
225
226 #[test]
227 fn parse_repo_basename_handles_https_without_dot_git() {
228 assert_eq!(
229 parse_repo_basename("https://github.com/yukimemi/kata").as_deref(),
230 Some("kata"),
231 );
232 }
233
234 #[test]
235 fn parse_repo_basename_handles_ssh_with_dot_git() {
236 assert_eq!(
237 parse_repo_basename("git@github.com:yukimemi/kata.git").as_deref(),
238 Some("kata"),
239 );
240 }
241
242 #[test]
243 fn parse_repo_basename_handles_ssh_without_dot_git() {
244 assert_eq!(
245 parse_repo_basename("git@github.com:yukimemi/kata").as_deref(),
246 Some("kata"),
247 );
248 }
249
250 #[test]
251 fn parse_repo_basename_returns_none_on_garbage_input() {
252 assert!(parse_repo_basename("").is_none());
253 assert!(parse_repo_basename("/").is_none());
254 assert!(parse_repo_basename(":").is_none());
255 assert!(parse_repo_basename(".git").is_none());
256 }
257}