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
24impl Forge {
25    /// Stable identifier as written in `project.yaml` and accepted by
26    /// `joy project set forge`. `Unknown` has no canonical form.
27    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
37/// VCS read operations that Joy needs.
38pub trait Vcs {
39    /// Check if the given directory is inside a VCS repository.
40    fn is_repo(&self, root: &Path) -> bool;
41
42    /// Initialize a new repository at the given path.
43    fn init_repo(&self, root: &Path) -> Result<(), JoyError>;
44
45    /// Get the current user's email from VCS config.
46    fn user_email(&self) -> Result<String, JoyError>;
47
48    /// List all version tags (e.g. v0.5.0), sorted descending.
49    fn version_tags(&self, root: &Path) -> Result<Vec<String>, JoyError>;
50
51    /// Get the latest reachable version tag, if any.
52    fn latest_version_tag(&self, root: &Path) -> Result<Option<String>, JoyError>;
53
54    /// Read a per-clone VCS-config value. The semantics are
55    /// Git-flavoured (`git config --local <key>`); other backends
56    /// implement on the closest equivalent (jj has `jj config`, pijul
57    /// `pijul config`).
58    fn config_get(&self, root: &Path, key: &str) -> Result<String, JoyError>;
59
60    /// Write a per-clone VCS-config value. Same semantics as
61    /// [`Vcs::config_get`].
62    fn config_set(&self, root: &Path, key: &str, value: &str) -> Result<(), JoyError>;
63}
64
65/// Git implementation of the VCS trait.
66pub struct GitVcs;
67
68/// Run a git command and return stdout as a trimmed string.
69/// Returns a descriptive error if git is not found or the command fails.
70fn 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
96/// Run a git command silently (ignore stdout/stderr), return Ok/Err.
97fn 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// -- Git config operations: now part of the Vcs trait. --
175
176// -- Git version check --
177
178/// Parsed git version.
179#[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    /// Get the installed git version. Returns error if git is not found.
189    pub fn version(&self) -> Result<GitVersion, JoyError> {
190        let raw = git_output(Path::new("."), &["--version"])?;
191        parse_git_version(&raw)
192    }
193
194    /// Check that git meets the minimum version requirement.
195    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    // "git version 2.43.0" or "git version 2.43.0.windows.1"
210    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
228// -- Git write operations --
229
230impl GitVcs {
231    /// Stage files for commit.
232    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    /// True if `path` matches one of the .gitignore patterns
239    /// (regardless of whether it is currently tracked).
240    ///
241    /// `git check-ignore --quiet <path>` exits 0 when the path is
242    /// ignored, 1 when it is not, anything else on error. Errors
243    /// are treated as "not ignored" so that genuine staging
244    /// attempts surface via the regular `add` error path rather
245    /// than getting swallowed here.
246    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    /// Stage all changes (git add -A).
260    pub fn add_all(&self, root: &Path) -> Result<(), JoyError> {
261        git_run(root, &["add", "-A"])
262    }
263
264    /// Create a commit with a message.
265    pub fn commit(&self, root: &Path, message: &str) -> Result<(), JoyError> {
266        git_run(root, &["commit", "--quiet", "-m", message])
267    }
268
269    /// Create an annotated tag with a message body.
270    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    /// Create a lightweight tag.
275    pub fn tag(&self, root: &Path, name: &str) -> Result<(), JoyError> {
276        git_run(root, &["tag", name])
277    }
278
279    /// Push the current branch to a remote.
280    pub fn push(&self, root: &Path, remote: &str) -> Result<(), JoyError> {
281        git_run(root, &["push", "--quiet", remote])
282    }
283
284    /// Push a specific tag to a remote.
285    pub fn push_tag(&self, root: &Path, remote: &str, tag: &str) -> Result<(), JoyError> {
286        git_run(root, &["push", "--quiet", remote, tag])
287    }
288
289    /// Push current branch and tags in one call.
290    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    /// Get the default remote name (usually "origin").
296    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    /// Get the remote URL for a given remote name.
303    pub fn remote_url(&self, root: &Path, remote: &str) -> Result<String, JoyError> {
304        git_output(root, &["remote", "get-url", remote])
305    }
306
307    /// List every configured remote as `(name, url)` pairs, in the
308    /// order `git remote` returns them. Empty when the repo has no
309    /// remotes configured.
310    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    /// Check if the working tree is clean.
328    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    /// Check if HEAD is exactly on a tag.
334    pub fn head_is_tagged(&self, root: &Path) -> bool {
335        git_output(root, &["describe", "--tags", "--exact-match", "HEAD"]).is_ok()
336    }
337}
338
339// -- Forge detection --
340
341impl GitVcs {
342    /// Detect the hosting platform from the remote URL.
343    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    /// Detect the forge type for every configured remote. Each entry
356    /// is `(remote_name, forge)`. Remotes with no recognisable forge
357    /// are returned as `Forge::Unknown` so callers can report what was
358    /// configured.
359    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
368/// Parse forge type from a git remote URL.
369pub 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
382// -- gh CLI check --
383
384/// Check if the GitHub CLI (gh) is installed and return its version.
385pub 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    // "gh version 2.87.3 (2026-02-24)" -> extract "2.87.3"
400    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
413/// Check if gh CLI is available (returns false if not installed).
414pub 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
423/// Create a GitHub release using the gh CLI.
424pub 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
444/// Default VCS provider. Returns the Git implementation.
445pub fn default_vcs() -> GitVcs {
446    GitVcs
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    #[ignore] // requires git user.email configured
455    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] // requires full clone with tags
470    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        // Should not error, just return true or false
555        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        // We're on GitHub
563        assert_eq!(forge, Forge::GitHub);
564    }
565}