Skip to main content

release_kit/
detect.rs

1//! Forge, repository, and technology detection.
2//!
3//! One pass reads `git remote get-url origin`: the path is the project, the
4//! host chooses the forge. An unrecognized host is never defaulted — a wrong
5//! guess runs protection calls against the wrong API and fails partway
6//! through a setup — so callers refuse and name the override flags instead.
7//! The technology is read from the version file, exactly as the bindings
8//! define it.
9
10use std::path::Path;
11use std::process::Command;
12
13/// A supported forge.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Forge {
16    /// github.com, driven through `gh`.
17    Github,
18    /// gitlab.com or a self-hosted GitLab, driven through `glab`.
19    Gitlab,
20}
21
22impl Forge {
23    /// The wire and directory name.
24    #[must_use]
25    pub const fn as_str(self) -> &'static str {
26        match self {
27            Self::Github => "github",
28            Self::Gitlab => "gitlab",
29        }
30    }
31
32    /// Parse a `--forge` value.
33    #[must_use]
34    pub fn parse(name: &str) -> Option<Self> {
35        match name {
36            "github" => Some(Self::Github),
37            "gitlab" => Some(Self::Gitlab),
38            _ => None,
39        }
40    }
41
42    /// The forge CLI this forge is driven through.
43    #[must_use]
44    pub const fn cli(self) -> &'static str {
45        match self {
46            Self::Github => "gh",
47            Self::Gitlab => "glab",
48        }
49    }
50
51    /// Every supported forge, in a stable order.
52    pub const ALL: [Self; 2] = [Self::Github, Self::Gitlab];
53}
54
55/// What one detection pass observed; every field is an observation, and
56/// refusing on what is absent is the caller's decision.
57#[derive(Debug, Default)]
58pub struct Detection {
59    /// The remote's host, where a remote exists and parses.
60    pub host: Option<String>,
61    /// The project path from the remote: no scheme, no `.git` suffix.
62    pub repo: Option<String>,
63    /// The forge the host maps to; `None` with a `host` present means the
64    /// host is unrecognized.
65    pub forge: Option<Forge>,
66}
67
68/// Read the `origin` remote of `dir` and map it, without judging.
69///
70/// The read answers for `dir` alone: the variables a running hook
71/// exports are scrubbed, so an inherited `GIT_DIR` cannot answer with
72/// another repository's remote.
73#[must_use]
74pub fn detect(dir: &Path) -> Detection {
75    let mut command = Command::new(crate::probes::git_bin());
76    for var in crate::maintenance::GIT_HOOK_VARS {
77        command.env_remove(var);
78    }
79    let out = command
80        .args(["-C"])
81        .arg(dir)
82        .args(["remote", "get-url", "origin"])
83        .output();
84    let url = match out {
85        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
86        _ => return Detection::default(),
87    };
88    let Some((host, path)) = split_remote(&url) else {
89        return Detection::default();
90    };
91    let forge = forge_for_host(&host);
92    Detection {
93        host: Some(host),
94        repo: Some(path),
95        forge,
96    }
97}
98
99/// The forge a host maps to. `gitlab.com` and hosts that name gitlab map to
100/// GitLab; a self-hosted instance on a host name that says nothing needs
101/// `--forge`.
102#[must_use]
103pub fn forge_for_host(host: &str) -> Option<Forge> {
104    if host == "github.com" {
105        return Some(Forge::Github);
106    }
107    if host == "gitlab.com" || host.starts_with("gitlab.") {
108        return Some(Forge::Gitlab);
109    }
110    None
111}
112
113/// Host and project path from a git remote URL, for the URL and `scp`-like
114/// forms. The path drops a leading slash and a `.git` suffix.
115#[must_use]
116pub fn split_remote(url: &str) -> Option<(String, String)> {
117    let (host, raw_path) = if let Some((_, rest)) = url.split_once("://") {
118        let (authority, path) = rest.split_once('/')?;
119        let host = authority
120            .rsplit_once('@')
121            .map_or(authority, |(_, host)| host);
122        let host = host.split(':').next()?;
123        (host.to_owned(), path.to_owned())
124    } else {
125        let (authority, path) = url.split_once(':')?;
126        let host = authority
127            .rsplit_once('@')
128            .map_or(authority, |(_, host)| host);
129        (host.to_owned(), path.to_owned())
130    };
131    let path = raw_path
132        .trim_start_matches('/')
133        .trim_end_matches('/')
134        .trim_end_matches(".git")
135        .to_owned();
136    (!host.is_empty() && !path.is_empty()).then_some((host, path))
137}
138
139/// The technology of a repository, read from its version file: `Cargo.toml`
140/// means rust, `pyproject.toml` means python, a `VERSION` file means bash.
141#[must_use]
142pub fn tech_of(dir: &Path) -> Option<&'static str> {
143    if dir.join("Cargo.toml").is_file() {
144        Some("rust")
145    } else if dir.join("pyproject.toml").is_file() {
146        Some("python")
147    } else if dir.join("VERSION").is_file() {
148        Some("bash")
149    } else {
150        None
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::{Forge, forge_for_host, split_remote};
157
158    #[test]
159    fn a_remote_splits_into_host_and_path_in_both_forms() {
160        assert_eq!(
161            split_remote("https://github.com/owner/name.git"),
162            Some(("github.com".into(), "owner/name".into()))
163        );
164        assert_eq!(
165            split_remote("git@gitlab.com:group/sub/name.git"),
166            Some(("gitlab.com".into(), "group/sub/name".into()))
167        );
168        assert_eq!(
169            split_remote("ssh://git@github.com:22/owner/name.git"),
170            Some(("github.com".into(), "owner/name".into()))
171        );
172        assert_eq!(split_remote("not a url"), None);
173    }
174
175    #[test]
176    fn a_host_maps_to_its_forge_and_an_unknown_host_to_none() {
177        assert_eq!(forge_for_host("github.com"), Some(Forge::Github));
178        assert_eq!(forge_for_host("gitlab.com"), Some(Forge::Gitlab));
179        assert_eq!(forge_for_host("gitlab.example.org"), Some(Forge::Gitlab));
180        assert_eq!(forge_for_host("codeberg.org"), None);
181    }
182}