Skip to main content

pop_common/
git.rs

1// SPDX-License-Identifier: GPL-3.0
2
3use crate::{api::ApiClient, errors::Error, polkadot_sdk::parse_latest_tag};
4use anyhow::Result;
5use git2::{
6	FetchOptions, IndexAddOption, RemoteCallbacks, Repository as GitRepository, ResetType,
7	build::RepoBuilder,
8};
9use git2_credentials::CredentialHandler;
10use std::{fs, path::Path, sync::LazyLock};
11use url::Url;
12
13/// A helper for handling Git operations.
14pub struct Git;
15impl Git {
16	/// Clone a Git repository.
17	///
18	/// # Arguments
19	/// * `url` - the URL of the repository to clone.
20	/// * `working_dir` - the target working directory.
21	/// * `reference` - an optional reference (revision/tag).
22	pub fn clone(url: &Url, working_dir: &Path, reference: Option<&str>) -> Result<()> {
23		let mut fo = FetchOptions::new();
24		if reference.is_none() {
25			fo.depth(1);
26		}
27		let mut repo = RepoBuilder::new();
28		repo.fetch_options(fo);
29		let repo = match repo.clone(url.as_str(), working_dir) {
30			Ok(repository) => repository,
31			Err(e) => match Self::ssh_clone(url, working_dir) {
32				Ok(repository) => repository,
33				Err(_) => return Err(e.into()),
34			},
35		};
36
37		if let Some(reference) = reference {
38			let object = repo
39				.revparse_single(reference)
40				.or_else(|_| repo.revparse_single(&format!("refs/tags/{}", reference)))
41				.or_else(|_| repo.revparse_single(&format!("refs/remotes/origin/{}", reference)))?;
42			repo.checkout_tree(&object, None)?;
43			repo.set_head_detached(object.id())?;
44		}
45		Ok(())
46	}
47
48	fn ssh_clone(url: &Url, working_dir: &Path) -> Result<GitRepository> {
49		let ssh_url = GitHub::convert_to_ssh_url(url);
50		// Prepare callback and fetch options.
51		let mut fo = FetchOptions::new();
52		Self::set_up_ssh_fetch_options(&mut fo)?;
53		// Prepare builder and clone.
54		let mut repo = RepoBuilder::new();
55		repo.fetch_options(fo);
56		Ok(repo.clone(&ssh_url, working_dir)?)
57	}
58
59	/// Clone a Git repository and degit it.
60	///
61	/// # Arguments
62	///
63	/// * `url` - the URL of the repository to clone.
64	/// * `target` - location where the repository will be cloned.
65	/// * `tag_version` - the specific tag or version of the repository to use
66	pub fn clone_and_degit(
67		url: &str,
68		target: &Path,
69		tag_version: Option<String>,
70	) -> Result<Option<String>> {
71		let repo = match GitRepository::clone(url, target) {
72			Ok(repo) => repo,
73			Err(_e) => Self::ssh_clone_and_degit(Url::parse(url).map_err(Error::from)?, target)?,
74		};
75
76		if let Some(tag_version) = tag_version {
77			let (object, reference) = repo.revparse_ext(&tag_version).expect("Object not found");
78			repo.checkout_tree(&object, None).expect("Failed to checkout");
79			match reference {
80				// gref is an actual reference like branches or tags
81				Some(gref) => repo.set_head(gref.name().unwrap()),
82				// this is a commit, not a reference
83				None => repo.set_head_detached(object.id()),
84			}
85			.expect("Failed to set HEAD");
86
87			let git_dir = repo.path();
88			fs::remove_dir_all(git_dir)?;
89			return Ok(Some(tag_version));
90		}
91
92		// fetch tags from remote
93		let release = Self::fetch_latest_tag(&repo);
94
95		let git_dir = repo.path();
96		fs::remove_dir_all(git_dir)?;
97		// Or by default the last one
98		Ok(release)
99	}
100
101	/// For users that have ssh configuration for cloning repositories.
102	fn ssh_clone_and_degit(url: Url, target: &Path) -> Result<GitRepository> {
103		let ssh_url = GitHub::convert_to_ssh_url(&url);
104		// Prepare callback and fetch options.
105		let mut fo = FetchOptions::new();
106		Self::set_up_ssh_fetch_options(&mut fo)?;
107		// Prepare builder and clone.
108		let mut builder = RepoBuilder::new();
109		builder.fetch_options(fo);
110		let repo = builder.clone(&ssh_url, target)?;
111		Ok(repo)
112	}
113
114	fn set_up_ssh_fetch_options(fo: &mut FetchOptions) -> Result<()> {
115		let mut callbacks = RemoteCallbacks::new();
116		let git_config = git2::Config::open_default()
117			.map_err(|e| Error::Config(format!("Cannot open git configuration: {}", e)))?;
118		let mut ch = CredentialHandler::new(git_config);
119		callbacks.credentials(move |url, username, allowed| {
120			ch.try_next_credential(url, username, allowed)
121		});
122
123		fo.remote_callbacks(callbacks);
124		Ok(())
125	}
126
127	/// Fetch the latest release from a repository
128	fn fetch_latest_tag(repo: &GitRepository) -> Option<String> {
129		let tags = repo.tag_names(None).ok()?;
130		parse_latest_tag(&tags.iter().flatten().collect::<Vec<_>>()).map(|t| t.to_string())
131	}
132
133	/// Creates an empty git repository at the specified location.
134	///
135	/// # Arguments
136	///
137	/// * `target` - The path where the empty git repository will be initialized.
138	pub fn git_create_empty_repository(target: &Path) -> Result<(), git2::Error> {
139		GitRepository::init(target)?;
140		Ok(())
141	}
142
143	/// Init a new git repository.
144	///
145	/// # Arguments
146	///
147	/// * `target` - location where the parachain will be created.
148	/// * `message` - message for first commit.
149	pub fn git_init(target: &Path, message: &str) -> Result<(), git2::Error> {
150		let repo = GitRepository::init(target)?;
151		let signature = repo.signature()?;
152
153		let mut index = repo.index()?;
154		index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
155		let tree_id = index.write_tree()?;
156
157		let tree = repo.find_tree(tree_id)?;
158		let commit_id = repo.commit(Some("HEAD"), &signature, &signature, message, &tree, &[])?;
159
160		let commit_object = repo.find_object(commit_id, Some(git2::ObjectType::Commit))?;
161		repo.reset(&commit_object, ResetType::Hard, None)?;
162
163		Ok(())
164	}
165}
166
167/// A client for the GitHub REST API.
168pub(crate) static GITHUB_API_CLIENT: LazyLock<ApiClient> = LazyLock::new(|| {
169	// GitHub API: unauthenticated = 60 requests per hour, authenticated = 5,000 requests per hour,
170	// GitHub Actions = 1,000 requests per hour per repository
171	ApiClient::new(1, std::env::var("GITHUB_TOKEN").ok())
172});
173
174/// A helper for handling GitHub operations.
175pub struct GitHub {
176	/// The organization name.
177	pub org: String,
178	/// The repository name
179	pub name: String,
180	api: String,
181}
182
183impl GitHub {
184	const GITHUB: &'static str = "github.com";
185
186	/// Parse URL of a GitHub repository.
187	///
188	/// # Arguments
189	///
190	/// * `url` - the URL of the repository to clone.
191	pub fn parse(url: &str) -> Result<Self> {
192		let url = Url::parse(url)?;
193		Ok(Self::new(Self::org(&url)?, Self::name(&url)?))
194	}
195
196	/// Create a new [GitHub] instance.
197	///
198	/// # Arguments
199	/// * `org` - The organization name.
200	/// * `name` - The repository name.
201	pub(crate) fn new(org: impl Into<String>, name: impl Into<String>) -> Self {
202		Self { org: org.into(), name: name.into(), api: "https://api.github.com".into() }
203	}
204
205	/// Overrides the api base URL.
206	pub fn with_api(mut self, api: impl Into<String>) -> Self {
207		self.api = api.into();
208		self
209	}
210
211	/// Fetches the latest release of the GitHub repository.
212	pub async fn latest_release(&self) -> Result<Release> {
213		let url = self.api_latest_release_url();
214		let response = GITHUB_API_CLIENT.get(url).await?;
215		let release = response.json().await?;
216		Ok(release)
217	}
218
219	/// Fetch the latest releases of the GitHub repository.
220	///
221	/// # Arguments
222	/// * `prerelease` - Whether to include prereleases.
223	pub async fn releases(&self, prerelease: bool) -> Result<Vec<Release>> {
224		let url = self.api_releases_url();
225		let response = GITHUB_API_CLIENT.get(url).await?;
226		let mut releases = response.json::<Vec<Release>>().await?;
227		releases.retain(|r| prerelease || !r.prerelease);
228		// Sort releases by `published_at` in descending order
229		releases.sort_by(|a, b| b.published_at.cmp(&a.published_at));
230		Ok(releases)
231	}
232
233	/// Retrieves the commit hash associated with a specified tag in a GitHub repository.
234	pub async fn get_commit_sha_from_release(&self, tag_name: &str) -> Result<String> {
235		let response = GITHUB_API_CLIENT.get(self.api_tag_information(tag_name)).await?;
236		let value = response.json::<serde_json::Value>().await?;
237		let commit = value
238			.get("object")
239			.and_then(|v| v.get("sha"))
240			.and_then(|v| v.as_str())
241			.map(|v| v.to_owned())
242			.ok_or(Error::Git("the github release tag sha was not found".to_string()))?;
243		Ok(commit)
244	}
245
246	/// Retrieves the license from the repository.
247	pub async fn get_repo_license(&self) -> Result<String> {
248		let url = self.api_license_url();
249		let response = GITHUB_API_CLIENT.get(url).await?;
250		let value = response.json::<serde_json::Value>().await?;
251		let license = value
252			.get("license")
253			.and_then(|v| v.get("spdx_id"))
254			.and_then(|v| v.as_str())
255			.map(|v| v.to_owned())
256			.ok_or(Error::Git("Unable to find license for GitHub repo".to_string()))?;
257		Ok(license)
258	}
259
260	fn api_latest_release_url(&self) -> String {
261		format!("{}/repos/{}/{}/releases/latest", self.api, self.org, self.name)
262	}
263
264	fn api_releases_url(&self) -> String {
265		format!("{}/repos/{}/{}/releases", self.api, self.org, self.name)
266	}
267
268	fn api_tag_information(&self, tag_name: &str) -> String {
269		format!("{}/repos/{}/{}/git/ref/tags/{}", self.api, self.org, self.name, tag_name)
270	}
271
272	fn api_license_url(&self) -> String {
273		format!("{}/repos/{}/{}/license", self.api, self.org, self.name)
274	}
275
276	fn org(repo: &Url) -> Result<&str> {
277		let path_segments = repo
278			.path_segments()
279			.map(|c| c.collect::<Vec<_>>())
280			.expect("repository must have path segments");
281		Ok(path_segments.first().ok_or(Error::Git(
282			"the organization (or user) is missing from the github url".to_string(),
283		))?)
284	}
285
286	/// Determines the name of a repository from a URL.
287	///
288	/// # Arguments
289	/// * `repo` - the URL of the repository.
290	pub fn name(repo: &Url) -> Result<&str> {
291		let path_segments = repo
292			.path_segments()
293			.map(|c| c.collect::<Vec<_>>())
294			.expect("repository must have path segments");
295		Ok(path_segments
296			.get(1)
297			.ok_or(Error::Git("the repository name is missing from the github url".to_string()))?)
298	}
299
300	#[cfg(test)]
301	pub(crate) fn release(repo: &Url, tag: &str, artifact: &str) -> String {
302		format!("{}/releases/download/{tag}/{artifact}", repo.as_str())
303	}
304
305	pub(crate) fn convert_to_ssh_url(url: &Url) -> String {
306		format!("git@{}:{}.git", url.host_str().unwrap_or(Self::GITHUB), &url.path()[1..])
307	}
308}
309
310/// Represents the data of a GitHub release.
311#[derive(Debug, PartialEq, serde::Deserialize)]
312pub struct Release {
313	/// The name of the tag.
314	pub tag_name: String,
315	/// The name of the release.
316	pub name: String,
317	/// Whether to identify the release as a prerelease or a full release.
318	pub prerelease: bool,
319	/// The commit hash for the release.
320	pub commit: Option<String>,
321	/// When the release was published.
322	pub published_at: String,
323}
324
325/// A descriptor of a remote repository.
326#[derive(Debug, PartialEq)]
327pub struct Repository {
328	/// The url of the repository.
329	pub url: Url,
330	/// If applicable, the branch or tag to be used.
331	pub reference: Option<String>,
332	/// The name of a package within the repository. Defaults to the repository name.
333	pub package: String,
334}
335
336impl Repository {
337	/// Parses a url in the form of <https://github.com/org/repository?package#tag> into its component parts.
338	///
339	/// # Arguments
340	/// * `url` - The url to be parsed.
341	pub fn parse(url: &str) -> Result<Self, Error> {
342		let url = Url::parse(url)?;
343		let package = url.query();
344		let reference = url.fragment().map(|f| f.to_string());
345
346		let mut url = url.clone();
347		url.set_query(None);
348		url.set_fragment(None);
349
350		let package = match package {
351			Some(b) => b,
352			None => GitHub::name(&url)?,
353		}
354		.to_string();
355
356		Ok(Self { url, reference, package })
357	}
358}
359
360#[cfg(test)]
361mod tests {
362	use super::*;
363	use mockito::{Mock, Server};
364
365	const BASE_PARACHAIN: &str = "https://github.com/r0gue-io/base-parachain";
366	const POLKADOT_SDK: &str = "https://github.com/paritytech/polkadot-sdk";
367
368	async fn latest_release_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock {
369		mock_server
370			.mock("GET", format!("/repos/{}/{}/releases/latest", repo.org, repo.name).as_str())
371			.with_status(200)
372			.with_header("content-type", "application/json")
373			.with_body(payload)
374			.create_async()
375			.await
376	}
377
378	async fn releases_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock {
379		mock_server
380			.mock("GET", format!("/repos/{}/{}/releases", repo.org, repo.name).as_str())
381			.with_status(200)
382			.with_header("content-type", "application/json")
383			.with_body(payload)
384			.create_async()
385			.await
386	}
387
388	async fn tag_mock(mock_server: &mut Server, repo: &GitHub, tag: &str, payload: &str) -> Mock {
389		mock_server
390			.mock("GET", format!("/repos/{}/{}/git/ref/tags/{tag}", repo.org, repo.name).as_str())
391			.with_status(200)
392			.with_header("content-type", "application/json")
393			.with_body(payload)
394			.create_async()
395			.await
396	}
397
398	async fn license_mock(mock_server: &mut Server, repo: &GitHub, payload: &str) -> Mock {
399		mock_server
400			.mock("GET", format!("/repos/{}/{}/license", repo.org, repo.name).as_str())
401			.with_status(200)
402			.with_header("content-type", "application/json")
403			.with_body(payload)
404			.create_async()
405			.await
406	}
407
408	#[tokio::test]
409	async fn test_get_latest_releases() -> Result<(), Box<dyn std::error::Error>> {
410		let mut mock_server = Server::new_async().await;
411
412		let expected_payload = r#"[{
413			"tag_name": "polkadot-v1.10.0",
414			"name": "Polkadot v1.10.0",
415			"prerelease": false,
416			"published_at": "2024-01-01T00:00:00Z"
417		  },
418		  {
419			"tag_name": "polkadot-v1.11.0",
420			"name": "Polkadot v1.11.0",
421			"prerelease": false,
422			"published_at": "2023-01-01T00:00:00Z"
423		  },
424		  {
425			"tag_name": "polkadot-v1.12.0",
426			"name": "Polkadot v1.12.0",
427			"prerelease": false,
428			"published_at": "2025-01-01T00:00:00Z"
429		  }
430		]"#;
431		let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
432		let mock = releases_mock(&mut mock_server, &repo, expected_payload).await;
433		let latest_release = repo.releases(false).await?;
434		assert_eq!(
435			latest_release,
436			vec![
437				Release {
438					tag_name: "polkadot-v1.12.0".to_string(),
439					name: "Polkadot v1.12.0".into(),
440					prerelease: false,
441					commit: None,
442					published_at: "2025-01-01T00:00:00Z".to_string()
443				},
444				Release {
445					tag_name: "polkadot-v1.10.0".to_string(),
446					name: "Polkadot v1.10.0".into(),
447					prerelease: false,
448					commit: None,
449					published_at: "2024-01-01T00:00:00Z".to_string()
450				},
451				Release {
452					tag_name: "polkadot-v1.11.0".to_string(),
453					name: "Polkadot v1.11.0".into(),
454					prerelease: false,
455					commit: None,
456					published_at: "2023-01-01T00:00:00Z".to_string()
457				}
458			]
459		);
460		mock.assert_async().await;
461		Ok(())
462	}
463
464	#[tokio::test]
465	async fn test_get_latest_release() -> Result<(), Box<dyn std::error::Error>> {
466		let mut mock_server = Server::new_async().await;
467
468		let expected_payload = r#"{
469			"tag_name": "polkadot-v1.12.0",
470			"name": "Polkadot v1.12.0",
471			"prerelease": false,
472			"published_at": "2025-01-01T00:00:00Z"
473		  }"#;
474		let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
475		let mock = latest_release_mock(&mut mock_server, &repo, expected_payload).await;
476		let latest_release = repo.latest_release().await?;
477		assert_eq!(
478			latest_release,
479			Release {
480				tag_name: "polkadot-v1.12.0".to_string(),
481				name: "Polkadot v1.12.0".into(),
482				prerelease: false,
483				commit: None,
484				published_at: "2025-01-01T00:00:00Z".to_string()
485			}
486		);
487		mock.assert_async().await;
488		Ok(())
489	}
490
491	#[tokio::test]
492	async fn get_releases_with_commit_sha() -> Result<(), Box<dyn std::error::Error>> {
493		let mut mock_server = Server::new_async().await;
494
495		let expected_payload = r#"{
496			"ref": "refs/tags/polkadot-v1.11.0",
497			"node_id": "REF_kwDOKDT1SrpyZWZzL3RhZ3MvcG9sa2Fkb3QtdjEuMTEuMA",
498			"url": "https://api.github.com/repos/paritytech/polkadot-sdk/git/refs/tags/polkadot-v1.11.0",
499			"object": {
500				"sha": "0bb6249268c0b77d2834640b84cb52fdd3d7e860",
501				"type": "commit",
502				"url": "https://api.github.com/repos/paritytech/polkadot-sdk/git/commits/0bb6249268c0b77d2834640b84cb52fdd3d7e860"
503			}
504		  }"#;
505		let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
506		let mock = tag_mock(&mut mock_server, &repo, "polkadot-v1.11.0", expected_payload).await;
507		let hash = repo.get_commit_sha_from_release("polkadot-v1.11.0").await?;
508		assert_eq!(hash, "0bb6249268c0b77d2834640b84cb52fdd3d7e860");
509		mock.assert_async().await;
510		Ok(())
511	}
512
513	#[tokio::test]
514	async fn get_repo_license() -> Result<(), Box<dyn std::error::Error>> {
515		let mut mock_server = Server::new_async().await;
516
517		let expected_payload = r#"{
518			"license": {
519			"key":"unlicense",
520			"name":"The Unlicense",
521			"spdx_id":"Unlicense",
522			"url":"https://api.github.com/licenses/unlicense",
523			"node_id":"MDc6TGljZW5zZTE1"
524			}
525		}"#;
526		let repo = GitHub::parse(BASE_PARACHAIN)?.with_api(mock_server.url());
527		let mock = license_mock(&mut mock_server, &repo, expected_payload).await;
528		let license = repo.get_repo_license().await?;
529		assert_eq!(license, "Unlicense".to_string());
530		mock.assert_async().await;
531		Ok(())
532	}
533
534	#[test]
535	fn test_get_releases_api_url() -> Result<(), Box<dyn std::error::Error>> {
536		assert_eq!(
537			GitHub::parse(POLKADOT_SDK)?.api_releases_url(),
538			"https://api.github.com/repos/paritytech/polkadot-sdk/releases"
539		);
540		Ok(())
541	}
542
543	#[test]
544	fn test_get_latest_release_api_url() -> Result<(), Box<dyn std::error::Error>> {
545		assert_eq!(
546			GitHub::parse(POLKADOT_SDK)?.api_latest_release_url(),
547			"https://api.github.com/repos/paritytech/polkadot-sdk/releases/latest"
548		);
549		Ok(())
550	}
551
552	#[test]
553	fn test_url_api_tag_information() -> Result<(), Box<dyn std::error::Error>> {
554		assert_eq!(
555			GitHub::parse(POLKADOT_SDK)?.api_tag_information("polkadot-v1.11.0"),
556			"https://api.github.com/repos/paritytech/polkadot-sdk/git/ref/tags/polkadot-v1.11.0"
557		);
558		Ok(())
559	}
560
561	#[test]
562	fn test_api_license_url() -> Result<(), Box<dyn std::error::Error>> {
563		assert_eq!(
564			GitHub::parse(POLKADOT_SDK)?.api_license_url(),
565			"https://api.github.com/repos/paritytech/polkadot-sdk/license"
566		);
567		Ok(())
568	}
569
570	#[test]
571	fn test_parse_org() -> Result<(), Box<dyn std::error::Error>> {
572		assert_eq!(GitHub::parse(BASE_PARACHAIN)?.org, "r0gue-io");
573		Ok(())
574	}
575
576	#[test]
577	fn test_parse_name() -> Result<(), Box<dyn std::error::Error>> {
578		let url = Url::parse(BASE_PARACHAIN)?;
579		let name = GitHub::name(&url)?;
580		assert_eq!(name, "base-parachain");
581		Ok(())
582	}
583
584	#[test]
585	fn test_release_url() -> Result<(), Box<dyn std::error::Error>> {
586		let repo = Url::parse(POLKADOT_SDK)?;
587		let url = GitHub::release(&repo, "polkadot-v1.9.0", "polkadot");
588		assert_eq!(url, format!("{}/releases/download/polkadot-v1.9.0/polkadot", POLKADOT_SDK));
589		Ok(())
590	}
591
592	#[test]
593	fn test_convert_to_ssh_url() {
594		assert_eq!(
595			GitHub::convert_to_ssh_url(&Url::parse(BASE_PARACHAIN).expect("valid repository url")),
596			"git@github.com:r0gue-io/base-parachain.git"
597		);
598		assert_eq!(
599			GitHub::convert_to_ssh_url(
600				&Url::parse("https://github.com/paritytech/substrate-contracts-node")
601					.expect("valid repository url")
602			),
603			"git@github.com:paritytech/substrate-contracts-node.git"
604		);
605		assert_eq!(
606			GitHub::convert_to_ssh_url(
607				&Url::parse("https://github.com/paritytech/frontier-parachain-template")
608					.expect("valid repository url")
609			),
610			"git@github.com:paritytech/frontier-parachain-template.git"
611		);
612	}
613
614	mod repository {
615		use super::Error;
616		use crate::git::Repository;
617		use url::Url;
618
619		#[test]
620		fn parsing_full_url_works() {
621			assert_eq!(
622				Repository::parse("https://github.com/org/repository?package#tag").unwrap(),
623				Repository {
624					url: Url::parse("https://github.com/org/repository").unwrap(),
625					reference: Some("tag".into()),
626					package: "package".into(),
627				}
628			);
629		}
630
631		#[test]
632		fn parsing_simple_url_works() {
633			let url = "https://github.com/org/repository";
634			assert_eq!(
635				Repository::parse(url).unwrap(),
636				Repository {
637					url: Url::parse(url).unwrap(),
638					reference: None,
639					package: "repository".into(),
640				}
641			);
642		}
643
644		#[test]
645		fn parsing_invalid_url_returns_error() {
646			assert!(matches!(
647				Repository::parse("github.com/org/repository"),
648				Err(Error::ParseError(..))
649			));
650		}
651	}
652}