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