Skip to main content

grit_lib/refs/store/
transaction.rs

1//! [`RefTransaction`] builder and validation.
2
3use std::collections::HashSet;
4
5use super::error::RefStoreError;
6use super::types::{Expected, RawRef, RefCaseOnlyRename, RefReflogRename, RefUpdate};
7use super::validation::{
8    validate_reflog_update, validate_storable_refname, validate_symref_target,
9};
10
11/// Batch of ref updates prepared for [`super::RefStore::prepare`].
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct RefTransaction {
14    updates: Vec<RefUpdate>,
15    names: HashSet<String>,
16    reflog_renames: Vec<RefReflogRename>,
17    case_only_renames: Vec<RefCaseOnlyRename>,
18    initial: bool,
19    /// When set, [`super::RefStore::prepare`] may skip updates that fail per-ref checks and
20    /// commit the rest in one batch (Git `REF_TRANSACTION_ALLOW_FAILURE`).
21    allow_failures: bool,
22}
23
24impl RefTransaction {
25    /// Start an empty transaction.
26    #[must_use]
27    pub fn new() -> Self {
28        Self::default()
29    }
30
31    /// Start a transaction holding `updates`, checking for duplicate names in one pass.
32    ///
33    /// # Errors
34    ///
35    /// Returns [`RefStoreError::DuplicateUpdate`] for the first name queued twice.
36    pub(crate) fn from_updates(updates: Vec<RefUpdate>) -> Result<Self, RefStoreError> {
37        let mut names = HashSet::with_capacity(updates.len());
38        for update in &updates {
39            if !names.insert(update.name.clone()) {
40                return Err(RefStoreError::DuplicateUpdate {
41                    name: update.name.clone(),
42                });
43            }
44        }
45        Ok(Self {
46            updates,
47            names,
48            ..Self::default()
49        })
50    }
51
52    /// Append an update, rejecting duplicate ref names in the same batch.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`RefStoreError::DuplicateUpdate`] when `update.name` was already queued.
57    pub fn update(mut self, update: RefUpdate) -> Result<Self, RefStoreError> {
58        self.push_update(update)?;
59        Ok(self)
60    }
61
62    /// Append an update in place.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`RefStoreError::DuplicateUpdate`] when `update.name` was already queued.
67    pub fn push_update(&mut self, update: RefUpdate) -> Result<(), RefStoreError> {
68        if !self.names.insert(update.name.clone()) {
69            return Err(RefStoreError::DuplicateUpdate {
70                name: update.name.clone(),
71            });
72        }
73        self.updates.push(update);
74        Ok(())
75    }
76
77    /// Borrow the queued updates.
78    #[must_use]
79    pub fn updates(&self) -> &[RefUpdate] {
80        &self.updates
81    }
82
83    /// Consume the transaction and return the update list.
84    #[must_use]
85    pub fn into_updates(self) -> Vec<RefUpdate> {
86        self.updates
87    }
88
89    /// Reflog rename operations bundled with this transaction.
90    #[must_use]
91    pub fn reflog_renames(&self) -> &[RefReflogRename] {
92        &self.reflog_renames
93    }
94
95    /// Case-only ref renames bundled with this transaction.
96    #[must_use]
97    pub fn case_only_renames(&self) -> &[RefCaseOnlyRename] {
98        &self.case_only_renames
99    }
100
101    /// Append a branch reflog rename to this transaction.
102    #[must_use]
103    pub fn with_reflog_rename(mut self, rename: RefReflogRename) -> Self {
104        self.reflog_renames.push(rename);
105        self
106    }
107
108    /// Append a case-only branch ref rename (files backend).
109    #[must_use]
110    pub fn with_case_only_rename(mut self, rename: RefCaseOnlyRename) -> Self {
111        self.case_only_renames.push(rename);
112        self
113    }
114
115    /// Mark this as the first ref transaction of a new repository, such as the remote refs a
116    /// clone imports (Git's initial ref transaction).
117    ///
118    /// The files backend then writes the batch as one `packed-refs` rewrite, without a loose
119    /// file or lock per ref, when every update creates a direct ref (no reflog, expectation
120    /// [`Expected::Any`] or [`Expected::Missing`]) that does not exist yet in the common
121    /// directory. Any other batch, and every other backend, commits as usual, so the flag
122    /// never changes which refs end up stored, only how they are laid out.
123    ///
124    /// The packed write takes only `packed-refs.lock`: callers promise no other writer is
125    /// creating the same refs, which holds for a repository nobody else knows about yet.
126    #[must_use]
127    pub fn initial(mut self) -> Self {
128        self.initial = true;
129        self
130    }
131
132    /// Whether [`Self::initial`] marked this transaction.
133    #[must_use]
134    pub fn is_initial(&self) -> bool {
135        self.initial
136    }
137
138    /// Allow per-ref rejections during prepare while committing the rest in one batch.
139    ///
140    /// Rejections are returned from [`super::PreparedRefTransaction::rejected_updates`].
141    /// Compare-and-swap, directory/file, and object-target failures on one ref do not roll back
142    /// sibling updates in the same transaction. Duplicate names, invalid ref names, and other
143    /// whole-transaction errors still fail prepare entirely.
144    #[must_use]
145    pub fn allowing_failures(mut self) -> Self {
146        self.allow_failures = true;
147        self
148    }
149
150    /// Whether [`Self::allowing_failures`] was set.
151    #[must_use]
152    pub fn allows_failures(&self) -> bool {
153        self.allow_failures
154    }
155
156    /// Split into ref updates, reflog renames, and case-only ref renames.
157    #[must_use]
158    pub fn into_parts(self) -> (Vec<RefUpdate>, Vec<RefReflogRename>, Vec<RefCaseOnlyRename>) {
159        (self.updates, self.reflog_renames, self.case_only_renames)
160    }
161
162    /// The ref a lock failure on this transaction is reported under: the first update, else
163    /// the source of the first reflog or case-only rename, else `refs`.
164    pub(crate) fn lock_name(&self) -> String {
165        self.updates
166            .first()
167            .map(|u| &u.name)
168            .or_else(|| self.reflog_renames.first().map(|r| &r.from))
169            .or_else(|| self.case_only_renames.first().map(|r| &r.from))
170            .map_or_else(|| "refs".to_owned(), Clone::clone)
171    }
172
173    /// Backend-independent checks every [`super::RefStore::prepare`] runs before taking locks.
174    ///
175    /// Duplicate names cannot reach here because [`Self::push_update`] and
176    /// [`Self::from_updates`] reject them.
177    pub(crate) fn validate(&self) -> Result<(), RefStoreError> {
178        for update in &self.updates {
179            validate_storable_refname(&update.name)?;
180            if !update.flags.log_only
181                && matches!(&update.new_value, Some(RawRef::Direct(oid)) if oid.is_zero())
182            {
183                return Err(RefStoreError::NullOid {
184                    name: update.name.clone(),
185                });
186            }
187            if let Some(ref log) = update.reflog {
188                validate_reflog_update(log)?;
189            }
190            if let Some(RawRef::Symbolic(target)) = &update.new_value {
191                validate_symref_target(&update.name, target)?;
192            }
193        }
194        for rename in &self.reflog_renames {
195            validate_reflog_update(&rename.append)?;
196        }
197        let renames = self
198            .reflog_renames
199            .iter()
200            .map(|r| (&r.from, &r.to))
201            .chain(self.case_only_renames.iter().map(|r| (&r.from, &r.to)));
202        for (from, to) in renames {
203            validate_storable_refname(from)?;
204            validate_storable_refname(to)?;
205        }
206        Ok(())
207    }
208}
209
210/// Whether `actual` satisfies `expected`.
211#[must_use]
212pub fn expected_matches(actual: Option<&RawRef>, expected: &Expected) -> bool {
213    match expected {
214        Expected::Any => true,
215        Expected::Missing => actual.is_none(),
216        Expected::Exists => actual.is_some(),
217        Expected::Oid(want) => matches!(actual, Some(RawRef::Direct(got)) if got == want),
218        Expected::Symref(want) => matches!(actual, Some(RawRef::Symbolic(got)) if got == want),
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use crate::objects::ObjectId;
225
226    use super::*;
227
228    fn oid(byte: u8) -> ObjectId {
229        let mut bytes = [0u8; 20];
230        bytes[19] = byte;
231        ObjectId::from_bytes(&bytes).expect("valid oid")
232    }
233
234    #[test]
235    fn duplicate_names_rejected() {
236        let txn = RefTransaction::new().update(RefUpdate {
237            name: "refs/heads/a".to_owned(),
238            new_value: Some(RawRef::Direct(oid(1))),
239            expected: Expected::Any,
240            reflog: None,
241            flags: Default::default(),
242        });
243        assert!(txn.is_ok());
244        let err = txn
245            .unwrap()
246            .update(RefUpdate {
247                name: "refs/heads/a".to_owned(),
248                new_value: Some(RawRef::Direct(oid(2))),
249                expected: Expected::Any,
250                reflog: None,
251                flags: Default::default(),
252            })
253            .unwrap_err();
254        assert!(matches!(
255            err,
256            RefStoreError::DuplicateUpdate { name } if name == "refs/heads/a"
257        ));
258    }
259
260    fn direct(name: &str, value: ObjectId) -> RefUpdate {
261        RefUpdate {
262            name: name.to_owned(),
263            new_value: Some(RawRef::Direct(value)),
264            expected: Expected::Any,
265            reflog: None,
266            flags: Default::default(),
267        }
268    }
269
270    fn sample_log() -> super::super::ReflogUpdate {
271        super::super::ReflogUpdate {
272            identity: "A <a@example.com>".to_owned(),
273            message: "rename".to_owned(),
274            time: time::OffsetDateTime::UNIX_EPOCH,
275            old_oid: None,
276            new_oid: None,
277        }
278    }
279
280    #[test]
281    fn validate_rejects_unsafe_update_names() {
282        for name in [
283            "",
284            "heads/x",
285            "refs/heads/../x",
286            "refs/heads/a b",
287            "refs/heads/x/",
288        ] {
289            let err = RefTransaction::new()
290                .update(direct(name, oid(1)))
291                .unwrap()
292                .validate()
293                .unwrap_err();
294            assert_eq!(
295                err,
296                RefStoreError::InvalidRefName {
297                    name: name.to_owned()
298                }
299            );
300        }
301    }
302
303    #[test]
304    fn validate_rejects_unsafe_rename_sides() {
305        let reflog = RefTransaction::new().with_reflog_rename(RefReflogRename {
306            from: "refs/heads/ok".to_owned(),
307            to: "refs/heads/x.lock".to_owned(),
308            append: sample_log(),
309        });
310        assert_eq!(
311            reflog.validate().unwrap_err(),
312            RefStoreError::InvalidRefName {
313                name: "refs/heads/x.lock".to_owned()
314            }
315        );
316        let case_only = RefTransaction::new().with_case_only_rename(RefCaseOnlyRename {
317            from: "refs/heads//x".to_owned(),
318            to: "refs/heads/X".to_owned(),
319        });
320        assert_eq!(
321            case_only.validate().unwrap_err(),
322            RefStoreError::InvalidRefName {
323                name: "refs/heads//x".to_owned()
324            }
325        );
326    }
327
328    #[test]
329    fn validate_rejects_null_oid_unless_log_only() {
330        let err = RefTransaction::new()
331            .update(direct("refs/heads/z", ObjectId::zero()))
332            .unwrap()
333            .validate()
334            .unwrap_err();
335        assert_eq!(
336            err,
337            RefStoreError::NullOid {
338                name: "refs/heads/z".to_owned()
339            }
340        );
341
342        let mut log_only = direct("HEAD", ObjectId::zero());
343        log_only.flags.log_only = true;
344        assert!(RefTransaction::new()
345            .update(log_only)
346            .unwrap()
347            .validate()
348            .is_ok());
349    }
350
351    #[test]
352    fn validate_rejects_forged_reflog_identity_and_message() {
353        let mut update = direct("HEAD", oid(1));
354        update.flags.log_only = true;
355        update.reflog = Some(super::super::ReflogUpdate {
356            identity: "Evil <a@b>\nforged".to_owned(),
357            message: "ok".to_owned(),
358            time: time::OffsetDateTime::UNIX_EPOCH,
359            old_oid: None,
360            new_oid: None,
361        });
362        assert!(matches!(
363            RefTransaction::new()
364                .update(update)
365                .unwrap()
366                .validate()
367                .unwrap_err(),
368            RefStoreError::InvalidReflog(_)
369        ));
370
371        let mut update = direct("HEAD", oid(1));
372        update.flags.log_only = true;
373        update.reflog = Some(super::super::ReflogUpdate {
374            identity: "A <a@example.com>".to_owned(),
375            message: "bad\nline".to_owned(),
376            time: time::OffsetDateTime::UNIX_EPOCH,
377            old_oid: None,
378            new_oid: None,
379        });
380        assert!(matches!(
381            RefTransaction::new()
382                .update(update)
383                .unwrap()
384                .validate()
385                .unwrap_err(),
386            RefStoreError::InvalidReflog(_)
387        ));
388
389        let txn = RefTransaction::new().with_reflog_rename(RefReflogRename {
390            from: "refs/heads/a".to_owned(),
391            to: "refs/heads/b".to_owned(),
392            append: super::super::ReflogUpdate {
393                identity: "Evil <a@b>\nforged".to_owned(),
394                message: "rename".to_owned(),
395                time: time::OffsetDateTime::UNIX_EPOCH,
396                old_oid: None,
397                new_oid: None,
398            },
399        });
400        assert!(matches!(
401            txn.validate().unwrap_err(),
402            RefStoreError::InvalidReflog(_)
403        ));
404    }
405
406    #[test]
407    fn validate_accepts_pseudorefs_symrefs_and_deletes() {
408        let txn = RefTransaction::new()
409            .update(direct("ORIG_HEAD", oid(1)))
410            .unwrap()
411            .update(RefUpdate {
412                name: "HEAD".to_owned(),
413                new_value: Some(RawRef::Symbolic("refs/heads/main".to_owned())),
414                expected: Expected::Any,
415                reflog: None,
416                flags: Default::default(),
417            })
418            .unwrap()
419            .update(RefUpdate {
420                name: "refs/heads/alias".to_owned(),
421                new_value: Some(RawRef::Symbolic("refs/heads/not-yet".to_owned())),
422                expected: Expected::Any,
423                reflog: None,
424                flags: Default::default(),
425            })
426            .unwrap()
427            .update(RefUpdate {
428                name: "FETCH_HEAD".to_owned(),
429                new_value: Some(RawRef::Symbolic("ORIG_HEAD".to_owned())),
430                expected: Expected::Any,
431                reflog: None,
432                flags: Default::default(),
433            })
434            .unwrap()
435            .update(RefUpdate {
436                name: "refs/heads/gone".to_owned(),
437                new_value: None,
438                expected: Expected::Any,
439                reflog: None,
440                flags: Default::default(),
441            })
442            .unwrap();
443        assert!(txn.validate().is_ok());
444    }
445
446    fn symref(name: &str, target: &str) -> RefUpdate {
447        RefUpdate {
448            name: name.to_owned(),
449            new_value: Some(RawRef::Symbolic(target.to_owned())),
450            expected: Expected::Any,
451            reflog: None,
452            flags: Default::default(),
453        }
454    }
455
456    fn invalid_symref(name: &str, target: &str) -> RefStoreError {
457        RefStoreError::InvalidSymrefTarget {
458            name: name.to_owned(),
459            target: target.to_owned(),
460        }
461    }
462
463    #[test]
464    fn validate_rejects_invalid_symref_targets() {
465        for (name, target) in [
466            ("HEAD", "foo"),
467            ("refs/heads/sym1", "foo"),
468            ("refs/heads/loop", "refs/heads/loop"),
469            ("HEAD", "refs/heads/a..b"),
470        ] {
471            let err = RefTransaction::new()
472                .update(symref(name, target))
473                .unwrap()
474                .validate()
475                .unwrap_err();
476            assert_eq!(err, invalid_symref(name, target), "{name} -> {target}");
477        }
478    }
479
480    #[test]
481    fn from_updates_rejects_a_name_queued_twice() {
482        let update = |name: &str| RefUpdate {
483            name: name.to_owned(),
484            new_value: None,
485            expected: Expected::Any,
486            reflog: None,
487            flags: Default::default(),
488        };
489        let txn =
490            RefTransaction::from_updates(vec![update("refs/heads/a"), update("refs/heads/b")])
491                .expect("distinct names");
492        assert_eq!(txn.updates().len(), 2);
493        let err = RefTransaction::from_updates(vec![
494            update("refs/heads/a"),
495            update("refs/heads/b"),
496            update("refs/heads/a"),
497        ])
498        .unwrap_err();
499        assert!(matches!(
500            err,
501            RefStoreError::DuplicateUpdate { name } if name == "refs/heads/a"
502        ));
503    }
504
505    #[test]
506    fn expected_oid_requires_direct_match() {
507        let direct = RawRef::Direct(oid(1));
508        assert!(expected_matches(Some(&direct), &Expected::Oid(oid(1))));
509        assert!(!expected_matches(Some(&direct), &Expected::Oid(oid(2))));
510        assert!(!expected_matches(None, &Expected::Exists));
511        assert!(expected_matches(None, &Expected::Missing));
512    }
513
514    #[test]
515    fn from_updates_then_push_update_rejects_duplicate_name() {
516        let update = |name: &str| RefUpdate {
517            name: name.to_owned(),
518            new_value: Some(RawRef::Direct(oid(1))),
519            expected: Expected::Any,
520            reflog: None,
521            flags: Default::default(),
522        };
523        let mut txn =
524            RefTransaction::from_updates(vec![update("refs/heads/a"), update("refs/heads/b")])
525                .expect("distinct names");
526        let err = txn
527            .push_update(update("refs/heads/a"))
528            .expect_err("duplicate after from_updates");
529        assert!(matches!(
530            err,
531            RefStoreError::DuplicateUpdate { name } if name == "refs/heads/a"
532        ));
533        assert_eq!(txn.updates().len(), 2);
534    }
535
536    fn push_unique_updates(n: u32) -> std::time::Duration {
537        let mut txn = RefTransaction::new();
538        let start = std::time::Instant::now();
539        for i in 0..n {
540            txn.push_update(RefUpdate {
541                name: format!("refs/heads/b{i:07}"),
542                new_value: Some(RawRef::Direct(oid((i % 256) as u8))),
543                expected: Expected::Missing,
544                reflog: None,
545                flags: Default::default(),
546            })
547            .expect("unique name");
548        }
549        start.elapsed()
550    }
551
552    #[test]
553    fn push_update_batch_build_scales_linearly() {
554        if cfg!(debug_assertions) {
555            // Wall-clock ratios are too noisy in unoptimized builds; release-only
556            // `push_update_100k_build_under_one_second` covers large batches.
557            push_unique_updates(100);
558            return;
559        }
560        let small = push_unique_updates(2_000);
561        let large = push_unique_updates(8_000);
562        let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-9);
563        assert!(
564            ratio < 12.0,
565            "expected roughly linear scaling (~4x for 4x refs), got {ratio:.1}x \
566             (small={small:?}, large={large:?})"
567        );
568    }
569
570    #[test]
571    #[ignore = "release-only sanity check for large batches (#2281); cargo test -p grit-lib --release push_update_100k -- --ignored --exact"]
572    fn push_update_100k_build_under_one_second() {
573        let elapsed = push_unique_updates(100_000);
574        assert!(
575            elapsed.as_secs() < 3,
576            "100k push_update build took {elapsed:?} (expected well under 1s in release)"
577        );
578    }
579}