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}