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