Skip to main content

lux_lib/git/
utils.rs

1use crate::{config::Config, fs, git::url::RemoteGitUrl};
2use git2::{AutotagOption, Cred, FetchOptions, RemoteCallbacks, Repository};
3use itertools::Itertools;
4use miette::Diagnostic;
5use thiserror::Error;
6
7#[derive(Debug, Error, Diagnostic)]
8pub enum GitError {
9    #[error(transparent)]
10    #[diagnostic(transparent)]
11    Fs(#[from] fs::FsError),
12    #[error("error initializing temporary bare git repository to fetch metadata")]
13    BareRepoInit(#[source] git2::Error),
14    #[error("error initializing remote repository '{0}' to fetch metadata")]
15    RemoteInit(String, #[source] git2::Error),
16    #[error("error fetching from remote repository '{0}'")]
17    RemoteFetch(String, #[source] git2::Error),
18    #[error("error listing remote refs for '{0}'")]
19    RemoteList(String, #[source] git2::Error),
20    #[error("could not determine latest tag or commit sha for '{0}'")]
21    NoTagOrCommitSha(String),
22}
23
24pub(crate) enum SemVerTagOrSha {
25    SemVerTag(String),
26    CommitSha(String),
27}
28
29#[tracing::instrument(level = "trace")]
30pub(crate) fn latest_semver_tag_or_commit_sha(
31    url: &RemoteGitUrl,
32    config: &Config,
33) -> Result<SemVerTagOrSha, GitError> {
34    match latest_semver_tag(url, config)? {
35        Some(tag) => Ok(SemVerTagOrSha::SemVerTag(tag)),
36        None => {
37            let sha = latest_commit_sha(url, config)?
38                .ok_or(GitError::NoTagOrCommitSha(url.to_string()))?;
39            Ok(SemVerTagOrSha::CommitSha(sha))
40        }
41    }
42}
43
44#[tracing::instrument(level = "trace")]
45fn latest_semver_tag(url: &RemoteGitUrl, config: &Config) -> Result<Option<String>, GitError> {
46    let temp_dir = fs::tempfile::tempdir()?;
47
48    let url_str = url.to_string();
49    let repo = Repository::init_bare(&temp_dir).map_err(GitError::BareRepoInit)?;
50    let mut remote = repo
51        .remote_anonymous(&url_str)
52        .map_err(|err| GitError::RemoteInit(url_str.clone(), err))?;
53    let mut callbacks = RemoteCallbacks::new();
54    callbacks.credentials(mk_credentials_callback(url, config));
55    let mut fetch_opts = FetchOptions::new();
56    fetch_opts.download_tags(AutotagOption::All);
57    fetch_opts.remote_callbacks(callbacks);
58    remote
59        .fetch(&[] as &[&str], Some(&mut fetch_opts), None)
60        .map_err(|err| GitError::RemoteFetch(url_str.clone(), err))?;
61    let refs = remote
62        .list()
63        .map_err(|err| GitError::RemoteList(url_str.clone(), err))?;
64    Ok(refs
65        .iter()
66        .filter_map(|head| {
67            let tag_name = head.name().strip_prefix("refs/tags/")?;
68            let version_str = tag_name.strip_prefix('v').unwrap_or(tag_name);
69            if let Ok(version) = semver::Version::parse(version_str) {
70                Some((tag_name.to_string(), version))
71            } else {
72                None
73            }
74        })
75        .sorted_by(|(_, a), (_, b)| b.cmp(a))
76        .map(|(version_str, _)| version_str)
77        .collect_vec()
78        .first()
79        .cloned())
80}
81
82fn mk_credentials_callback(
83    url: &RemoteGitUrl,
84    config: &Config,
85) -> impl FnMut(&str, Option<&str>, git2::CredentialType) -> Result<Cred, git2::Error> {
86    let access_token = config.access_token(url.host()).map(|token| {
87        (
88            token.username().to_string(),
89            unsafe { token.password() }.to_string(),
90        )
91    });
92    move |_url, username_from_url, _allowed_types| {
93        if let Some((username, password)) = &access_token {
94            Cred::userpass_plaintext(username, password)
95        } else {
96            Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
97        }
98    }
99}
100
101fn latest_commit_sha(url: &RemoteGitUrl, config: &Config) -> Result<Option<String>, GitError> {
102    let temp_dir = fs::tempfile::tempdir()?;
103    let url_str = url.to_string();
104    let repo = Repository::init_bare(&temp_dir).map_err(GitError::BareRepoInit)?;
105    let mut remote = repo
106        .remote_anonymous(&url_str)
107        .map_err(|err| GitError::RemoteInit(url_str.clone(), err))?;
108    let mut callbacks = RemoteCallbacks::new();
109    callbacks.credentials(mk_credentials_callback(url, config));
110    let mut fetch_opts = FetchOptions::new();
111    fetch_opts.remote_callbacks(callbacks);
112    remote
113        .fetch(&[] as &[&str], Some(&mut fetch_opts), None)
114        .map_err(|err| GitError::RemoteFetch(url_str.clone(), err))?;
115    let refs = remote
116        .list()
117        .map_err(|err| GitError::RemoteList(url_str.clone(), err))?;
118    Ok(refs.iter().find_map(|head| match head.name() {
119        "refs/heads/HEAD" => Some(head.oid().to_string()),
120        "refs/heads/main" => Some(head.oid().to_string()),
121        "refs/heads/master" => Some(head.oid().to_string()),
122        _ => None,
123    }))
124}
125
126#[cfg(test)]
127mod tests {
128
129    use super::*;
130    use crate::config::ConfigBuilder;
131
132    fn test_config() -> Config {
133        ConfigBuilder::new().unwrap().build().unwrap()
134    }
135
136    #[tokio::test]
137    async fn test_latest_semver_tag_http() {
138        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
139            println!("Skipping impure test");
140            return;
141        }
142        let url = "https://github.com/lumen-oss/lux.git".parse().unwrap();
143        assert!(latest_semver_tag(&url, &test_config()).unwrap().is_some());
144    }
145
146    #[tokio::test]
147    #[cfg(feature = "ssh-tests")]
148    async fn test_latest_semver_tag_ssh_user() {
149        let url = "git@github.com:lumen-oss/lux.git".parse().unwrap();
150        assert!(latest_semver_tag(&url, &test_config()).unwrap().is_some());
151    }
152
153    #[tokio::test]
154    #[cfg(feature = "ssh-tests")]
155    async fn test_latest_semver_tag_ssh_schema() {
156        let url = "ssh://github.com/lumen-oss/lux.git".parse().unwrap();
157        assert!(latest_semver_tag(&url, &test_config()).unwrap().is_some());
158    }
159
160    #[tokio::test]
161    async fn test_latest_commit_sha_http() {
162        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
163            println!("Skipping impure test");
164            return;
165        }
166        let url = "https://github.com/lumen-oss/lux.git".parse().unwrap();
167        assert!(latest_commit_sha(&url, &test_config()).unwrap().is_some());
168    }
169
170    #[tokio::test]
171    #[cfg(feature = "ssh-tests")]
172    async fn test_latest_commit_sha_ssh_user() {
173        let url = "git@github.com:lumen-oss/lux.git".parse().unwrap();
174        assert!(latest_commit_sha(&url, &test_config()).unwrap().is_some());
175    }
176
177    #[tokio::test]
178    #[cfg(feature = "ssh-tests")]
179    async fn test_latest_commit_sha_ssh_schema() {
180        let url = "ssh://github.com/lumen-oss/lux.git".parse().unwrap();
181        assert!(latest_commit_sha(&url, &test_config()).unwrap().is_some());
182    }
183}