1use crate::{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: {0}")]
13 BareRepoInit(git2::Error),
14 #[error("error initializing remote repository '{0}' to fetch metadata: {1}")]
15 RemoteInit(String, git2::Error),
16 #[error("error fetching from remote repository '{0}': {1}")]
17 RemoteFetch(String, git2::Error),
18 #[error("error listing remote refs for '{0}': {1}")]
19 RemoteList(String, 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) -> Result<SemVerTagOrSha, GitError> {
33 match latest_semver_tag(url)? {
34 Some(tag) => Ok(SemVerTagOrSha::SemVerTag(tag)),
35 None => {
36 let sha = latest_commit_sha(url)?.ok_or(GitError::NoTagOrCommitSha(url.to_string()))?;
37 Ok(SemVerTagOrSha::CommitSha(sha))
38 }
39 }
40}
41
42#[tracing::instrument(level = "trace")]
43fn latest_semver_tag(url: &RemoteGitUrl) -> Result<Option<String>, GitError> {
44 let temp_dir = fs::tempfile::tempdir()?;
45
46 let url_str = url.to_string();
47 let repo = Repository::init_bare(&temp_dir).map_err(GitError::BareRepoInit)?;
48 let mut remote = repo
49 .remote_anonymous(&url_str)
50 .map_err(|err| GitError::RemoteInit(url_str.clone(), err))?;
51 let mut callbacks = RemoteCallbacks::new();
52 callbacks.credentials(|_url, username_from_url, _allowed_types| {
53 Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
54 });
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 latest_commit_sha(url: &RemoteGitUrl) -> Result<Option<String>, GitError> {
83 let temp_dir = fs::tempfile::tempdir()?;
84 let url_str = url.to_string();
85 let repo = Repository::init_bare(&temp_dir).map_err(GitError::BareRepoInit)?;
86 let mut remote = repo
87 .remote_anonymous(&url_str)
88 .map_err(|err| GitError::RemoteInit(url_str.clone(), err))?;
89 let mut callbacks = RemoteCallbacks::new();
90 callbacks.credentials(|_url, username_from_url, _allowed_types| {
91 Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
92 });
93 let mut fetch_opts = FetchOptions::new();
94 fetch_opts.remote_callbacks(callbacks);
95 remote
96 .fetch(&[] as &[&str], Some(&mut fetch_opts), None)
97 .map_err(|err| GitError::RemoteFetch(url_str.clone(), err))?;
98 let refs = remote
99 .list()
100 .map_err(|err| GitError::RemoteList(url_str.clone(), err))?;
101 Ok(refs.iter().find_map(|head| match head.name() {
102 "refs/heads/HEAD" => Some(head.oid().to_string()),
103 "refs/heads/main" => Some(head.oid().to_string()),
104 "refs/heads/master" => Some(head.oid().to_string()),
105 _ => None,
106 }))
107}
108
109#[cfg(test)]
110mod tests {
111
112 use super::*;
113
114 #[tokio::test]
115 async fn test_latest_semver_tag_http() {
116 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
117 println!("Skipping impure test");
118 return;
119 }
120 let url = "https://github.com/lumen-oss/lux.git".parse().unwrap();
121 assert!(latest_semver_tag(&url).unwrap().is_some());
122 }
123
124 #[tokio::test]
125 #[cfg(feature = "ssh-tests")]
126 async fn test_latest_semver_tag_ssh_user() {
127 let url = "git@github.com:lumen-oss/lux.git".parse().unwrap();
128 assert!(latest_semver_tag(&url).unwrap().is_some());
129 }
130
131 #[tokio::test]
132 #[cfg(feature = "ssh-tests")]
133 async fn test_latest_semver_tag_ssh_schema() {
134 let url = "ssh://github.com/lumen-oss/lux.git".parse().unwrap();
135 assert!(latest_semver_tag(&url).unwrap().is_some());
136 }
137
138 #[tokio::test]
139 async fn test_latest_commit_sha_http() {
140 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
141 println!("Skipping impure test");
142 return;
143 }
144 let url = "https://github.com/lumen-oss/lux.git".parse().unwrap();
145 assert!(latest_commit_sha(&url).unwrap().is_some());
146 }
147
148 #[tokio::test]
149 #[cfg(feature = "ssh-tests")]
150 async fn test_latest_commit_sha_ssh_user() {
151 let url = "git@github.com:lumen-oss/lux.git".parse().unwrap();
152 assert!(latest_commit_sha(&url).unwrap().is_some());
153 }
154
155 #[tokio::test]
156 #[cfg(feature = "ssh-tests")]
157 async fn test_latest_commit_sha_ssh_schema() {
158 let url = "ssh://github.com/lumen-oss/lux.git".parse().unwrap();
159 assert!(latest_commit_sha(&url).unwrap().is_some());
160 }
161}