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    /// True if `path` matches one of the .gitignore patterns
226    /// (regardless of whether it is currently tracked).
227    ///
228    /// `git check-ignore --quiet <path>` exits 0 when the path is
229    /// ignored, 1 when it is not, anything else on error. Errors
230    /// are treated as "not ignored" so that genuine staging
231    /// attempts surface via the regular `add` error path rather
232    /// than getting swallowed here.
233    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    /// Stage all changes (git add -A).
247    pub fn add_all(&self, root: &Path) -> Result<(), JoyError> {
248        git_run(root, &["add", "-A"])
249    }
250
251    /// Create a commit with a message.
252    pub fn commit(&self, root: &Path, message: &str) -> Result<(), JoyError> {
253        git_run(root, &["commit", "--quiet", "-m", message])
254    }
255
256    /// Create an annotated tag with a message body.
257    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    /// Create a lightweight tag.
262    pub fn tag(&self, root: &Path, name: &str) -> Result<(), JoyError> {
263        git_run(root, &["tag", name])
264    }
265
266    /// Push the current branch to a remote.
267    pub fn push(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
268        git_run(root, &["push", "--quiet", remote])
269    }
270
271    /// Push a specific tag to a remote.
272    pub fn push_tag(&self, root: &Path, remote: &str, tag: &str) -> Result<(), JoyError> {
273        git_run(root, &["push", "--quiet", remote, tag])
274    }
275
276    /// Push current branch and tags in one call.
277    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    /// Get the default remote name (usually "origin").
283    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    /// Get the remote URL for a given remote name.
290    pub fn remote_url(&self, root: &Path, remote: &str) -> Result<String, JoyError> {
291        git_output(root, &["remote", "get-url", remote])
292    }
293
294    /// Check if the working tree is clean.
295    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    /// Check if HEAD is exactly on a tag.
301    pub fn head_is_tagged(&self, root: &Path) -> bool {
302        git_output(root, &["describe", "--tags", "--exact-match", "HEAD"]).is_ok()
303    }
304}
305
306// -- Forge detection --
307
308impl GitVcs {
309    /// Detect the hosting platform from the remote URL.
310    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
323/// Parse forge type from a git remote URL.
324pub 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
337// -- gh CLI check --
338
339/// Check if the GitHub CLI (gh) is installed and return its version.
340pub 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    // "gh version 2.87.3 (2026-02-24)" -> extract "2.87.3"
355    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
368/// Check if gh CLI is available (returns false if not installed).
369pub 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
378/// Create a GitHub release using the gh CLI.
379pub 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
399/// Default VCS provider. Returns the Git implementation.
400pub fn default_vcs() -> GitVcs {
401    GitVcs
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    #[ignore] // requires git user.email configured
410    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] // requires full clone with tags
425    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        // Should not error, just return true or false
510        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        // We're on GitHub
518        assert_eq!(forge, Forge::GitHub);
519    }
520}