git-ents.gitmain
⌘K
foforge
loose.rs416 lines · 15.4 KB · rusthistorycomment on this file
1//! [`LooseRefStore`]: `RefStore` over gitoxide loose refs and packed-refs —
2//! the local default backend (`roots.local`).
3//!
4//! Atomic multi-ref compare-and-swap is layered on gitoxide's own
5//! in-process ref transaction (`Repository::edit_references_as`), which
6//! does the actual loose-file write, reflog append, and packed-refs
7//! interaction. Nothing in this module shells out to `git`.
8//!
9//! gitoxide's file-transaction precondition check reads a ref's current
10//! value *before* acquiring that ref's lock file, then locks and writes
11//! without re-verifying — safe for callers who already serialize through
12//! one in-process handle, but not for two independent `gix::Repository`
13//! handles (two processes, or two handles opened separately in one
14//! process) racing the same ref: both can read the same stale
15//! precondition before either has locked anything, and both then "win".
16//! `arch.loose-cas-discipline` requires this store to write through *its
17//! own* compare-and-swap discipline, so [`LooseRefStore::transaction`]
18//! closes that window itself with [`STORE_LOCK_NAME`]: a lock file,
19//! separate from any ref's own `.lock`, that every `transaction()` call —
20//! from any handle, any process, sharing the same on-disk repository —
21//! must hold for the full read-check-write sequence before gitoxide's own
22//! per-ref locking ever begins.
23
24use std::path::{Path, PathBuf};
25use std::sync::{Mutex, PoisonError};
26use std::time::Duration;
27
28use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit as GixRefEdit, RefLog};
29use gix::refs::{FullName, FullNameRef, Target};
30use gix_hash::ObjectId;
31
32use crate::{Error, Expected, RefEdit, RefIter, RefStore, RefStoreRead, Result, TxOutcome};
33
34/// The lock file name, held for the duration of every
35/// [`LooseRefStore::transaction`] call, that closes the precondition
36/// TOCTOU window described in this module's doc comment. Deliberately
37/// distinct from any ref's own name so it can never collide with a
38/// `refs/**` path gitoxide locks internally.
39const STORE_LOCK_NAME: &str = "gix-ref-store.lock";
40
41/// How long [`LooseRefStore::transaction`] waits to acquire
42/// [`STORE_LOCK_NAME`] before giving up. Generous relative to how long a
43/// transaction actually holds it (a handful of small file writes), so a
44/// legitimate queue of waiters drains rather than spuriously failing.
45const STORE_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
46
47/// The identity every transaction's reflog entry is written under.
48///
49/// A `RefStore` write is a plumbing-level operation, not an authored
50/// change — `gate.adoc`'s tip invariant is what carries authorship for
51/// meta-ref content, via the commit's own signature. The reflog identity
52/// here exists only so gitoxide has somewhere to write a committer line;
53/// it is deliberately fixed and independent of the local `git config`, so
54/// a `LooseRefStore` never depends on `user.name`/`user.email` being set.
55const REFLOG_NAME: &str = "gix-ref-store";
56const REFLOG_EMAIL: &str = "ref-store@git-ents.invalid";
57const REFLOG_MESSAGE: &str = "gix-ref-store: transaction";
58
59/// [`RefStore`] over a gitoxide repository's loose refs and packed-refs.
60///
61/// # Examples
62///
63/// ```
64/// use gix_ref_store::LooseRefStore;
65///
66/// # fn open(dir: &std::path::Path) -> gix_ref_store::Result<()> {
67/// let store = LooseRefStore::open(dir)?;
68/// # let _ = store;
69/// # Ok(())
70/// # }
71/// ```
72pub struct LooseRefStore {
73 repo: Mutex<gix::Repository>,
74 /// The repository's git directory, captured at open time so
75 /// [`Self::store_lock_path`] can be computed without locking
76 /// [`Self::repo`] — the store-level lock must be acquired *before* any
77 /// gitoxide call touches `repo`, not while already holding it.
78 git_dir: PathBuf,
79}
80
81impl LooseRefStore {
82 /// Open the ref store for the repository at `path`.
83 ///
84 /// `path` may be a work tree or the `.git` directory itself; gitoxide
85 /// resolves either the same way `git` does.
86 // @relation(arch.loose-cas-discipline, scope=function)
87 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
88 let path = path.as_ref();
89 let repo = gix::open(path).map_err(|source| Error::Open {
90 path: path.to_path_buf(),
91 source: Box::new(source),
92 })?;
93 let git_dir = repo.git_dir().to_path_buf();
94 Ok(Self {
95 repo: Mutex::new(repo),
96 git_dir,
97 })
98 }
99
100 /// Lock the underlying repository handle, recovering from a poisoned
101 /// lock rather than panicking: a panic in one caller while holding the
102 /// lock must not permanently wedge every other caller sharing this
103 /// store.
104 fn repo(&self) -> std::sync::MutexGuard<'_, gix::Repository> {
105 self.repo.lock().unwrap_or_else(PoisonError::into_inner)
106 }
107
108 /// The fixed reflog identity every transaction is written under. See
109 /// [`REFLOG_NAME`] for why this is not the ambient `git config`
110 /// identity.
111 fn committer(&self) -> gix::actor::Signature {
112 gix::actor::Signature {
113 name: REFLOG_NAME.into(),
114 email: REFLOG_EMAIL.into(),
115 time: gix::date::Time::now_local_or_utc(),
116 }
117 }
118
119 /// The path of this store's own serialization lock — see this
120 /// module's doc comment for why `transaction` needs one beyond
121 /// whatever gitoxide locks internally.
122 fn store_lock_path(&self) -> PathBuf {
123 self.git_dir.join(STORE_LOCK_NAME)
124 }
125}
126
127impl RefStoreRead for LooseRefStore {
128 fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>> {
129 let repo = self.repo();
130 let Some(mut reference) = repo
131 .try_find_reference(name.as_bstr())
132 .map_err(|error| Error::Read(Box::new(error)))?
133 else {
134 return Ok(None);
135 };
136 let id = reference
137 .follow_to_object()
138 .map_err(|error| Error::Read(Box::new(error)))?;
139 Ok(Some(id.detach()))
140 }
141
142 fn iter_prefix(&self, prefix: &str) -> Result<RefIter> {
143 let repo = self.repo();
144 let platform = repo
145 .references()
146 .map_err(|error| Error::Read(Box::new(error)))?;
147 let iter = platform
148 .prefixed(prefix)
149 .map_err(|error| Error::Read(Box::new(error)))?;
150
151 let mut out = Vec::new();
152 for reference in iter {
153 let mut reference = reference.map_err(Error::Read)?;
154 let name = reference.name().to_owned();
155 let oid = reference
156 .follow_to_object()
157 .map_err(|error| Error::Read(Box::new(error)))?
158 .detach();
159 out.push(Ok((name, oid)));
160 }
161 Ok(RefIter::new(out.into_iter()))
162 }
163}
164
165impl RefStore for LooseRefStore {
166 // @relation(arch.loose-cas-discipline, scope=function)
167 fn transaction(&self, edits: &[RefEdit]) -> Result<TxOutcome> {
168 // Close the precondition-read-before-lock race described in this
169 // module's doc comment: no other `transaction()` call, on this
170 // handle or any other handle sharing this on-disk repository, may
171 // be inside its own read-check-write sequence while we are.
172 let _store_lock = gix_lock::Marker::acquire_to_hold_resource(
173 self.store_lock_path(),
174 gix_lock::acquire::Fail::AfterDurationWithBackoff(STORE_LOCK_TIMEOUT),
175 Some(self.git_dir.clone()),
176 )
177 .map_err(Error::StoreLock)?;
178
179 let gix_edits: Vec<GixRefEdit> = edits.iter().map(to_gix_edit).collect();
180 let committer = self.committer();
181 let mut buf = gix::date::parse::TimeBuf::default();
182 match self
183 .repo()
184 .edit_references_as(gix_edits, Some(committer.to_ref(&mut buf)))
185 {
186 Ok(_applied) => Ok(TxOutcome::Applied),
187 Err(error) => match rejected_name(&error) {
188 Some(name) => Ok(TxOutcome::Rejected { name }),
189 None => Err(Error::Transaction(error)),
190 },
191 }
192 }
193}
194
195/// Convert one backend-agnostic [`RefEdit`] into gitoxide's own
196/// transaction edit type.
197fn to_gix_edit(edit: &RefEdit) -> GixRefEdit {
198 let change = match edit.new {
199 Some(oid) => Change::Update {
200 log: LogChange {
201 mode: RefLog::AndReference,
202 // gitoxide only auto-creates a missing reflog for
203 // refs/heads/, refs/remotes/, refs/notes/, and HEAD unless
204 // told otherwise; this project's refs mostly live under
205 // refs/meta/*, which needs a log regardless of namespace.
206 force_create_reflog: true,
207 message: REFLOG_MESSAGE.into(),
208 },
209 expected: to_previous_value(&edit.expected),
210 new: Target::Object(oid),
211 },
212 None => Change::Delete {
213 expected: to_previous_value(&edit.expected),
214 log: RefLog::AndReference,
215 },
216 };
217 GixRefEdit {
218 change,
219 name: edit.name.clone(),
220 deref: false,
221 }
222}
223
224/// Map a backend-agnostic [`Expected`] precondition onto gitoxide's own
225/// [`PreviousValue`].
226fn to_previous_value(expected: &Expected) -> PreviousValue {
227 match expected {
228 Expected::Any => PreviousValue::Any,
229 Expected::MustNotExist => PreviousValue::MustNotExist,
230 Expected::MustExistAndMatch(oid) => PreviousValue::MustExistAndMatch(Target::Object(*oid)),
231 }
232}
233
234/// The ref name a rejected transaction's compare-and-swap precondition
235/// failed on, or `None` when `error` is not a CAS mismatch (some other
236/// failure — a lock timeout, an I/O error — that should propagate as
237/// `Err`, not `Ok(TxOutcome::Rejected)`).
238fn rejected_name(error: &gix::reference::edit::Error) -> Option<FullName> {
239 let gix::reference::edit::Error::FileTransactionPrepare(prepare_error) = error else {
240 return None;
241 };
242 use gix::refs::file::transaction::prepare::Error as PrepareError;
243 let full_name = match prepare_error {
244 PrepareError::MustNotExist { full_name, .. }
245 | PrepareError::MustExist { full_name, .. }
246 | PrepareError::ReferenceOutOfDate { full_name, .. }
247 | PrepareError::DeleteReferenceMustExist { full_name, .. } => full_name,
248 _ => return None,
249 };
250 full_name_from_bytes(full_name.clone())
251}
252
253/// Reconstruct a validated [`FullName`] from the raw bytes a
254/// `prepare::Error` variant carries.
255///
256/// These bytes always originated from a [`FullName`] we constructed
257/// ourselves in [`to_gix_edit`] and handed to gitoxide, so re-validating
258/// them can only fail if gitoxide's own transaction machinery corrupted a
259/// name it was given — a backend bug, not a caller error. `None` is
260/// returned rather than panicking so a hypothetical future gitoxide
261/// version that reports a differently-shaped name degrades to "not
262/// recognized as a CAS rejection" instead of crashing the caller.
263fn full_name_from_bytes(bytes: gix::bstr::BString) -> Option<FullName> {
264 FullName::try_from(bytes).ok()
265}
266
267#[cfg(test)]
268mod tests {
269 #![allow(clippy::unwrap_used, reason = "unit test")]
270
271 use gix_hash::ObjectId;
272
273 use super::LooseRefStore;
274 use crate::{Expected, RefEdit, RefStore, RefStoreRead, TxOutcome};
275
276 fn init_repo() -> tempfile::TempDir {
277 let dir = tempfile::tempdir().unwrap();
278 gix::init(dir.path()).unwrap();
279 dir
280 }
281
282 fn name(s: &str) -> gix::refs::FullName {
283 s.try_into().unwrap()
284 }
285
286 fn fixture_oid(byte: u8) -> ObjectId {
287 ObjectId::from_bytes_or_panic(&[byte; 20])
288 }
289
290 #[test]
291 fn get_returns_none_for_an_absent_ref() {
292 let dir = init_repo();
293 let store = LooseRefStore::open(dir.path()).unwrap();
294 assert_eq!(store.get(name("refs/heads/nope").as_ref()).unwrap(), None);
295 }
296
297 #[test]
298 fn transaction_creates_a_ref_then_rejects_a_stale_cas() {
299 let dir = init_repo();
300 let store = LooseRefStore::open(dir.path()).unwrap();
301 let first = fixture_oid(1);
302 let second = fixture_oid(2);
303
304 let create = RefEdit {
305 name: name("refs/heads/topic"),
306 expected: Expected::MustNotExist,
307 new: Some(first),
308 };
309 assert_eq!(store.transaction(&[create]).unwrap(), TxOutcome::Applied);
310 assert_eq!(
311 store.get(name("refs/heads/topic").as_ref()).unwrap(),
312 Some(first)
313 );
314
315 // Re-asserting must-not-exist while the ref already exists is a
316 // CAS mismatch, reported as `Rejected`, not an `Err`.
317 let recreate = RefEdit {
318 name: name("refs/heads/topic"),
319 expected: Expected::MustNotExist,
320 new: Some(second),
321 };
322 let outcome = store.transaction(&[recreate]).unwrap();
323 assert_eq!(
324 outcome,
325 TxOutcome::Rejected {
326 name: name("refs/heads/topic")
327 }
328 );
329 // The rejected edit must not have applied.
330 assert_eq!(
331 store.get(name("refs/heads/topic").as_ref()).unwrap(),
332 Some(first)
333 );
334 }
335
336 #[test]
337 fn transaction_is_all_or_nothing_across_multiple_edits() {
338 let dir = init_repo();
339 let store = LooseRefStore::open(dir.path()).unwrap();
340 let oid = fixture_oid(3);
341
342 // The second edit's precondition already fails (the ref doesn't
343 // exist yet), so neither edit should apply.
344 let edits = [
345 RefEdit {
346 name: name("refs/heads/a"),
347 expected: Expected::MustNotExist,
348 new: Some(oid),
349 },
350 RefEdit {
351 name: name("refs/heads/b"),
352 expected: Expected::MustExistAndMatch(oid),
353 new: Some(oid),
354 },
355 ];
356 let outcome = store.transaction(&edits).unwrap();
357 assert!(matches!(outcome, TxOutcome::Rejected { .. }));
358 assert_eq!(store.get(name("refs/heads/a").as_ref()).unwrap(), None);
359 }
360
361 #[test]
362 fn iter_prefix_lists_matching_refs() {
363 let dir = init_repo();
364 let store = LooseRefStore::open(dir.path()).unwrap();
365 let oid = fixture_oid(4);
366 store
367 .transaction(&[RefEdit {
368 name: name("refs/meta/thing"),
369 expected: Expected::MustNotExist,
370 new: Some(oid),
371 }])
372 .unwrap();
373 store
374 .transaction(&[RefEdit {
375 name: name("refs/heads/unrelated"),
376 expected: Expected::MustNotExist,
377 new: Some(oid),
378 }])
379 .unwrap();
380
381 let names: Vec<String> = store
382 .iter_prefix("refs/meta/")
383 .unwrap()
384 .map(|item| item.unwrap().0.as_bstr().to_string())
385 .collect();
386 assert_eq!(names, vec!["refs/meta/thing".to_owned()]);
387 }
388
389 #[test]
390 fn delete_removes_a_ref() {
391 let dir = init_repo();
392 let store = LooseRefStore::open(dir.path()).unwrap();
393 let oid = fixture_oid(5);
394 store
395 .transaction(&[RefEdit {
396 name: name("refs/meta/gone"),
397 expected: Expected::MustNotExist,
398 new: Some(oid),
399 }])
400 .unwrap();
401 assert_eq!(
402 store.get(name("refs/meta/gone").as_ref()).unwrap(),
403 Some(oid)
404 );
405
406 let outcome = store
407 .transaction(&[RefEdit {
408 name: name("refs/meta/gone"),
409 expected: Expected::MustExistAndMatch(oid),
410 new: None,
411 }])
412 .unwrap();
413 assert_eq!(outcome, TxOutcome::Applied);
414 assert_eq!(store.get(name("refs/meta/gone").as_ref()).unwrap(), None);
415 }
416}