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
24impl Forge {
25 pub fn as_config_str(&self) -> Option<&'static str> {
28 match self {
29 Forge::GitHub => Some("github"),
30 Forge::GitLab => Some("gitlab"),
31 Forge::Gitea => Some("gitea"),
32 Forge::Unknown => None,
33 }
34 }
35}
36
37pub trait Vcs {
39 fn is_repo(&self, root: &Path) -> bool;
41
42 fn init_repo(&self, root: &Path) -> Result<(), JoyError>;
44
45 fn user_email(&self) -> Result<String, JoyError>;
47
48 fn version_tags(&self, root: &Path) -> Result<Vec<String>, JoyError>;
50
51 fn latest_version_tag(&self, root: &Path) -> Result<Option<String>, JoyError>;
53
54 fn config_get(&self, root: &Path, key: &str) -> Result<String, JoyError>;
59
60 fn config_set(&self, root: &Path, key: &str, value: &str) -> Result<(), JoyError>;
63}
64
65pub struct GitVcs;
67
68fn git_output(root: &Path, args: &[&str]) -> Result<String, JoyError> {
71 let output = Command::new("git")
72 .args(args)
73 .current_dir(root)
74 .output()
75 .map_err(|e| {
76 if e.kind() == std::io::ErrorKind::NotFound {
77 JoyError::Git("git is not installed or not in PATH".into())
78 } else {
79 JoyError::Git(format!("failed to run git {}: {e}", args.join(" ")))
80 }
81 })?;
82
83 if !output.status.success() {
84 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
85 let cmd = format!("git {}", args.join(" "));
86 return Err(JoyError::Git(if stderr.is_empty() {
87 format!("{cmd} failed (exit {})", output.status.code().unwrap_or(-1))
88 } else {
89 format!("{cmd} failed: {stderr}")
90 }));
91 }
92
93 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
94}
95
96fn git_run(root: &Path, args: &[&str]) -> Result<(), JoyError> {
98 let output = Command::new("git")
99 .args(args)
100 .current_dir(root)
101 .output()
102 .map_err(|e| {
103 if e.kind() == std::io::ErrorKind::NotFound {
104 JoyError::Git("git is not installed or not in PATH".into())
105 } else {
106 JoyError::Git(format!("failed to run git {}: {e}", args.join(" ")))
107 }
108 })?;
109
110 if !output.status.success() {
111 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
112 let cmd = format!("git {}", args.join(" "));
113 return Err(JoyError::Git(if stderr.is_empty() {
114 format!("{cmd} failed (exit {})", output.status.code().unwrap_or(-1))
115 } else {
116 format!("{cmd} failed: {stderr}")
117 }));
118 }
119
120 Ok(())
121}
122
123impl Vcs for GitVcs {
124 fn is_repo(&self, root: &Path) -> bool {
125 Command::new("git")
126 .args(["rev-parse", "--is-inside-work-tree"])
127 .current_dir(root)
128 .stdout(std::process::Stdio::null())
129 .stderr(std::process::Stdio::null())
130 .status()
131 .is_ok_and(|s| s.success())
132 }
133
134 fn init_repo(&self, root: &Path) -> Result<(), JoyError> {
135 git_run(root, &["init"])
136 }
137
138 fn user_email(&self) -> Result<String, JoyError> {
139 let email = git_output(Path::new("."), &["config", "user.email"])?;
140 if email.is_empty() {
141 return Err(JoyError::Git("git user.email is empty".into()));
142 }
143 Ok(email)
144 }
145
146 fn version_tags(&self, root: &Path) -> Result<Vec<String>, JoyError> {
147 let output = git_output(root, &["tag", "--list", "--sort=-v:refname"]).unwrap_or_default();
148
149 let tags: Vec<String> = output
150 .lines()
151 .filter(|l| l.starts_with('v') || l.starts_with('V'))
152 .map(|l| l.to_string())
153 .collect();
154
155 Ok(tags)
156 }
157
158 fn latest_version_tag(&self, root: &Path) -> Result<Option<String>, JoyError> {
159 match git_output(root, &["describe", "--tags", "--abbrev=0", "--match", "v*"]) {
160 Ok(tag) if !tag.is_empty() => Ok(Some(tag)),
161 _ => Ok(None),
162 }
163 }
164
165 fn config_get(&self, root: &Path, key: &str) -> Result<String, JoyError> {
166 git_output(root, &["config", "--local", key])
167 }
168
169 fn config_set(&self, root: &Path, key: &str, value: &str) -> Result<(), JoyError> {
170 git_run(root, &["config", "--local", key, value])
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct GitVersion {
181 pub major: u32,
182 pub minor: u32,
183 pub patch: u32,
184 pub raw: String,
185}
186
187impl GitVcs {
188 pub fn version(&self) -> Result<GitVersion, JoyError> {
190 let raw = git_output(Path::new("."), &["--version"])?;
191 parse_git_version(&raw)
192 }
193
194 pub fn check_version(&self) -> Result<GitVersion, JoyError> {
196 let v = self.version()?;
197 if v.major < MIN_GIT_MAJOR {
198 return Err(JoyError::Git(format!(
199 "git {}.{}.{} is too old (minimum: {MIN_GIT_MAJOR}.0)\n \
200 = help: update git to version {MIN_GIT_MAJOR}.0 or newer",
201 v.major, v.minor, v.patch
202 )));
203 }
204 Ok(v)
205 }
206}
207
208fn parse_git_version(raw: &str) -> Result<GitVersion, JoyError> {
209 let version_str = raw.strip_prefix("git version ").unwrap_or(raw).trim();
211
212 let parts: Vec<&str> = version_str.splitn(4, '.').collect();
213 let major: u32 = parts
214 .first()
215 .and_then(|s| s.parse().ok())
216 .ok_or_else(|| JoyError::Git(format!("cannot parse git version: {raw}")))?;
217 let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
218 let patch: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
219
220 Ok(GitVersion {
221 major,
222 minor,
223 patch,
224 raw: raw.to_string(),
225 })
226}
227
228impl GitVcs {
231 pub fn add(&self, root: &Path, paths: &[&str]) -> Result<(), JoyError> {
233 let mut args = vec!["add"];
234 args.extend_from_slice(paths);
235 git_run(root, &args)
236 }
237
238 pub fn is_ignored(&self, root: &Path, path: &str) -> bool {
247 let status = std::process::Command::new("git")
248 .arg("-C")
249 .arg(root)
250 .arg("check-ignore")
251 .arg("--quiet")
252 .arg(path)
253 .stdout(std::process::Stdio::null())
254 .stderr(std::process::Stdio::null())
255 .status();
256 matches!(status, Ok(s) if s.code() == Some(0))
257 }
258
259 pub fn add_all(&self, root: &Path) -> Result<(), JoyError> {
261 git_run(root, &["add", "-A"])
262 }
263
264 pub fn commit(&self, root: &Path, message: &str) -> Result<(), JoyError> {
266 git_run(root, &["commit", "--quiet", "-m", message])
267 }
268
269 pub fn tag_annotated(&self, root: &Path, name: &str, body: &str) -> Result<(), JoyError> {
271 git_run(root, &["tag", "-a", name, "-m", body])
272 }
273
274 pub fn tag(&self, root: &Path, name: &str) -> Result<(), JoyError> {
276 git_run(root, &["tag", name])
277 }
278
279 pub fn push(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
281 git_run(root, &["push", "--quiet", remote])
282 }
283
284 pub fn push_tag(&self, root: &Path, remote: &str, tag: &str) -> Result<(), JoyError> {
286 git_run(root, &["push", "--quiet", remote, tag])
287 }
288
289 pub fn push_with_tags(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
291 self.push(root, remote)?;
292 git_run(root, &["push", "--quiet", remote, "--tags"])
293 }
294
295 pub fn default_remote(&self, root: &Path) -> Result<String, JoyError> {
297 let remote = git_output(root, &["remote"])?;
298 let first = remote.lines().next().unwrap_or("origin");
299 Ok(first.to_string())
300 }
301
302 pub fn remote_url(&self, root: &Path, remote: &str) -> Result<String, JoyError> {
304 git_output(root, &["remote", "get-url", remote])
305 }
306
307 pub fn all_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, JoyError> {
311 let names = match git_output(root, &["remote"]) {
312 Ok(s) => s,
313 Err(_) => return Ok(Vec::new()),
314 };
315 let mut out = Vec::new();
316 for name in names.lines() {
317 if name.is_empty() {
318 continue;
319 }
320 if let Ok(url) = self.remote_url(root, name) {
321 out.push((name.to_string(), url));
322 }
323 }
324 Ok(out)
325 }
326
327 pub fn is_clean(&self, root: &Path) -> Result<bool, JoyError> {
329 let output = git_output(root, &["status", "--porcelain"])?;
330 Ok(output.is_empty())
331 }
332
333 pub fn head_is_tagged(&self, root: &Path) -> bool {
335 git_output(root, &["describe", "--tags", "--exact-match", "HEAD"]).is_ok()
336 }
337}
338
339impl GitVcs {
342 pub fn detect_forge(&self, root: &Path) -> Forge {
344 let remote = match self.default_remote(root) {
345 Ok(r) => r,
346 Err(_) => return Forge::Unknown,
347 };
348 let url = match self.remote_url(root, &remote) {
349 Ok(u) => u,
350 Err(_) => return Forge::Unknown,
351 };
352 parse_forge_from_url(&url)
353 }
354
355 pub fn detect_forges(&self, root: &Path) -> Vec<(String, Forge)> {
360 self.all_remotes(root)
361 .unwrap_or_default()
362 .into_iter()
363 .map(|(name, url)| (name, parse_forge_from_url(&url)))
364 .collect()
365 }
366}
367
368pub fn parse_forge_from_url(url: &str) -> Forge {
370 let lower = url.to_lowercase();
371 if lower.contains("github.com") {
372 Forge::GitHub
373 } else if lower.contains("gitlab.com") || lower.contains("gitlab") {
374 Forge::GitLab
375 } else if lower.contains("gitea") || lower.contains("codeberg.org") {
376 Forge::Gitea
377 } else {
378 Forge::Unknown
379 }
380}
381
382pub fn gh_version() -> Result<String, JoyError> {
386 let output = Command::new("gh").arg("--version").output().map_err(|e| {
387 if e.kind() == std::io::ErrorKind::NotFound {
388 JoyError::Git("gh (GitHub CLI) is not installed or not in PATH".into())
389 } else {
390 JoyError::Git(format!("failed to run gh: {e}"))
391 }
392 })?;
393
394 if !output.status.success() {
395 return Err(JoyError::Git("gh --version failed".into()));
396 }
397
398 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
399 let version = raw
401 .lines()
402 .next()
403 .unwrap_or(&raw)
404 .strip_prefix("gh version ")
405 .unwrap_or(&raw)
406 .split_whitespace()
407 .next()
408 .unwrap_or(&raw)
409 .to_string();
410 Ok(version)
411}
412
413pub fn has_gh() -> bool {
415 Command::new("gh")
416 .arg("--version")
417 .stdout(std::process::Stdio::null())
418 .stderr(std::process::Stdio::null())
419 .status()
420 .is_ok_and(|s| s.success())
421}
422
423pub fn gh_create_release(
425 root: &Path,
426 tag: &str,
427 title: &str,
428 notes: &str,
429) -> Result<String, JoyError> {
430 let output = Command::new("gh")
431 .args(["release", "create", tag, "--title", title, "--notes", notes])
432 .current_dir(root)
433 .output()
434 .map_err(|e| JoyError::Git(format!("failed to run gh release create: {e}")))?;
435
436 if !output.status.success() {
437 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
438 return Err(JoyError::Git(format!("gh release create failed: {stderr}")));
439 }
440
441 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
442}
443
444pub fn default_vcs() -> GitVcs {
446 GitVcs
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 #[ignore] fn git_vcs_user_email() {
456 let vcs = GitVcs;
457 let result = vcs.user_email();
458 assert!(result.is_ok());
459 assert!(!result.unwrap().is_empty());
460 }
461
462 #[test]
463 fn git_vcs_is_repo() {
464 let vcs = GitVcs;
465 assert!(vcs.is_repo(Path::new(".")));
466 }
467
468 #[test]
469 #[ignore] fn git_vcs_version_tags() {
471 let vcs = GitVcs;
472 let tags = vcs.version_tags(Path::new(".")).unwrap();
473 assert!(!tags.is_empty());
474 }
475
476 #[test]
477 fn parse_git_version_standard() {
478 let v = parse_git_version("git version 2.43.0").unwrap();
479 assert_eq!(v.major, 2);
480 assert_eq!(v.minor, 43);
481 assert_eq!(v.patch, 0);
482 }
483
484 #[test]
485 fn parse_git_version_windows() {
486 let v = parse_git_version("git version 2.43.0.windows.1").unwrap();
487 assert_eq!(v.major, 2);
488 assert_eq!(v.minor, 43);
489 assert_eq!(v.patch, 0);
490 }
491
492 #[test]
493 fn parse_git_version_old() {
494 let v = parse_git_version("git version 1.8.5").unwrap();
495 assert_eq!(v.major, 1);
496 assert_eq!(v.minor, 8);
497 assert_eq!(v.patch, 5);
498 }
499
500 #[test]
501 fn forge_detection_github() {
502 assert_eq!(
503 parse_forge_from_url("git@github.com:joyint/joy.git"),
504 Forge::GitHub
505 );
506 assert_eq!(
507 parse_forge_from_url("https://github.com/joyint/joy.git"),
508 Forge::GitHub
509 );
510 }
511
512 #[test]
513 fn forge_detection_gitlab() {
514 assert_eq!(
515 parse_forge_from_url("git@gitlab.com:user/repo.git"),
516 Forge::GitLab
517 );
518 assert_eq!(
519 parse_forge_from_url("https://gitlab.example.com/user/repo.git"),
520 Forge::GitLab
521 );
522 }
523
524 #[test]
525 fn forge_detection_gitea() {
526 assert_eq!(
527 parse_forge_from_url("https://codeberg.org/user/repo.git"),
528 Forge::Gitea
529 );
530 assert_eq!(
531 parse_forge_from_url("https://gitea.example.com/user/repo.git"),
532 Forge::Gitea
533 );
534 }
535
536 #[test]
537 fn forge_detection_unknown() {
538 assert_eq!(
539 parse_forge_from_url("https://example.com/repo.git"),
540 Forge::Unknown
541 );
542 }
543
544 #[test]
545 fn git_version_check() {
546 let vcs = GitVcs;
547 let v = vcs.check_version().unwrap();
548 assert!(v.major >= MIN_GIT_MAJOR);
549 }
550
551 #[test]
552 fn git_clean_check() {
553 let vcs = GitVcs;
554 let _ = vcs.is_clean(Path::new("."));
556 }
557
558 #[test]
559 fn git_detect_forge() {
560 let vcs = GitVcs;
561 let forge = vcs.detect_forge(Path::new("."));
562 assert_eq!(forge, Forge::GitHub);
564 }
565}