Skip to main content

git_remote_object_store/
remote.rs

1//! High-level handle to a git-remote-object-store repository.
2//!
3//! # Entry point
4//!
5//! [`Remote`] is the primary library entry point for external consumers.
6//! It wraps an [`ObjectStore`] and the repository-level key prefix from the
7//! [`RemoteUrl`], so callers never need to track the prefix separately or
8//! know the internal key layout.
9//!
10//! # On-bucket key layout
11//!
12//! Objects are stored under `<prefix>/<suffix>` (where `<prefix>` is the
13//! path component of the URL and may be empty for bucket-root repositories):
14//!
15//! | Suffix | Purpose |
16//! |--------|---------|
17//! | `HEAD` | Ref pointer for the default branch |
18//! | `refs/heads/<branch>/<sha>.bundle` | Git bundle for a branch commit |
19//! | `refs/heads/<branch>/LOCK#.lock` | Per-ref push-lock file |
20//! | `refs/heads/<branch>/PROTECTED#` | Per-ref branch-protection sentinel |
21//! | `lfs/<oid>` | Git LFS object |
22//!
23//! Use [`Remote::key`] to build correctly-prefixed keys, then call methods
24//! on [`Remote::store`] directly for operations not covered by the helper
25//! methods.
26//!
27//! # Example
28//!
29//! ```no_run
30//! # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
31//! use git_remote_object_store::Remote;
32//!
33//! let remote = Remote::connect("s3+https://my-bucket.s3.us-east-1.amazonaws.com/my-repo").await?;
34//!
35//! // Read the HEAD ref
36//! let head = remote.get_head().await?;
37//! println!("{}", String::from_utf8_lossy(&head));
38//!
39//! // List all objects on a branch
40//! let metas = remote.list("refs/heads/main/").await?;
41//! for meta in metas {
42//!     println!("{} ({} bytes)", meta.key, meta.size);
43//! }
44//!
45//! // Same data via direct store access, for operations not covered by
46//! // the helper methods (custom keys, raw puts, conditional writes).
47//! let same_head = remote.store().get_bytes(&remote.key("HEAD")).await?;
48//! assert_eq!(head, same_head);
49//! # Ok(())
50//! # }
51//! ```
52
53use std::sync::Arc;
54
55use bytes::Bytes;
56
57use crate::object_store::{ObjectMeta, ObjectStore, ObjectStoreError, PutOpts};
58use crate::protocol::backend::{self, BackendError};
59use crate::url::{ParseError, RemoteUrl, StorageEngine};
60
61/// A handle to a git-remote-object-store repository in a cloud backend.
62///
63/// See the [module documentation][self] for the on-bucket key layout and
64/// usage examples.
65pub struct Remote {
66    store: Arc<dyn ObjectStore>,
67    prefix: String,
68    engine: StorageEngine,
69}
70
71impl Remote {
72    /// Parse `url_str` and open a connection to the backing cloud store.
73    ///
74    /// Runs an eager probe (a low-cost listing call) to verify connectivity
75    /// and surface auth failures early. Requires a Tokio runtime — call this
76    /// inside `#[tokio::main]` or an equivalent async context.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`RemoteError::Url`] when the URL is not a recognised scheme,
81    /// and [`RemoteError::Backend`] when the backend is unreachable (missing
82    /// bucket/container, insufficient permissions, invalid credentials).
83    pub async fn connect(url_str: &str) -> Result<Self, RemoteError> {
84        let url = url_str.parse::<RemoteUrl>()?;
85        Ok(Self::open(&url).await?)
86    }
87
88    /// Open a connection from an already-parsed [`RemoteUrl`].
89    ///
90    /// Prefer [`connect`](Self::connect) when starting from a string.
91    /// Use this variant when you need to inspect or route on the URL before
92    /// connecting.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`BackendError`] when the backend is unreachable.
97    pub async fn open(url: &RemoteUrl) -> Result<Self, BackendError> {
98        let (store, engine) = backend::build(url).await?;
99        let prefix = url.prefix().unwrap_or_default().to_owned();
100        Ok(Self {
101            store,
102            prefix,
103            engine,
104        })
105    }
106
107    /// Compute the storage key for `suffix` within this repository's prefix.
108    ///
109    /// Use this to construct keys for direct [`store`](Self::store) operations.
110    ///
111    /// For a repository at `s3+https://bucket/my-repo`:
112    /// - `remote.key("HEAD")` → `"my-repo/HEAD"`
113    /// - `remote.key("refs/heads/main/")` → `"my-repo/refs/heads/main/"`
114    ///
115    /// For a repository at `s3+https://bucket` (no prefix):
116    /// - `remote.key("HEAD")` → `"HEAD"`
117    #[must_use]
118    pub fn key(&self, suffix: &str) -> String {
119        crate::keys::join(Some(&self.prefix), suffix)
120    }
121
122    /// The underlying [`ObjectStore`] for direct get/put operations.
123    ///
124    /// Combine with [`key`](Self::key) to target the correct storage path:
125    ///
126    /// ```no_run
127    /// # #[tokio::main] async fn main() -> Result<(), git_remote_object_store::ObjectStoreError> {
128    /// # use git_remote_object_store::Remote;
129    /// # let remote: Remote = todo!();
130    /// let metas = remote.store().list(&remote.key("refs/heads/main/")).await?;
131    /// # Ok(())
132    /// # }
133    /// ```
134    #[must_use]
135    pub fn store(&self) -> &dyn ObjectStore {
136        &*self.store
137    }
138
139    /// Test-only constructor that lets integration tests build a
140    /// [`Remote`] against an in-memory [`crate::object_store::mock::MockStore`]
141    /// without going through `backend::build` (which would attempt a
142    /// live probe). Production callers must use [`Self::connect`] /
143    /// [`Self::open`].
144    #[cfg(any(test, feature = "test-util"))]
145    #[must_use]
146    pub fn new_for_test(
147        store: Arc<dyn ObjectStore>,
148        prefix: impl Into<String>,
149        engine: StorageEngine,
150    ) -> Self {
151        Self {
152            store,
153            prefix: prefix.into(),
154            engine,
155        }
156    }
157
158    /// The repository prefix (empty string for bucket-root repositories).
159    #[must_use]
160    pub fn prefix(&self) -> &str {
161        &self.prefix
162    }
163
164    /// The storage engine resolved at [`open`](Self::open) time from the
165    /// `FORMAT` key combined with any `?engine=` URL parameter.
166    ///
167    /// Callers that target engine-specific APIs (notably
168    /// [`crate::packchain::read_blob`]) inspect this to fail fast against
169    /// a remote of the wrong shape rather than blindly fetching the
170    /// engine-specific manifest keys.
171    #[must_use]
172    pub fn engine(&self) -> StorageEngine {
173        self.engine
174    }
175
176    /// Read the repository's `HEAD` ref.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`ObjectStoreError::NotFound`] when no `HEAD` object exists.
181    pub async fn get_head(&self) -> Result<Bytes, ObjectStoreError> {
182        self.store.get_bytes(&self.key("HEAD")).await
183    }
184
185    /// Write the repository's `HEAD` ref.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`ObjectStoreError`] on backend write failure (auth, network, etc.).
190    pub async fn put_head(&self, content: Bytes) -> Result<(), ObjectStoreError> {
191        self.store
192            .put_bytes(&self.key("HEAD"), content, PutOpts::default())
193            .await
194    }
195
196    /// List all objects whose storage key starts with `<prefix>/<suffix>`.
197    ///
198    /// Pass `""` to list everything in the repository.
199    /// Pass `"refs/heads/main/"` to list all bundles on that branch.
200    /// Pass `"refs/"` to list all ref objects.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`ObjectStoreError`] on backend list failure (auth, network, etc.).
205    pub async fn list(&self, suffix: &str) -> Result<Vec<ObjectMeta>, ObjectStoreError> {
206        self.store.list(&self.key(suffix)).await
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn make_remote(prefix: &str) -> Remote {
215        Remote {
216            store: Arc::new(crate::object_store::mock::MockStore::new()),
217            prefix: prefix.to_owned(),
218            engine: StorageEngine::Bundle,
219        }
220    }
221
222    #[test]
223    fn key_with_prefix_joins_with_slash() {
224        let remote = make_remote("my-repo");
225        assert_eq!(remote.key("HEAD"), "my-repo/HEAD");
226        assert_eq!(remote.key("refs/heads/main/"), "my-repo/refs/heads/main/");
227    }
228
229    #[test]
230    fn key_without_prefix_returns_suffix_only() {
231        let remote = make_remote("");
232        assert_eq!(remote.key("HEAD"), "HEAD");
233        assert_eq!(remote.key("refs/heads/main/"), "refs/heads/main/");
234    }
235
236    #[test]
237    fn prefix_reflects_construction_value() {
238        assert_eq!(make_remote("my-repo").prefix(), "my-repo");
239        assert_eq!(make_remote("").prefix(), "");
240    }
241}
242
243/// Error returned by [`Remote::connect`].
244#[derive(Debug, thiserror::Error)]
245pub enum RemoteError {
246    /// The URL string was not a recognised scheme or was malformed.
247    #[error(transparent)]
248    Url(#[from] ParseError),
249    /// The backend was unreachable (auth failure, missing bucket/container, etc.).
250    #[error(transparent)]
251    Backend(#[from] BackendError),
252}