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    /// Read a per-clone VCS-config value. The semantics are
42    /// Git-flavoured (`git config --local <key>`); other backends
43    /// implement on the closest equivalent (jj has `jj config`, pijul
44    /// `pijul config`).
45    fn config_get(&self, root: &Path, key: &str) -> Result<String, JoyError>;
46
47    /// Write a per-clone VCS-config value. Same semantics as
48    /// [`Vcs::config_get`].
49    fn config_set(&self, root: &Path, key: &str, value: &str) -> Result<(), JoyError>;
50}
51
52/// Git implementation of the VCS trait.
53pub struct GitVcs;
54
55/// Run a git command and return stdout as a trimmed string.
56/// Returns a descriptive error if git is not found or the command fails.
57fn 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
83/// Run a git command silently (ignore stdout/stderr), return Ok/Err.
84fn 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// -- Git config operations: now part of the Vcs trait. --
162
163// -- Git version check --
164
165/// Parsed git version.
166#[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    /// Get the installed git version. Returns error if git is not found.
176    pub fn version(&self) -> Result<GitVersion, JoyError> {
177        let raw = git_output(Path::new("."), &["--version"])?;
178        parse_git_version(&raw)
179    }
180
181    /// Check that git meets the minimum version requirement.
182    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    // "git version 2.43.0" or "git version 2.43.0.windows.1"
197    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
215// -- Git write operations --
216
217impl GitVcs {
218    /// Stage files for commit.
219    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    /// Stage all changes (git add -A).
226    pub fn add_all(&self, root: &Path) -> Result<(), JoyError> {
227        git_run(root, &["add", "-A"])
228    }
229
230    /// Create a commit with a message.
231    pub fn commit(&self, root: &Path, message: &str) -> Result<(), JoyError> {
232        git_run(root, &["commit", "--quiet", "-m", message])
233    }
234
235    /// Create an annotated tag with a message body.
236    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    /// Create a lightweight tag.
241    pub fn tag(&self, root: &Path, name: &str) -> Result<(), JoyError> {
242        git_run(root, &["tag", name])
243    }
244
245    /// Push the current branch to a remote.
246    pub fn push(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
247        git_run(root, &["push", "--quiet", remote])
248    }
249
250    /// Push a specific tag to a remote.
251    pub fn push_tag(&self, root: &Path, remote: &str, tag: &str) -> Result<(), JoyError> {
252        git_run(root, &["push", "--quiet", remote, tag])
253    }
254
255    /// Push current branch and tags in one call.
256    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    /// Get the default remote name (usually "origin").
262    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    /// Get the remote URL for a given remote name.
269    pub fn remote_url(&self, root: &Path, remote: &str) -> Result<String, JoyError> {
270        git_output(root, &["remote", "get-url", remote])
271    }
272
273    /// Check if the working tree is clean.
274    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    /// Check if HEAD is exactly on a tag.
280    pub fn head_is_tagged(&self, root: &Path) -> bool {
281        git_output(root, &["describe", "--tags", "--exact-match", "HEAD"]).is_ok()
282    }
283}
284
285// -- Forge detection --
286
287impl GitVcs {
288    /// Detect the hosting platform from the remote URL.
289    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
302/// Parse forge type from a git remote URL.
303pub 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
316// -- gh CLI check --
317
318/// Check if the GitHub CLI (gh) is installed and return its version.
319pub 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    // "gh version 2.87.3 (2026-02-24)" -> extract "2.87.3"
334    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
347/// Check if gh CLI is available (returns false if not installed).
348pub 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
357/// Create a GitHub release using the gh CLI.
358pub 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
378/// Default VCS provider. Returns the Git implementation.
379pub fn default_vcs() -> GitVcs {
380    GitVcs
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    #[ignore] // requires git user.email configured
389    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] // requires full clone with tags
404    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        // Should not error, just return true or false
489        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        // We're on GitHub
497        assert_eq!(forge, Forge::GitHub);
498    }
499}