diff --git a/rust/rhgitaly/src/gitlab/mod.rs b/rust/rhgitaly/src/gitlab/mod.rs index 725e8c524ffaa9ce06e4c8d8a10c86787b929352_cnVzdC9yaGdpdGFseS9zcmMvZ2l0bGFiL21vZC5ycw==..5ac1a84995048d5304b2c909e73f0e68fecc03b4_cnVzdC9yaGdpdGFseS9zcmMvZ2l0bGFiL21vZC5ycw== 100644 --- a/rust/rhgitaly/src/gitlab/mod.rs +++ b/rust/rhgitaly/src/gitlab/mod.rs @@ -3,4 +3,5 @@ // This software may be used and distributed according to the terms of the // GNU General Public License version 2 or any later version. // SPDX-License-Identifier: GPL-2.0-or-later +pub mod revision; pub mod state; diff --git a/rust/rhgitaly/src/gitlab/revision.rs b/rust/rhgitaly/src/gitlab/revision.rs new file mode 100644 index 0000000000000000000000000000000000000000..5ac1a84995048d5304b2c909e73f0e68fecc03b4_cnVzdC9yaGdpdGFseS9zcmMvZ2l0bGFiL3JldmlzaW9uLnJz --- /dev/null +++ b/rust/rhgitaly/src/gitlab/revision.rs @@ -0,0 +1,198 @@ +// Copyright 2023 Georges Racinet <georges.racinet@octobus.net> +// +// This software may be used and distributed according to the terms of the +// GNU General Public License version 2 or any later version. +// SPDX-License-Identifier: GPL-2.0-or-later + +//! High level support for GitLab revisions. +//! +//! This matches roughly the `hgitaly.revision` Python module. +//! +//! There is no precise definition of revision, it is merely what Gitaly clients +//! can pass legitimately in the various `revision` fields of requests. Often, it is just +//! the subset of of `git log` would accept and treat as a single revision, e.g., by default +//! `main` would be tried first as a tag name, then as a branch name. +//! +//! Refs given in their full path from, e.g., as `refs/heads/tags/v1.2` are valid revisions, hence +//! the hlpers to handle them are in particular provided by this module. +use std::path::Path; + +use super::state::{ + has_keep_around, map_lookup_typed_ref, stream_gitlab_branches, stream_gitlab_special_refs, + stream_gitlab_tags, StateFileError, TypedRef, +}; + +#[derive(Debug, derive_more::From)] +pub enum RefError { + /// Returned to indicate that a full ref path does not start with `refs/` + NotAFullRef, + /// Returned to indicate that a given full path starts like a typed ref, yet ends + /// without the "name" part, as does e.g., `refs/heads` + MissingRefName, + /// Returned when a ref is not found for the given repository. + NotFound, + #[from] + /// Errors reading and parsing GitLab state files + GitLabStateFileError(StateFileError), +} + +/// From GitLab state files in `store_vfs`, resolve a ref and convert using the given closures +/// +/// Parameter `ref_path` must be the full ref path, i.e., starting with `refs/` +/// Parameter `map_ka` is used if the ref is found to be a keep-around +/// Parameter `map_ref` is used in other cases +pub async fn map_full_ref<R, MK, MR>( + store_vfs: &Path, + ref_path: &[u8], + map_ref: MR, + map_ka: MK, +) -> Result<R, RefError> +where + MK: FnOnce(&[u8]) -> R, + MR: FnOnce(TypedRef) -> R + Copy, +{ + let mut split = ref_path.splitn(3, |c| *c == b'/'); + if split.next() != Some(b"refs") { + return Err(RefError::NotAFullRef); + } + + let ref_type = split + .next() // `None` happends when `ref_path` is just `"refs"` + .ok_or(RefError::NotAFullRef)?; + + if ref_type == b"keep-around" { + let ka = split.next().ok_or(RefError::NotAFullRef)?; + if has_keep_around(store_vfs, ka).await? { + return Ok(map_ka(ka)); + } + } + + let (stream, name_is_next) = match ref_type { + b"heads" => (stream_gitlab_branches(store_vfs).await?, true), + b"tags" => (stream_gitlab_tags(store_vfs).await?, true), + _ => (stream_gitlab_special_refs(store_vfs).await?, false), + }; + + let wanted = if name_is_next { + split.next().ok_or(RefError::MissingRefName)? + } else { + &ref_path[5..] + }; + + map_lookup_typed_ref(stream, wanted, map_ref) + .await? + .ok_or(RefError::NotFound) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use tokio::fs::File; + + use tokio::io::AsyncWriteExt; + + async fn write_test_file( + // TODO deduplicate with gitlab::state::tests + store_vfs: &Path, + name: &str, + content: &[u8], + ) -> Result<(), StateFileError> { + Ok(File::create(store_vfs.join(name)) + .await? + .write_all(content) + .await?) + } + + /// A simple wrapper that returns the hash for a full ref name + /// + /// One of the goals is the error treatment to map [`RefError::NotFound`] to `None` and + /// hence allow the testing code to use the question mark operator + async fn full_ref_hash(store_vfs: &Path, full_ref: &[u8]) -> Result<Option<Vec<u8>>, RefError> { + match map_full_ref(store_vfs, full_ref, |tr| tr.target_sha, |ka| ka.to_vec()).await { + Err(RefError::NotFound) => Ok(None), + Ok(res) => Ok(Some(res)), + Err(e) => Err(e), + } + } + + #[tokio::test] + async fn test_map_full_ref() -> Result<(), RefError> { + let tmp_dir = tempdir().unwrap(); // not async, but it doesn't matter much in tests + let store_vfs = tmp_dir.path(); + + write_test_file( + store_vfs, + "gitlab.branches", + b"001\n437bd1bf68ac037eb6956490444e2d7f9a5655c9 branch/default\n", + ) + .await?; + write_test_file( + store_vfs, + "gitlab.tags", + b"001\nb50274b9b1c58fc97c45357a7c901d39bafc264d v6\n", + ) + .await?; + write_test_file( + store_vfs, + "gitlab.special-refs", + b"001\nf61a76dc97fb1a58f30e1b74245b957bb8c8d609 merge-requests/35/train\n", + ) + .await?; + write_test_file( + store_vfs, + "gitlab.keep-arounds", + b"001\n9787c1a7b9390e3f09babce1506254eb698dfba3\n", + ) + .await?; + + assert_eq!( + full_ref_hash(store_vfs, b"refs/heads/branch/default").await?, + Some(b"437bd1bf68ac037eb6956490444e2d7f9a5655c9".to_vec()) + ); + assert_eq!( + full_ref_hash(store_vfs, b"refs/tags/v6").await?, + Some(b"b50274b9b1c58fc97c45357a7c901d39bafc264d".to_vec()) + ); + assert_eq!( + full_ref_hash(store_vfs, b"refs/merge-requests/35/train").await?, + Some(b"f61a76dc97fb1a58f30e1b74245b957bb8c8d609".to_vec()) + ); + assert_eq!( + full_ref_hash( + store_vfs, + b"refs/keep-around/9787c1a7b9390e3f09babce1506254eb698dfba3" + ) + .await?, + Some(b"9787c1a7b9390e3f09babce1506254eb698dfba3".to_vec()) + ); + + assert_eq!(full_ref_hash(store_vfs, b"refs/heads/other").await?, None); + assert_eq!(full_ref_hash(store_vfs, b"refs/pipelines/54").await?, None); + assert_eq!(full_ref_hash(store_vfs, b"refs/tags/v8").await?, None); + assert_eq!( + full_ref_hash( + store_vfs, + b"refs/keep-around/b50274b9b1c58fc97c45357a7c901d39bafc264d" + ) + .await?, + None + ); + + // input errors + match full_ref_hash(store_vfs, b"REF/foo").await.unwrap_err() { + RefError::NotAFullRef => Ok(()), + e => Err(e), + }?; + match full_ref_hash(store_vfs, b"refs").await.unwrap_err() { + RefError::NotAFullRef => Ok(()), + e => Err(e), + }?; + match full_ref_hash(store_vfs, b"refs/tags").await.unwrap_err() { + RefError::MissingRefName => Ok(()), + e => Err(e), + }?; + + Ok(()) + } +}