Skip to main content

joy_core/
vcs.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! VCS abstraction layer (see ADR-010, ADR-017).
5//! All version control operations go through the `Vcs` trait.
6//! Currently only Git is implemented, via CLI process calls.
7
8use std::path::Path;
9use std::process::Command;
10
11use crate::error::JoyError;
12
13const MIN_GIT_MAJOR: u32 = 2;
14
15/// Hosting platform type, detected from git remote URL.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Forge {
18    GitHub,
19    GitLab,
20    Gitea,
21    Unknown,
22}
23
24/// VCS read operations that Joy needs.
25pub trait Vcs {
26    /// Check if the given directory is inside a VCS repository.
27    fn is_repo(&self, root: &Path) -> bool;
28
29    /// Initialize a new repository at the given path.
30    fn init_repo(&self, root: &Path) -> Result<(), JoyError>;
31
32    /// Get the current user's email from VCS config.
33    fn user_email(&self) -> Result<String, JoyError>;
34
35    /// List all version tags (e.g. v0.5.0), sorted descending.
36    fn version_tags(&self, root: &Path) -> Result<Vec<String>, JoyError>;
37
38    /// Get the latest reachable version tag, if any.
39    fn latest_version_tag(&self, root: &Path) -> Result<Option<String>, JoyError>;
40}
41
42/// Git implementation of the VCS trait.
43pub struct GitVcs;
44
45/// Run a git command and return stdout as a trimmed string.
46/// Returns a descriptive error if git is not found or the command fails.
47fn 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
73/// Run a git command silently (ignore stdout/stderr), return Ok/Err.
74fn 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
143// -- Git config operations --
144
145impl GitVcs {
146    /// Get a git config value (local scope).
147    pub fn config_get(&self, root: &Path, key: &str) -> Result<String, JoyError> {
148        git_output(root, &["config", "--local", key])
149    }
150
151    /// Set a git config value (local scope).
152    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// -- Git version check --
158
159/// Parsed git version.
160#[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    /// Get the installed git version. Returns error if git is not found.
170    pub fn version(&self) -> Result<GitVersion, JoyError> {
171        let raw = git_output(Path::new("."), &["--version"])?;
172        parse_git_version(&raw)
173    }
174
175    /// Check that git meets the minimum version requirement.
176    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    // "git version 2.43.0" or "git version 2.43.0.windows.1"
191    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
209// -- Git write operations --
210
211impl GitVcs {
212    /// Stage files for commit.
213    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    /// Stage all changes (git add -A).
220    pub fn add_all(&self, root: &Path) -> Result<(), JoyError> {
221        git_run(root, &["add", "-A"])
222    }
223
224    /// Create a commit with a message.
225    pub fn commit(&self, root: &Path, message: &str) -> Result<(), JoyError> {
226        git_run(root, &["commit", "--quiet", "-m", message])
227    }
228
229    /// Create an annotated tag with a message body.
230    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    /// Create a lightweight tag.
235    pub fn tag(&self, root: &Path, name: &str) -> Result<(), JoyError> {
236        git_run(root, &["tag", name])
237    }
238
239    /// Push the current branch to a remote.
240    pub fn push(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
241        git_run(root, &["push", "--quiet", remote])
242    }
243
244    /// Push a specific tag to a remote.
245    pub fn push_tag(&self, root: &Path, remote: &str, tag: &str) -> Result<(), JoyError> {
246        git_run(root, &["push", "--quiet", remote, tag])
247    }
248
249    /// Push current branch and tags in one call.
250    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    /// Get the default remote name (usually "origin").
256    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    /// Get the remote URL for a given remote name.
263    pub fn remote_url(&self, root: &Path, remote: &str) -> Result<String, JoyError> {
264        git_output(root, &["remote", "get-url", remote])
265    }
266
267    /// Check if the working tree is clean.
268    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    /// Check if HEAD is exactly on a tag.
274    pub fn head_is_tagged(&self, root: &Path) -> bool {
275        git_output(root, &["describe", "--tags", "--exact-match", "HEAD"]).is_ok()
276    }
277}
278
279// -- Forge detection --
280
281impl GitVcs {
282    /// Detect the hosting platform from the remote URL.
283    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
296/// Parse forge type from a git remote URL.
297pub 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
310// -- gh CLI check --
311
312/// Check if the GitHub CLI (gh) is installed and return its version.
313pub 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    // "gh version 2.87.3 (2026-02-24)" -> extract "2.87.3"
328    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
341/// Check if gh CLI is available (returns false if not installed).
342pub 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
351/// Create a GitHub release using the gh CLI.
352pub 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
372/// Default VCS provider. Returns the Git implementation.
373pub fn default_vcs() -> GitVcs {
374    GitVcs
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    #[ignore] // requires git user.email configured
383    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] // requires full clone with tags
398    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        // Should not error, just return true or false
483        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        // We're on GitHub
491        assert_eq!(forge, Forge::GitHub);
492    }
493}