1use std::path::Path;
9use std::process::Command;
10
11use crate::error::JoyError;
12
13const MIN_GIT_MAJOR: u32 = 2;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Forge {
18 GitHub,
19 GitLab,
20 Gitea,
21 Unknown,
22}
23
24pub trait Vcs {
26 fn is_repo(&self, root: &Path) -> bool;
28
29 fn init_repo(&self, root: &Path) -> Result<(), JoyError>;
31
32 fn user_email(&self) -> Result<String, JoyError>;
34
35 fn version_tags(&self, root: &Path) -> Result<Vec<String>, JoyError>;
37
38 fn latest_version_tag(&self, root: &Path) -> Result<Option<String>, JoyError>;
40
41 fn config_get(&self, root: &Path, key: &str) -> Result<String, JoyError>;
46
47 fn config_set(&self, root: &Path, key: &str, value: &str) -> Result<(), JoyError>;
50}
51
52pub struct GitVcs;
54
55fn git_output(root: &Path, args: &[&str]) -> Result<String, JoyError> {
58 let output = Command::new("git")
59 .args(args)
60 .current_dir(root)
61 .output()
62 .map_err(|e| {
63 if e.kind() == std::io::ErrorKind::NotFound {
64 JoyError::Git("git is not installed or not in PATH".into())
65 } else {
66 JoyError::Git(format!("failed to run git {}: {e}", args.join(" ")))
67 }
68 })?;
69
70 if !output.status.success() {
71 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
72 let cmd = format!("git {}", args.join(" "));
73 return Err(JoyError::Git(if stderr.is_empty() {
74 format!("{cmd} failed (exit {})", output.status.code().unwrap_or(-1))
75 } else {
76 format!("{cmd} failed: {stderr}")
77 }));
78 }
79
80 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
81}
82
83fn git_run(root: &Path, args: &[&str]) -> Result<(), JoyError> {
85 let output = Command::new("git")
86 .args(args)
87 .current_dir(root)
88 .output()
89 .map_err(|e| {
90 if e.kind() == std::io::ErrorKind::NotFound {
91 JoyError::Git("git is not installed or not in PATH".into())
92 } else {
93 JoyError::Git(format!("failed to run git {}: {e}", args.join(" ")))
94 }
95 })?;
96
97 if !output.status.success() {
98 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
99 let cmd = format!("git {}", args.join(" "));
100 return Err(JoyError::Git(if stderr.is_empty() {
101 format!("{cmd} failed (exit {})", output.status.code().unwrap_or(-1))
102 } else {
103 format!("{cmd} failed: {stderr}")
104 }));
105 }
106
107 Ok(())
108}
109
110impl Vcs for GitVcs {
111 fn is_repo(&self, root: &Path) -> bool {
112 Command::new("git")
113 .args(["rev-parse", "--is-inside-work-tree"])
114 .current_dir(root)
115 .stdout(std::process::Stdio::null())
116 .stderr(std::process::Stdio::null())
117 .status()
118 .is_ok_and(|s| s.success())
119 }
120
121 fn init_repo(&self, root: &Path) -> Result<(), JoyError> {
122 git_run(root, &["init"])
123 }
124
125 fn user_email(&self) -> Result<String, JoyError> {
126 let email = git_output(Path::new("."), &["config", "user.email"])?;
127 if email.is_empty() {
128 return Err(JoyError::Git("git user.email is empty".into()));
129 }
130 Ok(email)
131 }
132
133 fn version_tags(&self, root: &Path) -> Result<Vec<String>, JoyError> {
134 let output = git_output(root, &["tag", "--list", "--sort=-v:refname"]).unwrap_or_default();
135
136 let tags: Vec<String> = output
137 .lines()
138 .filter(|l| l.starts_with('v') || l.starts_with('V'))
139 .map(|l| l.to_string())
140 .collect();
141
142 Ok(tags)
143 }
144
145 fn latest_version_tag(&self, root: &Path) -> Result<Option<String>, JoyError> {
146 match git_output(root, &["describe", "--tags", "--abbrev=0", "--match", "v*"]) {
147 Ok(tag) if !tag.is_empty() => Ok(Some(tag)),
148 _ => Ok(None),
149 }
150 }
151
152 fn config_get(&self, root: &Path, key: &str) -> Result<String, JoyError> {
153 git_output(root, &["config", "--local", key])
154 }
155
156 fn config_set(&self, root: &Path, key: &str, value: &str) -> Result<(), JoyError> {
157 git_run(root, &["config", "--local", key, value])
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct GitVersion {
168 pub major: u32,
169 pub minor: u32,
170 pub patch: u32,
171 pub raw: String,
172}
173
174impl GitVcs {
175 pub fn version(&self) -> Result<GitVersion, JoyError> {
177 let raw = git_output(Path::new("."), &["--version"])?;
178 parse_git_version(&raw)
179 }
180
181 pub fn check_version(&self) -> Result<GitVersion, JoyError> {
183 let v = self.version()?;
184 if v.major < MIN_GIT_MAJOR {
185 return Err(JoyError::Git(format!(
186 "git {}.{}.{} is too old (minimum: {MIN_GIT_MAJOR}.0)\n \
187 = help: update git to version {MIN_GIT_MAJOR}.0 or newer",
188 v.major, v.minor, v.patch
189 )));
190 }
191 Ok(v)
192 }
193}
194
195fn parse_git_version(raw: &str) -> Result<GitVersion, JoyError> {
196 let version_str = raw.strip_prefix("git version ").unwrap_or(raw).trim();
198
199 let parts: Vec<&str> = version_str.splitn(4, '.').collect();
200 let major: u32 = parts
201 .first()
202 .and_then(|s| s.parse().ok())
203 .ok_or_else(|| JoyError::Git(format!("cannot parse git version: {raw}")))?;
204 let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
205 let patch: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
206
207 Ok(GitVersion {
208 major,
209 minor,
210 patch,
211 raw: raw.to_string(),
212 })
213}
214
215impl GitVcs {
218 pub fn add(&self, root: &Path, paths: &[&str]) -> Result<(), JoyError> {
220 let mut args = vec!["add"];
221 args.extend_from_slice(paths);
222 git_run(root, &args)
223 }
224
225 pub fn add_all(&self, root: &Path) -> Result<(), JoyError> {
227 git_run(root, &["add", "-A"])
228 }
229
230 pub fn commit(&self, root: &Path, message: &str) -> Result<(), JoyError> {
232 git_run(root, &["commit", "--quiet", "-m", message])
233 }
234
235 pub fn tag_annotated(&self, root: &Path, name: &str, body: &str) -> Result<(), JoyError> {
237 git_run(root, &["tag", "-a", name, "-m", body])
238 }
239
240 pub fn tag(&self, root: &Path, name: &str) -> Result<(), JoyError> {
242 git_run(root, &["tag", name])
243 }
244
245 pub fn push(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
247 git_run(root, &["push", "--quiet", remote])
248 }
249
250 pub fn push_tag(&self, root: &Path, remote: &str, tag: &str) -> Result<(), JoyError> {
252 git_run(root, &["push", "--quiet", remote, tag])
253 }
254
255 pub fn push_with_tags(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
257 self.push(root, remote)?;
258 git_run(root, &["push", "--quiet", remote, "--tags"])
259 }
260
261 pub fn default_remote(&self, root: &Path) -> Result<String, JoyError> {
263 let remote = git_output(root, &["remote"])?;
264 let first = remote.lines().next().unwrap_or("origin");
265 Ok(first.to_string())
266 }
267
268 pub fn remote_url(&self, root: &Path, remote: &str) -> Result<String, JoyError> {
270 git_output(root, &["remote", "get-url", remote])
271 }
272
273 pub fn is_clean(&self, root: &Path) -> Result<bool, JoyError> {
275 let output = git_output(root, &["status", "--porcelain"])?;
276 Ok(output.is_empty())
277 }
278
279 pub fn head_is_tagged(&self, root: &Path) -> bool {
281 git_output(root, &["describe", "--tags", "--exact-match", "HEAD"]).is_ok()
282 }
283}
284
285impl GitVcs {
288 pub fn detect_forge(&self, root: &Path) -> Forge {
290 let remote = match self.default_remote(root) {
291 Ok(r) => r,
292 Err(_) => return Forge::Unknown,
293 };
294 let url = match self.remote_url(root, &remote) {
295 Ok(u) => u,
296 Err(_) => return Forge::Unknown,
297 };
298 parse_forge_from_url(&url)
299 }
300}
301
302pub fn parse_forge_from_url(url: &str) -> Forge {
304 let lower = url.to_lowercase();
305 if lower.contains("github.com") {
306 Forge::GitHub
307 } else if lower.contains("gitlab.com") || lower.contains("gitlab") {
308 Forge::GitLab
309 } else if lower.contains("gitea") || lower.contains("codeberg.org") {
310 Forge::Gitea
311 } else {
312 Forge::Unknown
313 }
314}
315
316pub fn gh_version() -> Result<String, JoyError> {
320 let output = Command::new("gh").arg("--version").output().map_err(|e| {
321 if e.kind() == std::io::ErrorKind::NotFound {
322 JoyError::Git("gh (GitHub CLI) is not installed or not in PATH".into())
323 } else {
324 JoyError::Git(format!("failed to run gh: {e}"))
325 }
326 })?;
327
328 if !output.status.success() {
329 return Err(JoyError::Git("gh --version failed".into()));
330 }
331
332 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
333 let version = raw
335 .lines()
336 .next()
337 .unwrap_or(&raw)
338 .strip_prefix("gh version ")
339 .unwrap_or(&raw)
340 .split_whitespace()
341 .next()
342 .unwrap_or(&raw)
343 .to_string();
344 Ok(version)
345}
346
347pub fn has_gh() -> bool {
349 Command::new("gh")
350 .arg("--version")
351 .stdout(std::process::Stdio::null())
352 .stderr(std::process::Stdio::null())
353 .status()
354 .is_ok_and(|s| s.success())
355}
356
357pub fn gh_create_release(
359 root: &Path,
360 tag: &str,
361 title: &str,
362 notes: &str,
363) -> Result<String, JoyError> {
364 let output = Command::new("gh")
365 .args(["release", "create", tag, "--title", title, "--notes", notes])
366 .current_dir(root)
367 .output()
368 .map_err(|e| JoyError::Git(format!("failed to run gh release create: {e}")))?;
369
370 if !output.status.success() {
371 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
372 return Err(JoyError::Git(format!("gh release create failed: {stderr}")));
373 }
374
375 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
376}
377
378pub fn default_vcs() -> GitVcs {
380 GitVcs
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 #[ignore] fn git_vcs_user_email() {
390 let vcs = GitVcs;
391 let result = vcs.user_email();
392 assert!(result.is_ok());
393 assert!(!result.unwrap().is_empty());
394 }
395
396 #[test]
397 fn git_vcs_is_repo() {
398 let vcs = GitVcs;
399 assert!(vcs.is_repo(Path::new(".")));
400 }
401
402 #[test]
403 #[ignore] fn git_vcs_version_tags() {
405 let vcs = GitVcs;
406 let tags = vcs.version_tags(Path::new(".")).unwrap();
407 assert!(!tags.is_empty());
408 }
409
410 #[test]
411 fn parse_git_version_standard() {
412 let v = parse_git_version("git version 2.43.0").unwrap();
413 assert_eq!(v.major, 2);
414 assert_eq!(v.minor, 43);
415 assert_eq!(v.patch, 0);
416 }
417
418 #[test]
419 fn parse_git_version_windows() {
420 let v = parse_git_version("git version 2.43.0.windows.1").unwrap();
421 assert_eq!(v.major, 2);
422 assert_eq!(v.minor, 43);
423 assert_eq!(v.patch, 0);
424 }
425
426 #[test]
427 fn parse_git_version_old() {
428 let v = parse_git_version("git version 1.8.5").unwrap();
429 assert_eq!(v.major, 1);
430 assert_eq!(v.minor, 8);
431 assert_eq!(v.patch, 5);
432 }
433
434 #[test]
435 fn forge_detection_github() {
436 assert_eq!(
437 parse_forge_from_url("git@github.com:joyint/joy.git"),
438 Forge::GitHub
439 );
440 assert_eq!(
441 parse_forge_from_url("https://github.com/joyint/joy.git"),
442 Forge::GitHub
443 );
444 }
445
446 #[test]
447 fn forge_detection_gitlab() {
448 assert_eq!(
449 parse_forge_from_url("git@gitlab.com:user/repo.git"),
450 Forge::GitLab
451 );
452 assert_eq!(
453 parse_forge_from_url("https://gitlab.example.com/user/repo.git"),
454 Forge::GitLab
455 );
456 }
457
458 #[test]
459 fn forge_detection_gitea() {
460 assert_eq!(
461 parse_forge_from_url("https://codeberg.org/user/repo.git"),
462 Forge::Gitea
463 );
464 assert_eq!(
465 parse_forge_from_url("https://gitea.example.com/user/repo.git"),
466 Forge::Gitea
467 );
468 }
469
470 #[test]
471 fn forge_detection_unknown() {
472 assert_eq!(
473 parse_forge_from_url("https://example.com/repo.git"),
474 Forge::Unknown
475 );
476 }
477
478 #[test]
479 fn git_version_check() {
480 let vcs = GitVcs;
481 let v = vcs.check_version().unwrap();
482 assert!(v.major >= MIN_GIT_MAJOR);
483 }
484
485 #[test]
486 fn git_clean_check() {
487 let vcs = GitVcs;
488 let _ = vcs.is_clean(Path::new("."));
490 }
491
492 #[test]
493 fn git_detect_forge() {
494 let vcs = GitVcs;
495 let forge = vcs.detect_forge(Path::new("."));
496 assert_eq!(forge, Forge::GitHub);
498 }
499}