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 is_ignored(&self, root: &Path, path: &str) -> bool {
234 let status = std::process::Command::new("git")
235 .arg("-C")
236 .arg(root)
237 .arg("check-ignore")
238 .arg("--quiet")
239 .arg(path)
240 .stdout(std::process::Stdio::null())
241 .stderr(std::process::Stdio::null())
242 .status();
243 matches!(status, Ok(s) if s.code() == Some(0))
244 }
245
246 pub fn add_all(&self, root: &Path) -> Result<(), JoyError> {
248 git_run(root, &["add", "-A"])
249 }
250
251 pub fn commit(&self, root: &Path, message: &str) -> Result<(), JoyError> {
253 git_run(root, &["commit", "--quiet", "-m", message])
254 }
255
256 pub fn tag_annotated(&self, root: &Path, name: &str, body: &str) -> Result<(), JoyError> {
258 git_run(root, &["tag", "-a", name, "-m", body])
259 }
260
261 pub fn tag(&self, root: &Path, name: &str) -> Result<(), JoyError> {
263 git_run(root, &["tag", name])
264 }
265
266 pub fn push(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
268 git_run(root, &["push", "--quiet", remote])
269 }
270
271 pub fn push_tag(&self, root: &Path, remote: &str, tag: &str) -> Result<(), JoyError> {
273 git_run(root, &["push", "--quiet", remote, tag])
274 }
275
276 pub fn push_with_tags(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
278 self.push(root, remote)?;
279 git_run(root, &["push", "--quiet", remote, "--tags"])
280 }
281
282 pub fn default_remote(&self, root: &Path) -> Result<String, JoyError> {
284 let remote = git_output(root, &["remote"])?;
285 let first = remote.lines().next().unwrap_or("origin");
286 Ok(first.to_string())
287 }
288
289 pub fn remote_url(&self, root: &Path, remote: &str) -> Result<String, JoyError> {
291 git_output(root, &["remote", "get-url", remote])
292 }
293
294 pub fn is_clean(&self, root: &Path) -> Result<bool, JoyError> {
296 let output = git_output(root, &["status", "--porcelain"])?;
297 Ok(output.is_empty())
298 }
299
300 pub fn head_is_tagged(&self, root: &Path) -> bool {
302 git_output(root, &["describe", "--tags", "--exact-match", "HEAD"]).is_ok()
303 }
304}
305
306impl GitVcs {
309 pub fn detect_forge(&self, root: &Path) -> Forge {
311 let remote = match self.default_remote(root) {
312 Ok(r) => r,
313 Err(_) => return Forge::Unknown,
314 };
315 let url = match self.remote_url(root, &remote) {
316 Ok(u) => u,
317 Err(_) => return Forge::Unknown,
318 };
319 parse_forge_from_url(&url)
320 }
321}
322
323pub fn parse_forge_from_url(url: &str) -> Forge {
325 let lower = url.to_lowercase();
326 if lower.contains("github.com") {
327 Forge::GitHub
328 } else if lower.contains("gitlab.com") || lower.contains("gitlab") {
329 Forge::GitLab
330 } else if lower.contains("gitea") || lower.contains("codeberg.org") {
331 Forge::Gitea
332 } else {
333 Forge::Unknown
334 }
335}
336
337pub fn gh_version() -> Result<String, JoyError> {
341 let output = Command::new("gh").arg("--version").output().map_err(|e| {
342 if e.kind() == std::io::ErrorKind::NotFound {
343 JoyError::Git("gh (GitHub CLI) is not installed or not in PATH".into())
344 } else {
345 JoyError::Git(format!("failed to run gh: {e}"))
346 }
347 })?;
348
349 if !output.status.success() {
350 return Err(JoyError::Git("gh --version failed".into()));
351 }
352
353 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
354 let version = raw
356 .lines()
357 .next()
358 .unwrap_or(&raw)
359 .strip_prefix("gh version ")
360 .unwrap_or(&raw)
361 .split_whitespace()
362 .next()
363 .unwrap_or(&raw)
364 .to_string();
365 Ok(version)
366}
367
368pub fn has_gh() -> bool {
370 Command::new("gh")
371 .arg("--version")
372 .stdout(std::process::Stdio::null())
373 .stderr(std::process::Stdio::null())
374 .status()
375 .is_ok_and(|s| s.success())
376}
377
378pub fn gh_create_release(
380 root: &Path,
381 tag: &str,
382 title: &str,
383 notes: &str,
384) -> Result<String, JoyError> {
385 let output = Command::new("gh")
386 .args(["release", "create", tag, "--title", title, "--notes", notes])
387 .current_dir(root)
388 .output()
389 .map_err(|e| JoyError::Git(format!("failed to run gh release create: {e}")))?;
390
391 if !output.status.success() {
392 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
393 return Err(JoyError::Git(format!("gh release create failed: {stderr}")));
394 }
395
396 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
397}
398
399pub fn default_vcs() -> GitVcs {
401 GitVcs
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 #[ignore] fn git_vcs_user_email() {
411 let vcs = GitVcs;
412 let result = vcs.user_email();
413 assert!(result.is_ok());
414 assert!(!result.unwrap().is_empty());
415 }
416
417 #[test]
418 fn git_vcs_is_repo() {
419 let vcs = GitVcs;
420 assert!(vcs.is_repo(Path::new(".")));
421 }
422
423 #[test]
424 #[ignore] fn git_vcs_version_tags() {
426 let vcs = GitVcs;
427 let tags = vcs.version_tags(Path::new(".")).unwrap();
428 assert!(!tags.is_empty());
429 }
430
431 #[test]
432 fn parse_git_version_standard() {
433 let v = parse_git_version("git version 2.43.0").unwrap();
434 assert_eq!(v.major, 2);
435 assert_eq!(v.minor, 43);
436 assert_eq!(v.patch, 0);
437 }
438
439 #[test]
440 fn parse_git_version_windows() {
441 let v = parse_git_version("git version 2.43.0.windows.1").unwrap();
442 assert_eq!(v.major, 2);
443 assert_eq!(v.minor, 43);
444 assert_eq!(v.patch, 0);
445 }
446
447 #[test]
448 fn parse_git_version_old() {
449 let v = parse_git_version("git version 1.8.5").unwrap();
450 assert_eq!(v.major, 1);
451 assert_eq!(v.minor, 8);
452 assert_eq!(v.patch, 5);
453 }
454
455 #[test]
456 fn forge_detection_github() {
457 assert_eq!(
458 parse_forge_from_url("git@github.com:joyint/joy.git"),
459 Forge::GitHub
460 );
461 assert_eq!(
462 parse_forge_from_url("https://github.com/joyint/joy.git"),
463 Forge::GitHub
464 );
465 }
466
467 #[test]
468 fn forge_detection_gitlab() {
469 assert_eq!(
470 parse_forge_from_url("git@gitlab.com:user/repo.git"),
471 Forge::GitLab
472 );
473 assert_eq!(
474 parse_forge_from_url("https://gitlab.example.com/user/repo.git"),
475 Forge::GitLab
476 );
477 }
478
479 #[test]
480 fn forge_detection_gitea() {
481 assert_eq!(
482 parse_forge_from_url("https://codeberg.org/user/repo.git"),
483 Forge::Gitea
484 );
485 assert_eq!(
486 parse_forge_from_url("https://gitea.example.com/user/repo.git"),
487 Forge::Gitea
488 );
489 }
490
491 #[test]
492 fn forge_detection_unknown() {
493 assert_eq!(
494 parse_forge_from_url("https://example.com/repo.git"),
495 Forge::Unknown
496 );
497 }
498
499 #[test]
500 fn git_version_check() {
501 let vcs = GitVcs;
502 let v = vcs.check_version().unwrap();
503 assert!(v.major >= MIN_GIT_MAJOR);
504 }
505
506 #[test]
507 fn git_clean_check() {
508 let vcs = GitVcs;
509 let _ = vcs.is_clean(Path::new("."));
511 }
512
513 #[test]
514 fn git_detect_forge() {
515 let vcs = GitVcs;
516 let forge = vcs.detect_forge(Path::new("."));
517 assert_eq!(forge, Forge::GitHub);
519 }
520}