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(())
+    }
+}