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}