Skip to main content

sley_refs/
lib.rs

1// sley#7: untrusted-input parsing crate — fallible ops propagate errors;
2// the only retained `expect`s would be documented compile-time invariants.
3#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
4
5use sley_config::GitConfig;
6use sley_core::{GitError, ObjectFormat, ObjectId, Result};
7use sley_formats::{Reftable, ReftableRefRecord, ReftableRefValue};
8use std::borrow::Borrow;
9use std::collections::{BTreeMap, BTreeSet, HashMap};
10use std::fmt;
11use std::fs;
12use std::io::Write;
13use std::ops::Deref;
14use std::path::{Path, PathBuf};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum RefTarget {
19    Direct(ObjectId),
20    Symbolic(String),
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Ref {
25    pub name: String,
26    pub target: RefTarget,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RefDelete {
31    pub name: String,
32    pub oid: ObjectId,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct DeleteRef {
37    pub name: String,
38    pub expected_old: Option<ObjectId>,
39    pub reflog: Option<DeleteRefReflog>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DeleteRefReflog {
44    pub committer: Vec<u8>,
45    pub message: Vec<u8>,
46}
47
48#[derive(Debug)]
49pub enum RefDeleteError {
50    NotFound,
51    ExpectedMismatch {
52        expected: Option<ObjectId>,
53        actual: Option<ObjectId>,
54    },
55    Locked,
56    InvalidName,
57    Io(std::io::Error),
58}
59
60impl fmt::Display for RefDeleteError {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            Self::NotFound => f.write_str("ref not found"),
64            Self::ExpectedMismatch { expected, actual } => {
65                write!(
66                    f,
67                    "ref expected old oid mismatch: expected {:?}, actual {:?}",
68                    expected, actual
69                )
70            }
71            Self::Locked => f.write_str("ref is locked"),
72            Self::InvalidName => f.write_str("invalid ref name"),
73            Self::Io(err) => write!(f, "io error: {err}"),
74        }
75    }
76}
77
78impl std::error::Error for RefDeleteError {}
79
80impl From<std::io::Error> for RefDeleteError {
81    fn from(value: std::io::Error) -> Self {
82        Self::Io(value)
83    }
84}
85
86pub fn parse_loose_ref(format: ObjectFormat, name: impl Into<String>, bytes: &[u8]) -> Result<Ref> {
87    let name = name.into();
88    let value = std::str::from_utf8(bytes)
89        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
90        .trim_end_matches('\n');
91    let target = if let Some(symbolic) = value.strip_prefix("ref: ") {
92        RefTarget::Symbolic(symbolic.to_string())
93    } else {
94        RefTarget::Direct(ObjectId::from_hex(format, value)?)
95    };
96    Ok(Ref { name, target })
97}
98
99pub fn write_loose_ref(reference: &Ref) -> Vec<u8> {
100    match &reference.target {
101        RefTarget::Direct(oid) => format!("{oid}\n").into_bytes(),
102        RefTarget::Symbolic(target) => format!("ref: {target}\n").into_bytes(),
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct PackedRef {
108    pub reference: Ref,
109    pub peeled: Option<ObjectId>,
110}
111
112pub fn parse_packed_refs(format: ObjectFormat, bytes: &[u8]) -> Result<Vec<PackedRef>> {
113    let text =
114        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
115    let mut refs: Vec<PackedRef> = Vec::new();
116    for raw_line in text.lines() {
117        let line = raw_line.trim_end();
118        if line.is_empty() || line.starts_with('#') {
119            continue;
120        }
121        if let Some(peeled) = line.strip_prefix('^') {
122            let oid = ObjectId::from_hex(format, peeled)?;
123            let Some(last) = refs.last_mut() else {
124                return Err(GitError::InvalidFormat(
125                    "peeled packed ref without preceding ref".into(),
126                ));
127            };
128            last.peeled = Some(oid);
129            continue;
130        }
131        let (oid, name) = line
132            .split_once(' ')
133            .ok_or_else(|| GitError::InvalidFormat("invalid packed ref line".into()))?;
134        validate_ref_name(name)?;
135        refs.push(PackedRef {
136            reference: Ref {
137                name: name.into(),
138                target: RefTarget::Direct(ObjectId::from_hex(format, oid)?),
139            },
140            peeled: None,
141        });
142    }
143    Ok(refs)
144}
145
146pub fn write_packed_refs(refs: &[PackedRef]) -> Result<Vec<u8>> {
147    let mut refs = refs.to_vec();
148    refs.sort_by(|left, right| left.reference.name.cmp(&right.reference.name));
149    let mut out = b"# pack-refs with: peeled fully-peeled sorted \n".to_vec();
150    for packed in refs {
151        validate_ref_name(&packed.reference.name)?;
152        let RefTarget::Direct(oid) = &packed.reference.target else {
153            return Err(GitError::InvalidFormat(format!(
154                "packed ref {} is symbolic",
155                packed.reference.name
156            )));
157        };
158        out.extend_from_slice(oid.to_hex().as_bytes());
159        out.push(b' ');
160        out.extend_from_slice(packed.reference.name.as_bytes());
161        out.push(b'\n');
162        if let Some(peeled) = packed.peeled {
163            out.push(b'^');
164            out.extend_from_slice(peeled.to_hex().as_bytes());
165            out.push(b'\n');
166        }
167    }
168    Ok(out)
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct ReflogEntry {
173    pub old_oid: ObjectId,
174    pub new_oid: ObjectId,
175    pub committer: Vec<u8>,
176    pub message: Vec<u8>,
177}
178
179impl ReflogEntry {
180    pub fn to_line(&self) -> Vec<u8> {
181        let mut out = Vec::new();
182        out.extend_from_slice(self.old_oid.to_hex().as_bytes());
183        out.push(b' ');
184        out.extend_from_slice(self.new_oid.to_hex().as_bytes());
185        out.push(b' ');
186        out.extend_from_slice(&self.committer);
187        if !self.message.is_empty() {
188            out.push(b'\t');
189            out.extend_from_slice(&self.message);
190        }
191        out.push(b'\n');
192        out
193    }
194
195    pub fn timestamp_seconds(&self) -> Result<i64> {
196        let committer = std::str::from_utf8(&self.committer)
197            .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
198        let Some((before_tz, _tz)) = committer.rsplit_once(' ') else {
199            return Err(GitError::InvalidFormat(
200                "reflog committer is missing timezone".into(),
201            ));
202        };
203        let Some((_identity, timestamp)) = before_tz.rsplit_once(' ') else {
204            return Err(GitError::InvalidFormat(
205                "reflog committer is missing timestamp".into(),
206            ));
207        };
208        timestamp
209            .parse::<i64>()
210            .map_err(|err| GitError::InvalidFormat(err.to_string()))
211    }
212}
213
214pub fn parse_reflog(format: ObjectFormat, bytes: &[u8]) -> Result<Vec<ReflogEntry>> {
215    let text =
216        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
217    let mut entries = Vec::new();
218    for line in text.lines() {
219        let mut parts = line.splitn(3, ' ');
220        let old = parts
221            .next()
222            .ok_or_else(|| GitError::InvalidFormat("missing reflog old oid".into()))?;
223        let new = parts
224            .next()
225            .ok_or_else(|| GitError::InvalidFormat("missing reflog new oid".into()))?;
226        let rest = parts
227            .next()
228            .ok_or_else(|| GitError::InvalidFormat("missing reflog committer".into()))?;
229        let (committer, message) = rest.split_once('\t').unwrap_or((rest, ""));
230        entries.push(ReflogEntry {
231            old_oid: ObjectId::from_hex(format, old)?,
232            new_oid: ObjectId::from_hex(format, new)?,
233            committer: committer.as_bytes().to_vec(),
234            message: message.as_bytes().to_vec(),
235        });
236    }
237    Ok(entries)
238}
239
240/// Expire reflog entries, mirroring `git reflog expire` semantics.
241///
242/// Entries are kept when their committer timestamp is at or after `cutoff_unix`.
243/// Entries whose `new_oid` is unreachable (per `is_reachable`) are held to the
244/// stricter `expire_unreachable_cutoff` when one is supplied: such an entry is
245/// dropped when its timestamp falls below either cutoff. When
246/// `expire_unreachable_cutoff` is `None`, reachability does not relax the single
247/// `cutoff_unix` bound.
248///
249/// The most recent entry (the one describing the ref's current value) is always
250/// preserved, exactly as git refuses to expire the tip of a reflog, even when it
251/// is older than the cutoff. Relative order of the surviving entries is kept.
252///
253/// This is a pure function over already-parsed entries so callers can read,
254/// filter, and rewrite reflogs however they like; see
255/// [`FileRefStore::expire_reflog_file`] for a filesystem convenience built on top
256/// of it.
257pub fn expire_reflog(
258    entries: &[ReflogEntry],
259    cutoff_unix: i64,
260    expire_unreachable_cutoff: Option<i64>,
261    is_reachable: impl Fn(&ObjectId) -> bool,
262) -> Result<Vec<ReflogEntry>> {
263    let last_index = entries.len().checked_sub(1);
264    let mut retained = Vec::with_capacity(entries.len());
265    for (index, entry) in entries.iter().enumerate() {
266        // Always keep the most recent entry: it records the current ref value
267        // and git never expires it.
268        if Some(index) == last_index {
269            retained.push(entry.clone());
270            continue;
271        }
272        let timestamp = entry.timestamp_seconds()?;
273        let mut expired = timestamp < cutoff_unix;
274        if let Some(unreachable_cutoff) = expire_unreachable_cutoff
275            && !is_reachable(&entry.new_oid)
276        {
277            expired = expired || timestamp < unreachable_cutoff;
278        }
279        if !expired {
280            retained.push(entry.clone());
281        }
282    }
283    Ok(retained)
284}
285
286#[derive(Debug, Default, Clone)]
287pub struct RefStore {
288    refs: HashMap<String, RefTarget>,
289    reflogs: BTreeMap<String, Vec<ReflogEntry>>,
290}
291
292impl RefStore {
293    pub fn new() -> Self {
294        Self::default()
295    }
296
297    pub fn get(&self, name: &str) -> Option<&RefTarget> {
298        self.refs.get(name)
299    }
300
301    pub fn transaction(&mut self) -> RefTransaction<'_> {
302        RefTransaction {
303            store: self,
304            updates: Vec::new(),
305        }
306    }
307
308    pub fn reflog(&self, name: &str) -> &[ReflogEntry] {
309        self.reflogs
310            .get(name)
311            .map(Vec::as_slice)
312            .unwrap_or_default()
313    }
314}
315
316#[derive(Debug)]
317pub struct RefUpdate {
318    pub name: String,
319    pub expected: Option<RefTarget>,
320    pub new: RefTarget,
321    pub reflog: Option<ReflogEntry>,
322}
323
324/// The compare-and-swap precondition a ref update is checked against (re-verified
325/// while the ref is locked, so it is a true CAS, not a check-then-write).
326///
327/// [`RefUpdate::expected`] can express [`Any`](RefPrecondition::Any) (`None`) and
328/// [`MustExistAndMatch`](RefPrecondition::MustExistAndMatch) (`Some`); the
329/// create-only and match-or-create modes are reachable via
330/// [`FileRefTransaction::update_to`].
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub enum RefPrecondition {
333    /// No precondition: create or overwrite unconditionally.
334    Any,
335    /// The ref must currently exist (with any value).
336    MustExist,
337    /// The ref must currently not exist (create-only).
338    MustNotExist,
339    /// The ref must currently exist and point exactly at this target.
340    MustExistAndMatch(RefTarget),
341    /// If the ref exists it must point exactly at this target; if it is absent,
342    /// the update is still allowed (match-or-create).
343    ExistingMustMatch(RefTarget),
344}
345
346impl RefPrecondition {
347    /// The precondition implied by a [`RefUpdate::expected`] value.
348    fn from_expected(expected: Option<RefTarget>) -> Self {
349        match expected {
350            None => Self::Any,
351            Some(target) => Self::MustExistAndMatch(target),
352        }
353    }
354
355    /// Whether `current` — the ref's value right now, or `None` if absent —
356    /// satisfies this precondition.
357    fn is_satisfied_by(&self, current: Option<&RefTarget>) -> bool {
358        match self {
359            Self::Any => true,
360            Self::MustExist => current.is_some(),
361            Self::MustNotExist => current.is_none(),
362            Self::MustExistAndMatch(target) => current == Some(target),
363            Self::ExistingMustMatch(target) => match current {
364                None => true,
365                Some(current) => current == target,
366            },
367        }
368    }
369
370    /// A human-readable description of an unmet precondition, for errors.
371    fn describe(&self, name: &str) -> String {
372        match self {
373            Self::Any => format!("ref {name} precondition not met"),
374            Self::MustExist => format!("expected ref {name} to exist"),
375            Self::MustNotExist => format!("expected ref {name} to not already exist"),
376            Self::MustExistAndMatch(_) => format!("expected ref {name} to match"),
377            Self::ExistingMustMatch(_) => {
378                format!("expected ref {name} to match its current value")
379            }
380        }
381    }
382}
383
384pub struct RefTransaction<'a> {
385    store: &'a mut RefStore,
386    updates: Vec<RefUpdate>,
387}
388
389impl<'a> RefTransaction<'a> {
390    pub fn update(&mut self, update: RefUpdate) {
391        self.updates.push(update);
392    }
393
394    pub fn commit(self) -> Result<()> {
395        for update in &self.updates {
396            if let Some(expected) = &update.expected
397                && self.store.refs.get(&update.name) != Some(expected)
398            {
399                return Err(GitError::Transaction(format!(
400                    "expected ref {} to match",
401                    update.name
402                )));
403            }
404        }
405        for update in self.updates {
406            self.store.refs.insert(update.name.clone(), update.new);
407            if let Some(entry) = update.reflog {
408                self.store
409                    .reflogs
410                    .entry(update.name)
411                    .or_default()
412                    .push(entry);
413            }
414        }
415        Ok(())
416    }
417}
418
419#[derive(Debug, Clone)]
420pub struct FileRefStore {
421    git_dir: PathBuf,
422    common_dir: PathBuf,
423    format: ObjectFormat,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct BranchCreate {
428    pub name: String,
429    pub oid: ObjectId,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct BranchDelete {
434    pub name: String,
435    pub oid: ObjectId,
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub struct TagCreate {
440    pub name: String,
441    pub oid: ObjectId,
442}
443
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct TagDelete {
446    pub name: String,
447    pub oid: ObjectId,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq)]
451pub struct BundleRefUpdate {
452    pub name: String,
453    pub oid: ObjectId,
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
457pub struct BundleRefUpdateReflog {
458    pub committer: Vec<u8>,
459    pub message: Vec<u8>,
460}
461
462#[derive(Debug, Clone, PartialEq, Eq)]
463pub struct AppliedBundleRefUpdate {
464    pub name: String,
465    pub old_oid: Option<ObjectId>,
466    pub new_oid: ObjectId,
467}
468
469impl FileRefStore {
470    pub fn new(git_dir: impl Into<PathBuf>, format: ObjectFormat) -> Self {
471        let git_dir = git_dir.into();
472        let common_dir = repository_common_dir(&git_dir);
473        Self {
474            git_dir,
475            common_dir,
476            format,
477        }
478    }
479
480    pub fn read_ref(&self, name: &str) -> Result<Option<RefTarget>> {
481        validate_ref_name_for_read(name)?;
482        self.read_ref_unchecked(name)
483    }
484
485    fn read_ref_unchecked(&self, name: &str) -> Result<Option<RefTarget>> {
486        if self.uses_reftable()? {
487            return self.read_reftable_ref(name);
488        }
489        if let Some(reference) = self.read_loose_ref(name)? {
490            return Ok(Some(reference.target));
491        }
492        if let Some(reference) = self.read_packed_ref(name)? {
493            return Ok(Some(reference.reference.target));
494        }
495        Ok(None)
496    }
497
498    /// Raw existence check matching git's `refs_read_raw_ref` (builtin/refs.c
499    /// cmd_refs_exists). A ref "exists" if its loose file is present (regardless
500    /// of contents — dangling symrefs, bad object ids, and refs written with a
501    /// bad name all count) or if it is recorded in packed-refs / the reftable.
502    /// Unlike [`read_ref`], no name validation is performed and the object the
503    /// ref points at is never read. Returns:
504    ///   * `Ok(true)`  — the raw ref exists.
505    ///   * `Ok(false)` — ENOENT or EISDIR (a bare directory where the ref would
506    ///     live and no packed entry); git maps both to exit code 2.
507    pub fn raw_ref_exists(&self, name: &str) -> Result<bool> {
508        if self.uses_reftable()? {
509            return Ok(self.read_reftable_ref(name)?.is_some());
510        }
511        // git routes root-ref-syntax names (HEAD, FETCH_HEAD, MERGE_HEAD, …) to
512        // the per-worktree gitdir and everything else to the common dir; mirror
513        // files_ref_path's REF_WORKTREE_CURRENT vs REF_WORKTREE_SHARED split.
514        let base = if is_root_ref_syntax(name) {
515            &self.git_dir
516        } else {
517            &self.common_dir
518        };
519        let path = base.join(name);
520        match fs::symlink_metadata(&path) {
521            Ok(meta) if meta.is_dir() => {
522                // A directory at the loose path is EISDIR unless packed-refs
523                // still carries the name.
524                Ok(self.read_packed_ref(name)?.is_some())
525            }
526            Ok(_) => Ok(true),
527            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
528                Ok(self.read_packed_ref(name)?.is_some())
529            }
530            Err(err) => Err(err.into()),
531        }
532    }
533
534    pub fn read_reflog(&self, name: &str) -> Result<Vec<ReflogEntry>> {
535        validate_ref_name_for_read(name)?;
536        let path = self.reflog_path(name);
537        if !path.exists() {
538            return Ok(Vec::new());
539        }
540        parse_reflog(self.format, &fs::read(path)?)
541    }
542
543    pub fn write_reflog(&self, name: &str, entries: &[ReflogEntry]) -> Result<()> {
544        validate_ref_name_for_read(name)?;
545        let path = self.reflog_path(name);
546        let parent = path
547            .parent()
548            .ok_or_else(|| GitError::InvalidPath("reflog path has no parent".into()))?;
549        fs::create_dir_all(parent)?;
550        let mut bytes = Vec::new();
551        for entry in entries {
552            bytes.extend_from_slice(&entry.to_line());
553        }
554        write_locked(&path, &bytes)
555    }
556
557    pub fn expire_reflog_older_than(&self, name: &str, cutoff_seconds: i64) -> Result<usize> {
558        validate_ref_name_for_read(name)?;
559        let path = self.reflog_path(name);
560        if !path.exists() {
561            return Ok(0);
562        }
563        let entries = parse_reflog(self.format, &fs::read(&path)?)?;
564        let original_len = entries.len();
565        let mut retained = Vec::new();
566        for entry in entries {
567            if entry.timestamp_seconds()? >= cutoff_seconds {
568                retained.push(entry);
569            }
570        }
571        let mut bytes = Vec::new();
572        for entry in &retained {
573            bytes.extend_from_slice(&entry.to_line());
574        }
575        write_locked(&path, &bytes)?;
576        Ok(original_len - retained.len())
577    }
578
579    /// Read a ref's reflog, expire entries with [`expire_reflog`], and rewrite
580    /// the file with the survivors.
581    ///
582    /// Reachability of each entry's `new_oid` is delegated to `is_reachable` so
583    /// the caller can supply whatever object-graph knowledge it has. Rewriting is
584    /// opt-in via `write`: when `false` nothing is written and the function only
585    /// reports how many entries would be removed (a dry run). When `true` the
586    /// reflog is rewritten atomically (lock file + rename) only if at least one
587    /// entry was removed; an unchanged reflog is left untouched. Returns the
588    /// number of entries removed.
589    pub fn expire_reflog_file(
590        &self,
591        name: &str,
592        cutoff_unix: i64,
593        expire_unreachable_cutoff: Option<i64>,
594        write: bool,
595        is_reachable: impl Fn(&ObjectId) -> bool,
596    ) -> Result<usize> {
597        validate_ref_name(name)?;
598        let path = self.reflog_path(name);
599        if !path.exists() {
600            return Ok(0);
601        }
602        let entries = parse_reflog(self.format, &fs::read(&path)?)?;
603        let original_len = entries.len();
604        let retained = expire_reflog(
605            &entries,
606            cutoff_unix,
607            expire_unreachable_cutoff,
608            is_reachable,
609        )?;
610        let removed = original_len - retained.len();
611        if write && removed > 0 {
612            let mut bytes = Vec::new();
613            for entry in &retained {
614                bytes.extend_from_slice(&entry.to_line());
615            }
616            write_locked(&path, &bytes)?;
617        }
618        Ok(removed)
619    }
620
621    pub fn list_refs(&self) -> Result<Vec<Ref>> {
622        if self.uses_reftable()? {
623            return self.list_reftable_refs();
624        }
625        let mut refs = BTreeMap::new();
626        let packed_path = self.common_dir.join("packed-refs");
627        if packed_path.exists() {
628            for packed in parse_packed_refs(self.format, &fs::read(packed_path)?)? {
629                refs.insert(packed.reference.name.clone(), packed.reference);
630            }
631        }
632        let refs_dir = self.common_dir.join("refs");
633        if refs_dir.exists() {
634            self.collect_loose_refs(&refs_dir, "refs", &mut refs)?;
635        }
636        Ok(refs.into_values().collect())
637    }
638
639    pub fn write_packed_refs(&self, refs: &[PackedRef]) -> Result<()> {
640        write_locked(
641            &self.common_dir.join("packed-refs"),
642            &write_packed_refs(refs)?,
643        )
644    }
645
646    pub fn pack_refs(&self, prune_loose: bool) -> Result<Vec<PackedRef>> {
647        self.pack_refs_with_peeler(prune_loose, |_, _| Ok(None))
648    }
649
650    pub fn pack_refs_with_peeler<F>(&self, prune_loose: bool, mut peel: F) -> Result<Vec<PackedRef>>
651    where
652        F: FnMut(&str, &ObjectId) -> Result<Option<ObjectId>>,
653    {
654        let mut packed_refs = BTreeMap::new();
655        let packed_path = self.common_dir.join("packed-refs");
656        if packed_path.exists() {
657            for packed in parse_packed_refs(self.format, &fs::read(&packed_path)?)? {
658                packed_refs.insert(packed.reference.name.clone(), packed);
659            }
660        }
661
662        let mut loose_refs = BTreeMap::new();
663        let refs_dir = self.common_dir.join("refs");
664        if refs_dir.exists() {
665            self.collect_loose_refs(&refs_dir, "refs", &mut loose_refs)?;
666        }
667        let mut packed_loose_names = Vec::new();
668        for reference in loose_refs.into_values() {
669            let RefTarget::Direct(oid) = reference.target else {
670                continue;
671            };
672            let peeled = peel(&reference.name, &oid)?;
673            packed_loose_names.push(reference.name.clone());
674            packed_refs.insert(
675                reference.name.clone(),
676                PackedRef {
677                    reference: Ref {
678                        name: reference.name,
679                        target: RefTarget::Direct(oid),
680                    },
681                    peeled,
682                },
683            );
684        }
685
686        let refs = packed_refs.into_values().collect::<Vec<_>>();
687        self.write_packed_refs(&refs)?;
688        if prune_loose {
689            for name in packed_loose_names {
690                self.delete_loose_ref(&name)?;
691            }
692        }
693        Ok(refs)
694    }
695
696    pub fn current_branch_ref(&self) -> Result<Option<String>> {
697        match self.read_ref("HEAD")? {
698            Some(RefTarget::Symbolic(name)) if name.starts_with("refs/heads/") => Ok(Some(name)),
699            _ => Ok(None),
700        }
701    }
702
703    pub fn current_branch(&self) -> Result<Option<String>> {
704        Ok(self
705            .current_branch_ref()?
706            .and_then(|name| name.strip_prefix("refs/heads/").map(str::to_string)))
707    }
708
709    pub fn transaction(&self) -> FileRefTransaction<'_> {
710        FileRefTransaction {
711            store: self,
712            changes: Vec::new(),
713        }
714    }
715
716    pub fn create_branch(
717        &self,
718        branch: &str,
719        start: ObjectId,
720        committer: Vec<u8>,
721        message: Vec<u8>,
722    ) -> Result<BranchCreate> {
723        let name = branch_ref_name(branch)?;
724        if self.read_ref(&name)?.is_some() {
725            return Err(GitError::Transaction(format!(
726                "branch {branch} already exists"
727            )));
728        }
729        let zero = ObjectId::null(self.format);
730        let mut tx = self.transaction();
731        tx.update(RefUpdate {
732            name: name.clone(),
733            expected: None,
734            new: RefTarget::Direct(start),
735            reflog: Some(ReflogEntry {
736                old_oid: zero,
737                new_oid: start,
738                committer,
739                message,
740            }),
741        });
742        tx.commit()?;
743        Ok(BranchCreate { name, oid: start })
744    }
745
746    pub fn delete_branch(&self, branch: &str) -> Result<BranchDelete> {
747        let name = branch_ref_name(branch)?;
748        if matches!(self.read_ref("HEAD")?, Some(RefTarget::Symbolic(head)) if head == name) {
749            return Err(GitError::Transaction(format!(
750                "cannot delete branch {branch} checked out at HEAD"
751            )));
752        }
753        let oid = self.delete_direct_ref(&name, "branch", branch)?;
754        self.remove_reflog_file(&name);
755        Ok(BranchDelete { name, oid })
756    }
757
758    pub fn move_branch(
759        &self,
760        old_branch: &str,
761        new_branch: &str,
762        force: bool,
763        committer: Vec<u8>,
764    ) -> Result<()> {
765        self.copy_or_move_branch(old_branch, new_branch, force, false, committer)
766    }
767
768    pub fn copy_branch(
769        &self,
770        old_branch: &str,
771        new_branch: &str,
772        force: bool,
773        committer: Vec<u8>,
774    ) -> Result<()> {
775        self.copy_or_move_branch(old_branch, new_branch, force, true, committer)
776    }
777
778    /// Find an existing ref (other than `exclude`) that would have a
779    /// directory/file conflict with creating `new_name`: either an existing ref
780    /// is a path-prefix of `new_name` (it occupies a directory component
781    /// `new_name` needs), or `new_name` is a path-prefix of an existing ref
782    /// (`new_name` would occupy a directory another ref needs). Returns the
783    /// conflicting ref name.
784    fn conflicting_ref_for_path(&self, new_name: &str, exclude: &str) -> Result<Option<String>> {
785        for reference in self.list_refs()? {
786            let name = &reference.name;
787            if name == new_name || name == exclude {
788                continue;
789            }
790            // `name` sits above `new_name`: name = refs/heads/r, new = refs/heads/r/q
791            if new_name.starts_with(&format!("{name}/")) {
792                return Ok(Some(name.clone()));
793            }
794            // `name` sits below `new_name`: new = refs/heads/r, name = refs/heads/r/q
795            if name.starts_with(&format!("{new_name}/")) {
796                return Ok(Some(name.clone()));
797            }
798        }
799        Ok(None)
800    }
801
802    fn copy_or_move_branch(
803        &self,
804        old_branch: &str,
805        new_branch: &str,
806        force: bool,
807        copy: bool,
808        committer: Vec<u8>,
809    ) -> Result<()> {
810        let old_name = branch_ref_name(old_branch)?;
811        let new_name = branch_ref_name(new_branch)?;
812        if old_name == new_name {
813            return Ok(());
814        }
815        let Some(target) = self.read_ref(&old_name)? else {
816            return Err(GitError::reference_not_found(format!(
817                "branch {old_branch}"
818            )));
819        };
820        let RefTarget::Direct(oid) = target else {
821            return Err(GitError::InvalidFormat(format!(
822                "branch {old_branch} is symbolic"
823            )));
824        };
825        // Detect a directory/file conflict against some *other* ref before
826        // mutating anything (git's rename_ref fails up front, leaving the old
827        // branch intact): e.g. renaming `q` -> `r/q` while `r` exists, or `q` ->
828        // `r` while `r/x` exists. The old ref itself is excluded because a
829        // self-nesting rename (`m` -> `m/m`) is handled by removing it first.
830        if let Some(conflict) = self.conflicting_ref_for_path(&new_name, &old_name)? {
831            return Err(GitError::Transaction(format!(
832                "'{conflict}' exists; cannot create '{new_name}'"
833            )));
834        }
835        // git's validate_branchname uses refs_ref_exists (RESOLVE_REF_READING):
836        // a *dangling* symref destination does not "exist", so a rename onto it
837        // proceeds without --force and overwrites the symref file (t3200 #16).
838        let dest_entry = self.read_ref(&new_name)?;
839        let dest_resolves = resolve_ref_peeled(self, &new_name)?.is_some();
840        if dest_resolves && !force {
841            return Err(GitError::Transaction(format!(
842                "branch {new_branch} already exists"
843            )));
844        }
845        // Remove any existing destination ref (direct or symbolic) before
846        // writing. A dangling symref must be removed as a symref; a real branch
847        // as a direct ref.
848        match dest_entry {
849            Some(RefTarget::Symbolic(_)) => {
850                self.delete_symbolic_ref(&new_name)?;
851                self.remove_reflog_file(&new_name);
852            }
853            Some(RefTarget::Direct(_)) => {
854                let _ = self.delete_direct_ref(&new_name, "branch", new_branch)?;
855                self.remove_reflog_file(&new_name);
856            }
857            None => {}
858        }
859
860        // Capture the old reflog before removing anything; it is carried over
861        // to the new ref.
862        let mut reflog = self.read_reflog(&old_name)?;
863        reflog.push(ReflogEntry {
864            old_oid: oid,
865            new_oid: oid,
866            committer,
867            message: if copy {
868                format!("Branch: copied {old_name} to {new_name}").into_bytes()
869            } else {
870                format!("Branch: renamed {old_name} to {new_name}").into_bytes()
871            },
872        });
873
874        // A directory/file conflict can occur when the new ref's path nests
875        // under the old ref (`m` -> `m/m`) or vice-versa; remove the old loose
876        // ref AND its reflog first so neither file blocks creating the new
877        // directory under refs/ or logs/refs/ (t3200 #17, #18).
878        if !copy {
879            let _ = self.delete_direct_ref(&old_name, "branch", old_branch)?;
880            self.remove_reflog_file(&old_name);
881        }
882
883        self.write_loose_ref(&Ref {
884            name: new_name.clone(),
885            target: RefTarget::Direct(oid),
886        })?;
887        self.write_reflog(&new_name, &reflog)?;
888
889        if !copy
890            && matches!(self.read_ref("HEAD")?, Some(RefTarget::Symbolic(head)) if head == old_name)
891        {
892            self.write_loose_ref(&Ref {
893                name: "HEAD".into(),
894                target: RefTarget::Symbolic(new_name),
895            })?;
896        }
897        Ok(())
898    }
899
900    pub fn create_tag(&self, tag: &str, target: ObjectId) -> Result<TagCreate> {
901        let name = tag_ref_name(tag)?;
902        if self.read_ref(&name)?.is_some() {
903            return Err(GitError::Transaction(format!("tag {tag} already exists")));
904        }
905        let mut tx = self.transaction();
906        tx.update(RefUpdate {
907            name: name.clone(),
908            expected: None,
909            new: RefTarget::Direct(target),
910            reflog: None,
911        });
912        tx.commit()?;
913        Ok(TagCreate { name, oid: target })
914    }
915
916    pub fn apply_bundle_ref_updates(
917        &self,
918        refs: &[BundleRefUpdate],
919        reflog: Option<BundleRefUpdateReflog>,
920    ) -> Result<Vec<AppliedBundleRefUpdate>> {
921        let (updates, applied) = prepare_bundle_ref_updates(refs, reflog.as_ref(), |name, oid| {
922            if oid.format() != self.format {
923                return Err(GitError::InvalidObjectId(format!(
924                    "bundle ref {name} has {} object id for {} repository",
925                    oid.format().name(),
926                    self.format.name()
927                )));
928            }
929            self.read_ref(name)
930        })?;
931        let mut tx = self.transaction();
932        for update in updates {
933            tx.update(update);
934        }
935        tx.commit()?;
936        Ok(applied)
937    }
938
939    pub fn delete_tag(&self, tag: &str) -> Result<TagDelete> {
940        let name = TagRefNameBuf::from_tag_name_unrestricted(tag)?.into_string();
941        let oid = self.delete_direct_ref(&name, "tag", tag)?;
942        Ok(TagDelete { name, oid })
943    }
944
945    pub fn delete_ref(&self, name: &str) -> Result<RefDelete> {
946        validate_ref_name(name)?;
947        let oid = self.delete_direct_ref(name, "ref", name)?;
948        self.remove_reflog_file(name);
949        Ok(RefDelete {
950            name: name.into(),
951            oid,
952        })
953    }
954
955    pub fn delete_ref_checked(
956        &self,
957        delete: DeleteRef,
958    ) -> std::result::Result<RefDelete, RefDeleteError> {
959        validate_ref_name(&delete.name).map_err(|_| RefDeleteError::InvalidName)?;
960        if self.uses_reftable().map_err(ref_delete_error_from_git)? {
961            return self.delete_reftable_ref_checked(delete);
962        }
963        self.delete_files_ref_checked(delete)
964    }
965
966    pub fn delete_symbolic_ref(&self, name: &str) -> Result<bool> {
967        validate_ref_name_for_read(name)?;
968        if self.uses_reftable()? {
969            let Some(target) = self.read_ref(name)? else {
970                return Ok(false);
971            };
972            if !matches!(target, RefTarget::Symbolic(_)) {
973                return Ok(false);
974            }
975            self.append_reftable_records(vec![ReftableRefRecord {
976                name: name.to_string(),
977                update_index: 0,
978                value: ReftableRefValue::Deletion,
979            }])?;
980            self.remove_reflog_file(name);
981            return Ok(true);
982        }
983        let Some(reference) = self.read_loose_ref(name)? else {
984            return Ok(false);
985        };
986        if !matches!(reference.target, RefTarget::Symbolic(_)) {
987            return Ok(false);
988        }
989        self.delete_loose_ref(name)?;
990        self.remove_reflog_file(name);
991        Ok(true)
992    }
993
994    fn delete_direct_ref(&self, name: &str, kind: &str, short_name: &str) -> Result<ObjectId> {
995        if self.uses_reftable()? {
996            let Some(target) = self.read_ref(name)? else {
997                return Err(GitError::reference_not_found(format!(
998                    "{kind} {short_name}"
999                )));
1000            };
1001            let RefTarget::Direct(oid) = target else {
1002                return Err(GitError::InvalidFormat(format!(
1003                    "{kind} {short_name} is symbolic"
1004                )));
1005            };
1006            self.append_reftable_records(vec![ReftableRefRecord {
1007                name: name.to_string(),
1008                update_index: 0,
1009                value: ReftableRefValue::Deletion,
1010            }])?;
1011            return Ok(oid);
1012        }
1013        let Some(reference) = self.read_loose_ref(name)? else {
1014            return self.delete_packed_ref(name, kind, short_name);
1015        };
1016        let oid = match reference.target {
1017            RefTarget::Direct(oid) => oid,
1018            RefTarget::Symbolic(target) => {
1019                return Err(GitError::InvalidFormat(format!(
1020                    "{kind} {short_name} is symbolic to {target}"
1021                )));
1022            }
1023        };
1024        self.delete_loose_ref(name)?;
1025        Ok(oid)
1026    }
1027
1028    fn delete_packed_ref(&self, name: &str, kind: &str, short_name: &str) -> Result<ObjectId> {
1029        let path = self.common_dir.join("packed-refs");
1030        if !path.exists() {
1031            return Err(GitError::reference_not_found(format!(
1032                "{kind} {short_name}"
1033            )));
1034        }
1035        let mut refs = parse_packed_refs(self.format, &fs::read(&path)?)?;
1036        let Some(index) = refs
1037            .iter()
1038            .position(|reference| reference.reference.name == name)
1039        else {
1040            return Err(GitError::reference_not_found(format!(
1041                "{kind} {short_name}"
1042            )));
1043        };
1044        let removed = refs.remove(index);
1045        let RefTarget::Direct(oid) = removed.reference.target else {
1046            return Err(GitError::InvalidFormat(format!(
1047                "{kind} {short_name} is symbolic"
1048            )));
1049        };
1050        self.write_packed_refs(&refs)?;
1051        Ok(oid)
1052    }
1053
1054    fn delete_reftable_ref_checked(
1055        &self,
1056        delete: DeleteRef,
1057    ) -> std::result::Result<RefDelete, RefDeleteError> {
1058        let target = self
1059            .read_ref(&delete.name)
1060            .map_err(ref_delete_error_from_git)?;
1061        let oid = checked_delete_oid(delete.expected_old, target)?;
1062        self.append_reftable_records(vec![ReftableRefRecord {
1063            name: delete.name.clone(),
1064            update_index: 0,
1065            value: ReftableRefValue::Deletion,
1066        }])
1067        .map_err(ref_delete_error_from_git)?;
1068        // Git unlinks logs/refs/<name> on delete (pruning now-empty dirs); it
1069        // does not keep a deletion entry. Mirror delete_ref / delete_branch.
1070        self.remove_reflog_file(&delete.name);
1071        Ok(RefDelete {
1072            name: delete.name,
1073            oid,
1074        })
1075    }
1076
1077    fn delete_files_ref_checked(
1078        &self,
1079        delete: DeleteRef,
1080    ) -> std::result::Result<RefDelete, RefDeleteError> {
1081        let name = delete.name;
1082        let path = self.ref_path(&name);
1083        let parent = path.parent().ok_or(RefDeleteError::InvalidName)?;
1084        fs::create_dir_all(parent).map_err(RefDeleteError::from)?;
1085
1086        let loose_lock_path = lock_path_for(&path).map_err(|_| RefDeleteError::InvalidName)?;
1087        let _prune_guard = RefDirPruneGuard {
1088            store: self,
1089            name: name.clone(),
1090        };
1091        let loose_lock = DeleteLock::acquire(loose_lock_path)?;
1092
1093        let packed_path = self.common_dir.join("packed-refs");
1094        let packed_lock_path =
1095            lock_path_for(&packed_path).map_err(|_| RefDeleteError::InvalidName)?;
1096        let mut packed_lock = DeleteLock::acquire(packed_lock_path)?;
1097
1098        let loose_ref = self
1099            .read_loose_ref(&name)
1100            .map_err(ref_delete_error_from_git)?;
1101        let packed_original = match fs::read(&packed_path) {
1102            Ok(bytes) => Some(bytes),
1103            Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
1104            Err(err) => return Err(RefDeleteError::Io(err)),
1105        };
1106        let mut packed_refs = match &packed_original {
1107            Some(bytes) => {
1108                parse_packed_refs(self.format, bytes).map_err(ref_delete_error_from_git)?
1109            }
1110            None => Vec::new(),
1111        };
1112        let packed_index = packed_refs
1113            .iter()
1114            .position(|reference| reference.reference.name == name);
1115
1116        let current = if let Some(reference) = loose_ref.as_ref() {
1117            Some(reference.target.clone())
1118        } else {
1119            packed_index.map(|index| packed_refs[index].reference.target.clone())
1120        };
1121        let oid = checked_delete_oid(delete.expected_old, current)?;
1122
1123        let packed_changed = if let Some(index) = packed_index {
1124            packed_refs.remove(index);
1125            true
1126        } else {
1127            false
1128        };
1129
1130        if packed_changed {
1131            let packed_bytes =
1132                write_packed_refs(&packed_refs).map_err(ref_delete_error_from_git)?;
1133            packed_lock.write_all(&packed_bytes)?;
1134            let lock_path = packed_lock.close();
1135            if let Err(err) = fs::rename(&lock_path, &packed_path) {
1136                let _ = fs::remove_file(&lock_path);
1137                return Err(RefDeleteError::Io(err));
1138            }
1139        } else {
1140            packed_lock.remove();
1141        }
1142
1143        if loose_ref.is_some()
1144            && let Err(err) = fs::remove_file(&path)
1145        {
1146            if packed_changed && let Some(bytes) = packed_original.as_ref() {
1147                let _ = restore_file_atomically(&packed_path, bytes);
1148            }
1149            return Err(RefDeleteError::Io(err));
1150        }
1151        loose_lock.remove();
1152
1153        // Git unlinks logs/refs/<name> on delete (pruning now-empty dirs); it
1154        // does not keep a deletion entry. Mirror delete_ref / delete_branch.
1155        self.remove_reflog_file(&name);
1156        Ok(RefDelete { name, oid })
1157    }
1158
1159    fn read_loose_ref(&self, name: &str) -> Result<Option<Ref>> {
1160        let path = self.ref_path(name);
1161        if !path.exists() {
1162            return Ok(None);
1163        }
1164        if path.is_dir() {
1165            return Ok(None);
1166        }
1167        Ok(Some(parse_loose_ref(self.format, name, &fs::read(path)?)?))
1168    }
1169
1170    fn read_packed_ref(&self, name: &str) -> Result<Option<PackedRef>> {
1171        let path = self.common_dir.join("packed-refs");
1172        if !path.exists() {
1173            return Ok(None);
1174        }
1175        Ok(parse_packed_refs(self.format, &fs::read(path)?)?
1176            .into_iter()
1177            .find(|reference| reference.reference.name == name))
1178    }
1179
1180    fn read_reftable_ref(&self, name: &str) -> Result<Option<RefTarget>> {
1181        for table in self.reftables()?.into_iter().rev() {
1182            if let Some(record) = table.refs.into_iter().find(|record| record.name == name) {
1183                return reftable_ref_target(record.value);
1184            }
1185        }
1186        Ok(None)
1187    }
1188
1189    fn list_reftable_refs(&self) -> Result<Vec<Ref>> {
1190        let mut refs = BTreeMap::<String, Ref>::new();
1191        for table in self.reftables()? {
1192            for record in table.refs {
1193                if !record.name.starts_with("refs/") {
1194                    continue;
1195                }
1196                match reftable_ref_target(record.value)? {
1197                    Some(target) => {
1198                        refs.insert(
1199                            record.name.clone(),
1200                            Ref {
1201                                name: record.name,
1202                                target,
1203                            },
1204                        );
1205                    }
1206                    None => {
1207                        refs.remove(&record.name);
1208                    }
1209                }
1210            }
1211        }
1212        Ok(refs.into_values().collect())
1213    }
1214
1215    fn reftables(&self) -> Result<Vec<Reftable>> {
1216        let reftable_dir = self.common_dir.join("reftable");
1217        let tables_list = reftable_dir.join("tables.list");
1218        if !tables_list.exists() {
1219            return Ok(Vec::new());
1220        }
1221        let text = fs::read_to_string(&tables_list)?;
1222        let mut tables = Vec::new();
1223        for raw_line in text.lines() {
1224            let line = raw_line.trim();
1225            if line.is_empty() {
1226                continue;
1227            }
1228            if line.contains('/')
1229                || line.contains('\\')
1230                || Path::new(line).components().count() != 1
1231            {
1232                return Err(GitError::InvalidPath(format!(
1233                    "invalid reftable table name {line}"
1234                )));
1235            }
1236            let table = Reftable::parse(&fs::read(reftable_dir.join(line))?)?;
1237            if table.header.object_format != self.format {
1238                return Err(GitError::InvalidFormat(format!(
1239                    "reftable {line} has {} object ids in {} repository",
1240                    table.header.object_format.name(),
1241                    self.format.name()
1242                )));
1243            }
1244            tables.push(table);
1245        }
1246        Ok(tables)
1247    }
1248
1249    fn uses_reftable(&self) -> Result<bool> {
1250        let config_path = self.common_dir.join("config");
1251        if !config_path.exists() {
1252            return Ok(false);
1253        }
1254        let config = GitConfig::parse(&fs::read(config_path)?)?;
1255        Ok(matches!(
1256            config.get("extensions", None, "refStorage"),
1257            Some(value) if value.eq_ignore_ascii_case("reftable")
1258        ))
1259    }
1260
1261    fn append_reftable_records(&self, mut records: Vec<ReftableRefRecord>) -> Result<()> {
1262        if records.is_empty() {
1263            return Ok(());
1264        }
1265        let reftable_dir = self.common_dir.join("reftable");
1266        fs::create_dir_all(&reftable_dir)?;
1267        let tables_list = reftable_dir.join("tables.list");
1268        let mut table_names = if tables_list.exists() {
1269            fs::read_to_string(&tables_list)?
1270                .lines()
1271                .map(str::trim)
1272                .filter(|line| !line.is_empty())
1273                .map(str::to_string)
1274                .collect::<Vec<_>>()
1275        } else {
1276            Vec::new()
1277        };
1278        let update_index = self.next_reftable_update_index(&table_names)?;
1279        for record in &mut records {
1280            record.update_index = update_index;
1281        }
1282        let table_name = reftable_table_name(update_index);
1283        let bytes = Reftable::write_ref_only(self.format, update_index, update_index, &records)?;
1284        write_locked(&reftable_dir.join(&table_name), &bytes)?;
1285        table_names.push(table_name);
1286        let mut list = Vec::new();
1287        for name in &table_names {
1288            list.extend_from_slice(name.as_bytes());
1289            list.push(b'\n');
1290        }
1291        write_locked(&tables_list, &list)
1292    }
1293
1294    fn next_reftable_update_index(&self, table_names: &[String]) -> Result<u64> {
1295        let reftable_dir = self.common_dir.join("reftable");
1296        let mut max_update_index = 0;
1297        for name in table_names {
1298            let table = Reftable::parse(&fs::read(reftable_dir.join(name))?)?;
1299            max_update_index = max_update_index.max(table.header.max_update_index);
1300        }
1301        max_update_index
1302            .checked_add(1)
1303            .ok_or_else(|| GitError::InvalidFormat("reftable update index overflow".into()))
1304    }
1305
1306    fn collect_loose_refs(
1307        &self,
1308        dir: &Path,
1309        prefix: &str,
1310        refs: &mut BTreeMap<String, Ref>,
1311    ) -> Result<()> {
1312        for entry in fs::read_dir(dir)? {
1313            let entry = entry?;
1314            let path = entry.path();
1315            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
1316            if path.is_dir() {
1317                self.collect_loose_refs(&path, &name, refs)?;
1318            } else if !name.ends_with(".lock") {
1319                let reference = parse_loose_ref(self.format, name.clone(), &fs::read(path)?)?;
1320                refs.insert(name, reference);
1321            }
1322        }
1323        Ok(())
1324    }
1325
1326    fn write_loose_ref(&self, reference: &Ref) -> Result<()> {
1327        if self.uses_reftable()? {
1328            self.append_reftable_records(vec![ReftableRefRecord {
1329                name: reference.name.clone(),
1330                update_index: 0,
1331                value: reftable_value_from_ref_target(&reference.target),
1332            }])?;
1333            return Ok(());
1334        }
1335        let path = self.ref_path(&reference.name);
1336        let parent = path
1337            .parent()
1338            .ok_or_else(|| GitError::InvalidPath("ref path has no parent".into()))?;
1339        fs::create_dir_all(parent)?;
1340        write_locked(&path, &write_loose_ref(reference))
1341    }
1342
1343    fn delete_loose_ref(&self, name: &str) -> Result<()> {
1344        let path = self.ref_path(name);
1345        let lock_path = lock_path_for(&path)?;
1346        {
1347            let mut file = fs::OpenOptions::new()
1348                .write(true)
1349                .create_new(true)
1350                .open(&lock_path)?;
1351            file.write_all(b"delete\n")?;
1352            file.sync_all()?;
1353        }
1354        match fs::remove_file(&path) {
1355            Ok(()) => {
1356                fs::remove_file(lock_path)?;
1357                self.prune_empty_ref_dirs(name);
1358                Ok(())
1359            }
1360            Err(err) => {
1361                let _ = fs::remove_file(lock_path);
1362                Err(GitError::Io(err.to_string()))
1363            }
1364        }
1365    }
1366
1367    /// Remove now-empty parent directories left after deleting a loose ref,
1368    /// stopping at the `refs/` boundary. git does this so that, e.g., deleting
1369    /// `refs/heads/l/m` lets `refs/heads/l` be created as a file afterwards
1370    /// (t3200 #14). Pruning stops at the first non-empty directory and never
1371    /// removes the `refs` directory itself.
1372    fn prune_empty_ref_dirs(&self, name: &str) {
1373        let base = self.ref_base_dir(name).to_path_buf();
1374        let refs_root = base.join("refs");
1375        if let Some(parent) = self.ref_path(name).parent() {
1376            prune_empty_dirs_up_to(parent, &refs_root);
1377        }
1378    }
1379
1380    /// Remove a ref's reflog file and prune any empty parent directories it
1381    /// leaves behind under `logs/refs/`, stopping at the `logs/refs` boundary.
1382    /// Without this, deleting `refs/heads/l/m` leaves `logs/refs/heads/l/` and a
1383    /// later `refs/heads/l` cannot create its own `logs/refs/heads/l` reflog
1384    /// file (t3200 #14, #18).
1385    fn remove_reflog_file(&self, name: &str) {
1386        let path = self.reflog_path(name);
1387        let _ = fs::remove_file(&path);
1388        let base = self.ref_base_dir(name).to_path_buf();
1389        let logs_refs_root = base.join("logs").join("refs");
1390        if let Some(parent) = path.parent() {
1391            prune_empty_dirs_up_to(parent, &logs_refs_root);
1392        }
1393    }
1394
1395    pub fn append_reflog(&self, name: &str, entry: &ReflogEntry) -> Result<()> {
1396        validate_ref_name_for_read(name)?;
1397        let path = self.reflog_path(name);
1398        let parent = path
1399            .parent()
1400            .ok_or_else(|| GitError::InvalidPath("reflog path has no parent".into()))?;
1401        fs::create_dir_all(parent)?;
1402        let mut file = fs::OpenOptions::new()
1403            .create(true)
1404            .append(true)
1405            .open(path)?;
1406        file.write_all(&entry.to_line())?;
1407        file.sync_all()?;
1408        Ok(())
1409    }
1410
1411    fn ref_path(&self, name: &str) -> PathBuf {
1412        self.ref_base_dir(name).join(name)
1413    }
1414
1415    fn reflog_path(&self, name: &str) -> PathBuf {
1416        self.ref_base_dir(name).join("logs").join(name)
1417    }
1418
1419    fn ref_base_dir(&self, name: &str) -> &Path {
1420        if name == "HEAD" {
1421            &self.git_dir
1422        } else {
1423            &self.common_dir
1424        }
1425    }
1426
1427    fn check_ref_directory_conflict(&self, name: &str) -> Result<()> {
1428        let components = name.split('/').collect::<Vec<_>>();
1429        for index in 1..components.len() {
1430            let ancestor = components[..index].join("/");
1431            if self.read_ref_unchecked(&ancestor)?.is_some() {
1432                return Err(ref_directory_conflict_error(name, &ancestor));
1433            }
1434        }
1435        let child_prefix = format!("{name}/");
1436        for reference in self.list_refs()? {
1437            if reference.name.starts_with(&child_prefix) {
1438                return Err(ref_directory_conflict_error(name, &reference.name));
1439            }
1440        }
1441        Ok(())
1442    }
1443}
1444
1445fn reftable_ref_target(value: ReftableRefValue) -> Result<Option<RefTarget>> {
1446    match value {
1447        ReftableRefValue::Deletion => Ok(None),
1448        ReftableRefValue::Direct(oid) | ReftableRefValue::Peeled { target: oid, .. } => {
1449            Ok(Some(RefTarget::Direct(oid)))
1450        }
1451        ReftableRefValue::Symbolic(target) => Ok(Some(RefTarget::Symbolic(target))),
1452    }
1453}
1454
1455fn reftable_value_from_ref_target(target: &RefTarget) -> ReftableRefValue {
1456    match target {
1457        RefTarget::Direct(oid) => ReftableRefValue::Direct(*oid),
1458        RefTarget::Symbolic(target) => ReftableRefValue::Symbolic(target.clone()),
1459    }
1460}
1461
1462fn reftable_table_name(update_index: u64) -> String {
1463    let nanos = SystemTime::now()
1464        .duration_since(UNIX_EPOCH)
1465        .map(|duration| duration.as_nanos())
1466        .unwrap_or(0);
1467    format!("0x{update_index:012x}-0x{update_index:012x}-sley-{nanos:x}.ref")
1468}
1469
1470fn repository_common_dir(git_dir: &Path) -> PathBuf {
1471    if let Some(common_dir) = std::env::var_os("GIT_COMMON_DIR") {
1472        return PathBuf::from(common_dir);
1473    }
1474    let commondir = git_dir.join("commondir");
1475    if let Ok(value) = fs::read_to_string(&commondir) {
1476        let path = PathBuf::from(value.trim());
1477        let common = if path.is_absolute() {
1478            path
1479        } else {
1480            git_dir.join(path)
1481        };
1482        return fs::canonicalize(&common).unwrap_or(common);
1483    }
1484    git_dir.to_path_buf()
1485}
1486
1487pub struct FileRefTransaction<'a> {
1488    store: &'a FileRefStore,
1489    changes: Vec<QueuedRefChange>,
1490}
1491
1492/// One queued update inside a [`FileRefTransaction`], carrying the
1493/// compare-and-swap precondition to enforce under lock.
1494struct QueuedUpdate {
1495    name: String,
1496    precondition: RefPrecondition,
1497    new: RefTarget,
1498    reflog: Option<ReflogEntry>,
1499}
1500
1501struct QueuedDelete {
1502    name: String,
1503    precondition: RefDeletePrecondition,
1504}
1505
1506enum QueuedRefChange {
1507    Update(QueuedUpdate),
1508    Delete(QueuedDelete),
1509}
1510
1511/// The compare-and-delete precondition checked for a queued ref delete.
1512#[derive(Debug, Clone, PartialEq, Eq)]
1513pub enum RefDeletePrecondition {
1514    /// Any existing direct or symbolic ref may be deleted.
1515    Any,
1516    /// The ref's immediate target must match exactly.
1517    Immediate(RefTarget),
1518    /// The ref must be direct. When an object id is supplied, it must match.
1519    Direct(Option<ObjectId>),
1520    /// The ref may be symbolic, but its peeled direct target must match.
1521    Peeled(ObjectId),
1522}
1523
1524impl<'a> FileRefTransaction<'a> {
1525    /// Queue a ref update whose precondition comes from [`RefUpdate::expected`]
1526    /// (`None` = no check; `Some(target)` = the ref must currently match
1527    /// `target`). For create-only or match-or-create semantics use
1528    /// [`update_to`](FileRefTransaction::update_to).
1529    pub fn update(&mut self, update: RefUpdate) {
1530        self.changes.push(QueuedRefChange::Update(QueuedUpdate {
1531            name: update.name,
1532            precondition: RefPrecondition::from_expected(update.expected),
1533            new: update.new,
1534            reflog: update.reflog,
1535        }));
1536    }
1537
1538    /// Queue a ref update with an explicit compare-and-swap [`RefPrecondition`]
1539    /// (e.g. [`MustNotExist`](RefPrecondition::MustNotExist) for create-only, or
1540    /// [`ExistingMustMatch`](RefPrecondition::ExistingMustMatch) for
1541    /// match-or-create). The precondition is re-verified while the ref is
1542    /// locked.
1543    pub fn update_to(
1544        &mut self,
1545        name: impl Into<String>,
1546        new: RefTarget,
1547        precondition: RefPrecondition,
1548        reflog: Option<ReflogEntry>,
1549    ) {
1550        self.changes.push(QueuedRefChange::Update(QueuedUpdate {
1551            name: name.into(),
1552            precondition,
1553            new,
1554            reflog,
1555        }));
1556    }
1557
1558    /// Queue a direct ref delete using the historical checked-delete shape.
1559    ///
1560    /// `expected_old = None` means "delete any direct ref"; `Some(oid)` means
1561    /// the direct ref must currently point at that object id.
1562    pub fn delete(&mut self, delete: DeleteRef) {
1563        self.delete_with_precondition(
1564            delete.name,
1565            RefDeletePrecondition::Direct(delete.expected_old),
1566            delete.reflog,
1567        );
1568    }
1569
1570    /// Queue a ref delete with an explicit direct/symbolic precondition.
1571    ///
1572    /// `_reflog` is accepted for API compatibility but ignored: git unlinks the
1573    /// reflog on delete rather than writing a deletion entry, so a
1574    /// caller-supplied deletion message has no on-disk effect.
1575    pub fn delete_with_precondition(
1576        &mut self,
1577        name: impl Into<String>,
1578        precondition: RefDeletePrecondition,
1579        _reflog: Option<DeleteRefReflog>,
1580    ) {
1581        self.changes.push(QueuedRefChange::Delete(QueuedDelete {
1582            name: name.into(),
1583            precondition,
1584        }));
1585    }
1586
1587    /// Commit all queued updates and deletes atomically and durably.
1588    ///
1589    /// All ref changes succeed together or none take effect. For the loose-ref
1590    /// backend the sequence is:
1591    ///
1592    /// 1. Preserve the historical update-only coalescing behavior. Mixed
1593    ///    transactions reject duplicate ref names so a delete and write cannot
1594    ///    target the same ref ambiguously.
1595    /// 2. Take an exclusive `<ref>.lock` file for every ref up front, and lock
1596    ///    `packed-refs` before checked deletes can inspect or rewrite it.
1597    /// 3. Re-verify every precondition *while holding the locks*, closing
1598    ///    the check-then-write race that a pre-lock verification would leave open.
1599    /// 4. Stage every write, delete marker, and packed-refs rewrite.
1600    /// 5. Rename/remove staged paths, rolling back already-applied paths if a
1601    ///    later step fails.
1602    ///
1603    /// If any step fails, every path already changed in this commit is restored
1604    /// to the exact bytes it held beforehand (or removed if it did not exist),
1605    /// and all outstanding lock files are deleted. Reflog entries are appended
1606    /// only after every ref change has landed.
1607    pub fn commit(self) -> Result<()> {
1608        let FileRefTransaction { store, changes } = self;
1609        let changes = coalesce_ref_changes(changes)?;
1610        if store.uses_reftable()? {
1611            return store.commit_reftable(changes);
1612        }
1613        store.commit_loose(changes)
1614    }
1615}
1616
1617impl FileRefStore {
1618    fn commit_reftable(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
1619        let mut records = Vec::with_capacity(changes.len());
1620        let mut reflogs = Vec::new();
1621        let mut delete_names = Vec::new();
1622        for change in changes {
1623            match change {
1624                CoalescedRefChange::Update(update) => {
1625                    if !matches!(update.precondition, RefPrecondition::Any) {
1626                        let current = self.read_ref(&update.name)?;
1627                        if !update.precondition.is_satisfied_by(current.as_ref()) {
1628                            return Err(GitError::Transaction(
1629                                update.precondition.describe(&update.name),
1630                            ));
1631                        }
1632                    }
1633                    records.push(ReftableRefRecord {
1634                        name: update.name.clone(),
1635                        update_index: 0,
1636                        value: reftable_value_from_ref_target(&update.new),
1637                    });
1638                    for entry in update.reflog {
1639                        reflogs.push((update.name.clone(), entry));
1640                    }
1641                }
1642                CoalescedRefChange::Delete(delete) => {
1643                    let current = self.read_ref(&delete.name)?;
1644                    // Enforce the precondition; git unlinks logs/refs/<name> on
1645                    // delete rather than appending a deletion reflog entry, so the
1646                    // returned OID is unused.
1647                    verify_delete_precondition(
1648                        self,
1649                        &delete.name,
1650                        current.as_ref(),
1651                        &delete.precondition,
1652                    )?;
1653                    records.push(ReftableRefRecord {
1654                        name: delete.name.clone(),
1655                        update_index: 0,
1656                        value: ReftableRefValue::Deletion,
1657                    });
1658                    delete_names.push(delete.name.clone());
1659                }
1660            }
1661        }
1662        self.append_reftable_records(records)?;
1663        // Git unlinks logs/refs/<name> (pruning now-empty dirs) on delete; do
1664        // this before appending update reflogs so a delete+recreate does not race
1665        // the new ref's reflog file.
1666        for name in &delete_names {
1667            self.remove_reflog_file(name);
1668        }
1669        for (name, entry) in reflogs {
1670            self.append_reflog(&name, &entry)?;
1671        }
1672        Ok(())
1673    }
1674
1675    /// Atomic, all-or-nothing commit for the loose-ref backend. See
1676    /// [`FileRefTransaction::commit`] for the full ordering and rollback rules.
1677    fn commit_loose(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
1678        let has_delete = changes
1679            .iter()
1680            .any(|change| matches!(change, CoalescedRefChange::Delete(_)));
1681        let mut pending = Vec::with_capacity(changes.len() + usize::from(has_delete));
1682        // Acquire every lock first; bail (releasing what we hold) on any failure.
1683        for change in &changes {
1684            let name = change.name();
1685            if matches!(change, CoalescedRefChange::Update(_))
1686                && let Err(err) = self.check_ref_directory_conflict(name)
1687            {
1688                release_pending_locks(&pending);
1689                return Err(err);
1690            }
1691            let path = self.ref_path(name);
1692            let parent = path
1693                .parent()
1694                .ok_or_else(|| GitError::InvalidPath("ref path has no parent".into()))?;
1695            if let Err(err) = fs::create_dir_all(parent) {
1696                release_pending_locks(&pending);
1697                if err.kind() == std::io::ErrorKind::NotADirectory {
1698                    return Err(ref_directory_conflict_error(
1699                        name,
1700                        &parent_to_ref_name(self.ref_base_dir(name), parent),
1701                    ));
1702                }
1703                return Err(GitError::Io(err.to_string()));
1704            }
1705            let lock_path = match lock_path_for(&path) {
1706                Ok(lock_path) => lock_path,
1707                Err(err) => {
1708                    release_pending_locks(&pending);
1709                    return Err(err);
1710                }
1711            };
1712            if let Err(err) = fs::OpenOptions::new()
1713                .write(true)
1714                .create_new(true)
1715                .open(&lock_path)
1716            {
1717                release_pending_locks(&pending);
1718                return Err(GitError::Io(format!("could not lock ref {name}: {err}")));
1719            }
1720            let action = match change {
1721                CoalescedRefChange::Update(update) => PendingPathAction::Write {
1722                    contents: write_loose_ref(&Ref {
1723                        name: update.name.clone(),
1724                        target: update.new.clone(),
1725                    }),
1726                },
1727                CoalescedRefChange::Delete(_) => PendingPathAction::Delete,
1728            };
1729            pending.push(PendingPathChange {
1730                name: name.to_string(),
1731                path,
1732                lock_path,
1733                original: None,
1734                action,
1735            });
1736        }
1737
1738        let packed_path = self.common_dir.join("packed-refs");
1739        let mut packed_refs = Vec::new();
1740        if has_delete {
1741            let packed_lock_path = match lock_path_for(&packed_path) {
1742                Ok(lock_path) => lock_path,
1743                Err(err) => {
1744                    release_pending_locks(&pending);
1745                    return Err(err);
1746                }
1747            };
1748            if let Err(err) = fs::OpenOptions::new()
1749                .write(true)
1750                .create_new(true)
1751                .open(&packed_lock_path)
1752            {
1753                release_pending_locks(&pending);
1754                return Err(GitError::Io(format!("could not lock packed-refs: {err}")));
1755            }
1756            let packed_original = match fs::read(&packed_path) {
1757                Ok(bytes) => Some(bytes),
1758                Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
1759                Err(err) => {
1760                    release_pending_locks(&pending);
1761                    let _ = fs::remove_file(&packed_lock_path);
1762                    return Err(GitError::Io(err.to_string()));
1763                }
1764            };
1765            packed_refs = match &packed_original {
1766                Some(bytes) => match parse_packed_refs(self.format, bytes) {
1767                    Ok(refs) => refs,
1768                    Err(err) => {
1769                        release_pending_locks(&pending);
1770                        let _ = fs::remove_file(&packed_lock_path);
1771                        return Err(err);
1772                    }
1773                },
1774                None => Vec::new(),
1775            };
1776            pending.push(PendingPathChange {
1777                name: "packed-refs".into(),
1778                path: packed_path.clone(),
1779                lock_path: packed_lock_path,
1780                original: packed_original,
1781                action: PendingPathAction::ReleaseLock,
1782            });
1783        }
1784
1785        // Verify expectations under lock, then capture prior on-disk state for
1786        // rollback. Mixed transactions read packed refs from the snapshot held
1787        // behind packed-refs.lock so deletes cannot race a packed rewrite.
1788        let mut reflogs = Vec::new();
1789        let mut delete_names = BTreeSet::new();
1790        for index in 0..changes.len() {
1791            match &changes[index] {
1792                CoalescedRefChange::Update(update) => {
1793                    if !matches!(update.precondition, RefPrecondition::Any) {
1794                        let current = if has_delete {
1795                            match self.read_ref_from_locked_packed(&update.name, &packed_refs) {
1796                                Ok(current) => current,
1797                                Err(err) => {
1798                                    release_pending_locks(&pending);
1799                                    return Err(err);
1800                                }
1801                            }
1802                        } else {
1803                            match self.read_ref(&update.name) {
1804                                Ok(current) => current,
1805                                Err(err) => {
1806                                    release_pending_locks(&pending);
1807                                    return Err(err);
1808                                }
1809                            }
1810                        };
1811                        if !update.precondition.is_satisfied_by(current.as_ref()) {
1812                            release_pending_locks(&pending);
1813                            return Err(GitError::Transaction(
1814                                update.precondition.describe(&update.name),
1815                            ));
1816                        }
1817                    }
1818                    pending[index].original = match read_optional_file(&pending[index].path) {
1819                        Ok(original) => original,
1820                        Err(err) => {
1821                            release_pending_locks(&pending);
1822                            return Err(err);
1823                        }
1824                    };
1825                    for entry in &update.reflog {
1826                        reflogs.push((update.name.clone(), entry.clone()));
1827                    }
1828                }
1829                CoalescedRefChange::Delete(delete) => {
1830                    let state = match self.read_locked_ref_state(&delete.name, &packed_refs) {
1831                        Ok(state) => state,
1832                        Err(err) => {
1833                            release_pending_locks(&pending);
1834                            return Err(err);
1835                        }
1836                    };
1837                    // Enforce the delete precondition under lock; the returned
1838                    // OID is unused because git unlinks logs/refs/<name> on
1839                    // delete rather than appending a deletion reflog entry.
1840                    if let Err(err) = verify_delete_precondition(
1841                        self,
1842                        &delete.name,
1843                        state.current.as_ref(),
1844                        &delete.precondition,
1845                    ) {
1846                        release_pending_locks(&pending);
1847                        return Err(err);
1848                    }
1849                    pending[index].original = if state.has_loose {
1850                        match read_optional_file(&pending[index].path) {
1851                            Ok(original) => original,
1852                            Err(err) => {
1853                                release_pending_locks(&pending);
1854                                return Err(err);
1855                            }
1856                        }
1857                    } else {
1858                        None
1859                    };
1860                    delete_names.insert(delete.name.clone());
1861                }
1862            }
1863        }
1864
1865        if has_delete {
1866            let old_len = packed_refs.len();
1867            packed_refs.retain(|reference| !delete_names.contains(&reference.reference.name));
1868            if packed_refs.len() != old_len {
1869                let packed_bytes = match write_packed_refs(&packed_refs) {
1870                    Ok(bytes) => bytes,
1871                    Err(err) => {
1872                        release_pending_locks(&pending);
1873                        return Err(err);
1874                    }
1875                };
1876                if let Some(packed) = pending.last_mut() {
1877                    packed.action = PendingPathAction::Write {
1878                        contents: packed_bytes,
1879                    };
1880                }
1881            }
1882        }
1883
1884        // Stage every new value or delete marker into its lock file. Nothing has
1885        // been renamed or removed yet, so on failure we only drop lock files.
1886        for change in &pending {
1887            if let Err(err) = stage_pending_change(change) {
1888                release_pending_locks(&pending);
1889                return Err(err);
1890            }
1891        }
1892
1893        // Apply each staged path change; on failure restore paths already
1894        // changed and drop the remaining lock files.
1895        for index in 0..pending.len() {
1896            if let Err(err) = maybe_fail_loose_commit_action(index) {
1897                rollback_after_apply(&pending, index);
1898                return Err(err);
1899            }
1900            if let Err(err) = apply_pending_change(&pending[index]) {
1901                rollback_after_apply(&pending, index + 1);
1902                return Err(err);
1903            }
1904        }
1905
1906        for change in &pending {
1907            if matches!(change.action, PendingPathAction::Delete) && change.original.is_some() {
1908                self.prune_empty_ref_dirs(&change.name);
1909            }
1910        }
1911        // Git unlinks logs/refs/<name> (and prunes now-empty log dirs) on delete;
1912        // do this before appending update reflogs so a delete+recreate in the
1913        // same direction does not race the new ref's reflog file.
1914        for name in &delete_names {
1915            self.remove_reflog_file(name);
1916        }
1917        // All refs are durable; append reflogs last, matching git's ordering.
1918        for (name, entry) in reflogs {
1919            self.append_reflog(&name, &entry)?;
1920        }
1921        Ok(())
1922    }
1923
1924    fn read_ref_from_locked_packed(
1925        &self,
1926        name: &str,
1927        packed_refs: &[PackedRef],
1928    ) -> Result<Option<RefTarget>> {
1929        let state = self.read_locked_ref_state(name, packed_refs)?;
1930        Ok(state.current)
1931    }
1932
1933    fn read_locked_ref_state(
1934        &self,
1935        name: &str,
1936        packed_refs: &[PackedRef],
1937    ) -> Result<LockedRefState> {
1938        let loose = self.read_loose_ref(name)?;
1939        let packed_index = packed_refs
1940            .iter()
1941            .position(|reference| reference.reference.name == name);
1942        let current = if let Some(reference) = loose.as_ref() {
1943            Some(reference.target.clone())
1944        } else {
1945            packed_index.map(|index| packed_refs[index].reference.target.clone())
1946        };
1947        Ok(LockedRefState {
1948            current,
1949            has_loose: loose.is_some(),
1950        })
1951    }
1952}
1953
1954struct LockedRefState {
1955    current: Option<RefTarget>,
1956    has_loose: bool,
1957}
1958
1959enum CoalescedRefChange {
1960    Update(CoalescedRefUpdate),
1961    Delete(CoalescedRefDelete),
1962}
1963
1964impl CoalescedRefChange {
1965    fn name(&self) -> &str {
1966        match self {
1967            Self::Update(update) => &update.name,
1968            Self::Delete(delete) => &delete.name,
1969        }
1970    }
1971}
1972
1973/// A ref update with all writes that targeted the same name folded together.
1974struct CoalescedRefUpdate {
1975    name: String,
1976    precondition: RefPrecondition,
1977    new: RefTarget,
1978    reflog: Vec<ReflogEntry>,
1979}
1980
1981struct CoalescedRefDelete {
1982    name: String,
1983    precondition: RefDeletePrecondition,
1984}
1985
1986fn coalesce_ref_changes(changes: Vec<QueuedRefChange>) -> Result<Vec<CoalescedRefChange>> {
1987    let has_delete = changes
1988        .iter()
1989        .any(|change| matches!(change, QueuedRefChange::Delete(_)));
1990    if !has_delete {
1991        let updates = changes
1992            .into_iter()
1993            .map(|change| match change {
1994                QueuedRefChange::Update(update) => update,
1995                QueuedRefChange::Delete(_) => unreachable!("has_delete was false"),
1996            })
1997            .collect::<Vec<_>>();
1998        return coalesce_ref_updates(updates).map(|updates| {
1999            updates
2000                .into_iter()
2001                .map(CoalescedRefChange::Update)
2002                .collect()
2003        });
2004    }
2005
2006    let mut seen = BTreeSet::new();
2007    let mut coalesced = Vec::with_capacity(changes.len());
2008    for change in changes {
2009        let name = match &change {
2010            QueuedRefChange::Update(update) => &update.name,
2011            QueuedRefChange::Delete(delete) => &delete.name,
2012        };
2013        validate_ref_name_for_update(name)?;
2014        if !seen.insert(name.clone()) {
2015            return Err(GitError::Transaction(format!(
2016                "ref {name} appears more than once in transaction"
2017            )));
2018        }
2019        coalesced.push(match change {
2020            QueuedRefChange::Update(update) => CoalescedRefChange::Update(CoalescedRefUpdate {
2021                name: update.name,
2022                precondition: update.precondition,
2023                new: update.new,
2024                reflog: update.reflog.into_iter().collect(),
2025            }),
2026            QueuedRefChange::Delete(delete) => CoalescedRefChange::Delete(CoalescedRefDelete {
2027                name: delete.name,
2028                precondition: delete.precondition,
2029            }),
2030        });
2031    }
2032    Ok(coalesced)
2033}
2034
2035/// Fold repeated updates to the same ref into one, preserving first-seen order.
2036/// The last queued value wins, reflog entries accumulate in order, and the
2037/// precondition is taken from the first update (the state the caller
2038/// asserted before any change in this transaction).
2039fn coalesce_ref_updates(updates: Vec<QueuedUpdate>) -> Result<Vec<CoalescedRefUpdate>> {
2040    let mut order: Vec<String> = Vec::new();
2041    let mut by_name: HashMap<String, CoalescedRefUpdate> = HashMap::new();
2042    for update in updates {
2043        validate_ref_name_for_update(&update.name)?;
2044        match by_name.get_mut(&update.name) {
2045            Some(existing) => {
2046                existing.new = update.new;
2047                if let Some(entry) = update.reflog {
2048                    existing.reflog.push(entry);
2049                }
2050            }
2051            None => {
2052                order.push(update.name.clone());
2053                by_name.insert(
2054                    update.name.clone(),
2055                    CoalescedRefUpdate {
2056                        name: update.name,
2057                        precondition: update.precondition,
2058                        new: update.new,
2059                        reflog: update.reflog.into_iter().collect(),
2060                    },
2061                );
2062            }
2063        }
2064    }
2065    let mut coalesced = Vec::with_capacity(order.len());
2066    for name in order {
2067        if let Some(update) = by_name.remove(&name) {
2068            coalesced.push(update);
2069        }
2070    }
2071    Ok(coalesced)
2072}
2073
2074/// A staged path change: the target path, its lock file, and original bytes for
2075/// rollback.
2076struct PendingPathChange {
2077    name: String,
2078    path: PathBuf,
2079    lock_path: PathBuf,
2080    original: Option<Vec<u8>>,
2081    action: PendingPathAction,
2082}
2083
2084enum PendingPathAction {
2085    Write { contents: Vec<u8> },
2086    Delete,
2087    ReleaseLock,
2088}
2089
2090struct RefDirPruneGuard<'a> {
2091    store: &'a FileRefStore,
2092    name: String,
2093}
2094
2095impl Drop for RefDirPruneGuard<'_> {
2096    fn drop(&mut self) {
2097        self.store.prune_empty_ref_dirs(&self.name);
2098    }
2099}
2100
2101struct DeleteLock {
2102    path: PathBuf,
2103    file: Option<fs::File>,
2104    active: bool,
2105}
2106
2107impl DeleteLock {
2108    fn acquire(path: PathBuf) -> std::result::Result<Self, RefDeleteError> {
2109        match fs::OpenOptions::new()
2110            .write(true)
2111            .create_new(true)
2112            .open(&path)
2113        {
2114            Ok(file) => Ok(Self {
2115                path,
2116                file: Some(file),
2117                active: true,
2118            }),
2119            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
2120                Err(RefDeleteError::Locked)
2121            }
2122            Err(err) => Err(RefDeleteError::Io(err)),
2123        }
2124    }
2125
2126    fn write_all(&mut self, bytes: &[u8]) -> std::result::Result<(), RefDeleteError> {
2127        let Some(file) = self.file.as_mut() else {
2128            return Err(RefDeleteError::Io(std::io::Error::other(
2129                "lock file is already closed",
2130            )));
2131        };
2132        file.set_len(0)?;
2133        file.write_all(bytes)?;
2134        file.sync_all()?;
2135        Ok(())
2136    }
2137
2138    fn close(mut self) -> PathBuf {
2139        self.active = false;
2140        let _ = self.file.take();
2141        self.path.clone()
2142    }
2143
2144    fn remove(mut self) {
2145        self.active = false;
2146        let _ = self.file.take();
2147        let _ = fs::remove_file(&self.path);
2148    }
2149}
2150
2151impl Drop for DeleteLock {
2152    fn drop(&mut self) {
2153        if self.active {
2154            let _ = self.file.take();
2155            let _ = fs::remove_file(&self.path);
2156        }
2157    }
2158}
2159
2160fn checked_delete_oid(
2161    expected: Option<ObjectId>,
2162    current: Option<RefTarget>,
2163) -> std::result::Result<ObjectId, RefDeleteError> {
2164    let Some(current) = current else {
2165        return Err(RefDeleteError::NotFound);
2166    };
2167    let RefTarget::Direct(actual) = current else {
2168        return Err(RefDeleteError::ExpectedMismatch {
2169            expected,
2170            actual: None,
2171        });
2172    };
2173    if let Some(expected_oid) = expected
2174        && expected_oid != actual
2175    {
2176        return Err(RefDeleteError::ExpectedMismatch {
2177            expected: Some(expected_oid),
2178            actual: Some(actual),
2179        });
2180    }
2181    Ok(actual)
2182}
2183
2184/// Verify a queued/checked delete may proceed, dying on a precondition
2185/// mismatch. Git unlinks the reflog on delete (it never writes a deletion
2186/// entry), so this validates only — the peeled OID is no longer plumbed out.
2187/// `peeled_oid_for_delete` is still invoked where the precondition requires the
2188/// peeled value, so a broken/unpeelable ref is still reported.
2189fn verify_delete_precondition(
2190    store: &FileRefStore,
2191    name: &str,
2192    current: Option<&RefTarget>,
2193    precondition: &RefDeletePrecondition,
2194) -> Result<()> {
2195    let Some(current) = current else {
2196        return Err(GitError::Transaction(format!("ref {name} not found")));
2197    };
2198    match precondition {
2199        RefDeletePrecondition::Any => {
2200            peeled_oid_for_delete(store, current)?;
2201            Ok(())
2202        }
2203        RefDeletePrecondition::Immediate(expected) if current == expected => {
2204            peeled_oid_for_delete(store, current)?;
2205            Ok(())
2206        }
2207        RefDeletePrecondition::Immediate(_) => Err(delete_precondition_mismatch(name)),
2208        RefDeletePrecondition::Direct(expected) => {
2209            let RefTarget::Direct(actual) = current else {
2210                return Err(delete_precondition_mismatch(name));
2211            };
2212            if let Some(expected) = expected
2213                && expected != actual
2214            {
2215                return Err(delete_precondition_mismatch(name));
2216            }
2217            Ok(())
2218        }
2219        RefDeletePrecondition::Peeled(expected) => {
2220            let actual = peeled_oid_for_delete(store, current)?;
2221            if actual == Some(*expected) {
2222                Ok(())
2223            } else {
2224                Err(delete_precondition_mismatch(name))
2225            }
2226        }
2227    }
2228}
2229
2230fn peeled_oid_for_delete(store: &FileRefStore, target: &RefTarget) -> Result<Option<ObjectId>> {
2231    match target {
2232        RefTarget::Direct(oid) => Ok(Some(*oid)),
2233        RefTarget::Symbolic(name) => resolve_ref_peeled(store, name),
2234    }
2235}
2236
2237fn delete_precondition_mismatch(name: &str) -> GitError {
2238    GitError::Transaction(format!("expected ref {name} to match"))
2239}
2240
2241fn ref_delete_error_from_git(err: GitError) -> RefDeleteError {
2242    match err {
2243        GitError::InvalidPath(_) => RefDeleteError::InvalidName,
2244        GitError::NotFound(_) => RefDeleteError::NotFound,
2245        GitError::Io(message) if message.contains("File exists") => RefDeleteError::Locked,
2246        GitError::Io(message) if message.contains("could not lock") => RefDeleteError::Locked,
2247        GitError::Transaction(message) if message.contains("could not lock") => {
2248            RefDeleteError::Locked
2249        }
2250        other => RefDeleteError::Io(std::io::Error::other(other.to_string())),
2251    }
2252}
2253
2254fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
2255    match fs::read(path) {
2256        Ok(bytes) => Ok(Some(bytes)),
2257        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
2258        Err(err) => Err(GitError::Io(err.to_string())),
2259    }
2260}
2261
2262fn stage_lock_file(lock_path: &Path, contents: &[u8]) -> Result<()> {
2263    let mut file = fs::OpenOptions::new()
2264        .write(true)
2265        .truncate(true)
2266        .open(lock_path)?;
2267    file.write_all(contents)?;
2268    file.sync_all()?;
2269    Ok(())
2270}
2271
2272fn stage_pending_change(change: &PendingPathChange) -> Result<()> {
2273    match &change.action {
2274        PendingPathAction::Write { contents } => stage_lock_file(&change.lock_path, contents),
2275        PendingPathAction::Delete => stage_lock_file(&change.lock_path, b"delete\n"),
2276        PendingPathAction::ReleaseLock => Ok(()),
2277    }
2278}
2279
2280fn apply_pending_change(change: &PendingPathChange) -> Result<()> {
2281    match &change.action {
2282        PendingPathAction::Write { .. } => {
2283            fs::rename(&change.lock_path, &change.path).map_err(|err| GitError::Io(err.to_string()))
2284        }
2285        PendingPathAction::Delete => {
2286            if change.original.is_some() {
2287                fs::remove_file(&change.path).map_err(|err| GitError::Io(err.to_string()))?;
2288            }
2289            fs::remove_file(&change.lock_path).map_err(|err| GitError::Io(err.to_string()))
2290        }
2291        PendingPathAction::ReleaseLock => {
2292            fs::remove_file(&change.lock_path).map_err(|err| GitError::Io(err.to_string()))
2293        }
2294    }
2295}
2296
2297/// Delete every still-held lock file. Used when a transaction aborts before any
2298/// path change, so nothing on disk has changed yet.
2299fn release_pending_locks(pending: &[PendingPathChange]) {
2300    for change in pending {
2301        let _ = fs::remove_file(&change.lock_path);
2302    }
2303}
2304
2305/// Roll back after `applied` path changes have already landed: restore each to
2306/// its captured bytes (or remove it if it did not previously exist), then drop
2307/// the lock files that have not yet been applied.
2308fn rollback_after_apply(pending: &[PendingPathChange], applied: usize) {
2309    for change in pending.iter().take(applied) {
2310        if matches!(change.action, PendingPathAction::ReleaseLock) {
2311            let _ = fs::remove_file(&change.lock_path);
2312            continue;
2313        }
2314        match &change.original {
2315            Some(bytes) => {
2316                let _ = restore_file_atomically(&change.path, bytes);
2317            }
2318            None => {
2319                let _ = fs::remove_file(&change.path);
2320            }
2321        }
2322        let _ = fs::remove_file(&change.lock_path);
2323    }
2324    for change in pending.iter().skip(applied) {
2325        let _ = fs::remove_file(&change.lock_path);
2326    }
2327}
2328
2329#[cfg(test)]
2330thread_local! {
2331    static FAIL_LOOSE_COMMIT_ACTION: std::cell::Cell<Option<usize>> =
2332        const { std::cell::Cell::new(None) };
2333}
2334
2335#[cfg(test)]
2336fn set_fail_loose_commit_action_for_test(index: Option<usize>) {
2337    FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.set(index));
2338}
2339
2340#[cfg(test)]
2341fn maybe_fail_loose_commit_action(index: usize) -> Result<()> {
2342    let should_fail = FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.get() == Some(index));
2343    if should_fail {
2344        FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.set(None));
2345        return Err(GitError::Io(format!(
2346            "injected loose ref transaction failure at action {index}"
2347        )));
2348    }
2349    Ok(())
2350}
2351
2352#[cfg(not(test))]
2353fn maybe_fail_loose_commit_action(_index: usize) -> Result<()> {
2354    Ok(())
2355}
2356
2357/// Best-effort atomic restore of `path` to `bytes` during rollback, reusing the
2358/// write-to-temp-then-rename dance so a crash mid-rollback cannot truncate a ref.
2359fn restore_file_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
2360    write_locked(path, bytes)
2361}
2362
2363#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2364pub struct FullRefName<'a> {
2365    name: &'a str,
2366}
2367
2368impl<'a> FullRefName<'a> {
2369    pub fn new(name: &'a str) -> Result<Self> {
2370        validate_ref_name(name)?;
2371        Ok(Self { name })
2372    }
2373
2374    pub fn as_str(&self) -> &str {
2375        self.name
2376    }
2377
2378    pub fn into_str(self) -> &'a str {
2379        self.name
2380    }
2381
2382    pub fn to_owned(&self) -> FullRefNameBuf {
2383        FullRefNameBuf {
2384            name: self.name.to_string(),
2385        }
2386    }
2387
2388    pub fn as_branch(&self) -> Result<BranchRefName<'a>> {
2389        BranchRefName::from_full_ref(*self)
2390    }
2391
2392    pub fn as_tag(&self) -> Result<TagRefName<'a>> {
2393        TagRefName::from_full_ref(*self)
2394    }
2395
2396    pub fn as_remote(&self) -> Result<RemoteRefName<'a>> {
2397        RemoteRefName::from_full_ref(*self)
2398    }
2399}
2400
2401impl AsRef<str> for FullRefName<'_> {
2402    fn as_ref(&self) -> &str {
2403        self.as_str()
2404    }
2405}
2406
2407impl fmt::Display for FullRefName<'_> {
2408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2409        f.write_str(self.as_str())
2410    }
2411}
2412
2413#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2414pub struct FullRefNameBuf {
2415    name: String,
2416}
2417
2418impl FullRefNameBuf {
2419    pub fn new(name: impl Into<String>) -> Result<Self> {
2420        let name = name.into();
2421        validate_ref_name(&name)?;
2422        Ok(Self { name })
2423    }
2424
2425    pub fn as_ref_name(&self) -> FullRefName<'_> {
2426        FullRefName { name: &self.name }
2427    }
2428
2429    pub fn as_str(&self) -> &str {
2430        &self.name
2431    }
2432
2433    pub fn into_string(self) -> String {
2434        self.name
2435    }
2436
2437    pub fn as_branch(&self) -> Result<BranchRefName<'_>> {
2438        self.as_ref_name().as_branch()
2439    }
2440
2441    pub fn as_tag(&self) -> Result<TagRefName<'_>> {
2442        self.as_ref_name().as_tag()
2443    }
2444
2445    pub fn as_remote(&self) -> Result<RemoteRefName<'_>> {
2446        self.as_ref_name().as_remote()
2447    }
2448}
2449
2450impl AsRef<str> for FullRefNameBuf {
2451    fn as_ref(&self) -> &str {
2452        self.as_str()
2453    }
2454}
2455
2456impl Borrow<str> for FullRefNameBuf {
2457    fn borrow(&self) -> &str {
2458        self.as_str()
2459    }
2460}
2461
2462impl Deref for FullRefNameBuf {
2463    type Target = str;
2464
2465    fn deref(&self) -> &Self::Target {
2466        self.as_str()
2467    }
2468}
2469
2470impl fmt::Display for FullRefNameBuf {
2471    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2472        f.write_str(self.as_str())
2473    }
2474}
2475
2476#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2477pub struct BranchRefName<'a> {
2478    name: &'a str,
2479}
2480
2481impl<'a> BranchRefName<'a> {
2482    pub const PREFIX: &'static str = "refs/heads/";
2483
2484    pub fn from_full(name: &'a str) -> Result<Self> {
2485        let full = FullRefName::new(name)?;
2486        Self::from_full_ref(full)
2487    }
2488
2489    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
2490        validate_namespaced_ref(name.as_str(), Self::PREFIX, "branch")?;
2491        Ok(Self {
2492            name: name.into_str(),
2493        })
2494    }
2495
2496    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
2497        FullRefName { name: self.name }
2498    }
2499
2500    pub fn as_str(&self) -> &str {
2501        self.name
2502    }
2503
2504    pub fn branch_name(&self) -> &str {
2505        self.short_name()
2506    }
2507
2508    pub fn short_name(&self) -> &str {
2509        &self.name[Self::PREFIX.len()..]
2510    }
2511
2512    pub fn into_str(self) -> &'a str {
2513        self.name
2514    }
2515
2516    pub fn to_owned(&self) -> BranchRefNameBuf {
2517        BranchRefNameBuf {
2518            name: self.name.to_string(),
2519        }
2520    }
2521}
2522
2523impl AsRef<str> for BranchRefName<'_> {
2524    fn as_ref(&self) -> &str {
2525        self.as_str()
2526    }
2527}
2528
2529impl fmt::Display for BranchRefName<'_> {
2530    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2531        f.write_str(self.as_str())
2532    }
2533}
2534
2535impl<'a> From<BranchRefName<'a>> for FullRefName<'a> {
2536    fn from(name: BranchRefName<'a>) -> Self {
2537        name.as_full_ref_name()
2538    }
2539}
2540
2541#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2542pub struct BranchRefNameBuf {
2543    name: String,
2544}
2545
2546impl BranchRefNameBuf {
2547    pub fn from_branch_name(branch: &str) -> Result<Self> {
2548        validate_short_ref_name("branch", branch)?;
2549        let name = format!("{}{}", BranchRefName::PREFIX, branch);
2550        Self::from_full(name)
2551    }
2552
2553    pub fn from_full(name: impl Into<String>) -> Result<Self> {
2554        let name = name.into();
2555        BranchRefName::from_full(&name)?;
2556        Ok(Self { name })
2557    }
2558
2559    pub fn as_ref_name(&self) -> BranchRefName<'_> {
2560        BranchRefName { name: &self.name }
2561    }
2562
2563    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
2564        FullRefName { name: &self.name }
2565    }
2566
2567    pub fn as_str(&self) -> &str {
2568        &self.name
2569    }
2570
2571    pub fn branch_name(&self) -> &str {
2572        self.short_name()
2573    }
2574
2575    pub fn short_name(&self) -> &str {
2576        &self.name[BranchRefName::PREFIX.len()..]
2577    }
2578
2579    pub fn into_string(self) -> String {
2580        self.name
2581    }
2582}
2583
2584impl AsRef<str> for BranchRefNameBuf {
2585    fn as_ref(&self) -> &str {
2586        self.as_str()
2587    }
2588}
2589
2590impl Borrow<str> for BranchRefNameBuf {
2591    fn borrow(&self) -> &str {
2592        self.as_str()
2593    }
2594}
2595
2596impl Deref for BranchRefNameBuf {
2597    type Target = str;
2598
2599    fn deref(&self) -> &Self::Target {
2600        self.as_str()
2601    }
2602}
2603
2604impl fmt::Display for BranchRefNameBuf {
2605    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2606        f.write_str(self.as_str())
2607    }
2608}
2609
2610impl From<BranchRefNameBuf> for FullRefNameBuf {
2611    fn from(name: BranchRefNameBuf) -> Self {
2612        Self { name: name.name }
2613    }
2614}
2615
2616#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2617pub struct TagRefName<'a> {
2618    name: &'a str,
2619}
2620
2621impl<'a> TagRefName<'a> {
2622    pub const PREFIX: &'static str = "refs/tags/";
2623
2624    pub fn from_full(name: &'a str) -> Result<Self> {
2625        let full = FullRefName::new(name)?;
2626        Self::from_full_ref(full)
2627    }
2628
2629    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
2630        validate_namespaced_ref(name.as_str(), Self::PREFIX, "tag")?;
2631        Ok(Self {
2632            name: name.into_str(),
2633        })
2634    }
2635
2636    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
2637        FullRefName { name: self.name }
2638    }
2639
2640    pub fn as_str(&self) -> &str {
2641        self.name
2642    }
2643
2644    pub fn tag_name(&self) -> &str {
2645        self.short_name()
2646    }
2647
2648    pub fn short_name(&self) -> &str {
2649        &self.name[Self::PREFIX.len()..]
2650    }
2651
2652    pub fn into_str(self) -> &'a str {
2653        self.name
2654    }
2655
2656    pub fn to_owned(&self) -> TagRefNameBuf {
2657        TagRefNameBuf {
2658            name: self.name.to_string(),
2659        }
2660    }
2661}
2662
2663impl AsRef<str> for TagRefName<'_> {
2664    fn as_ref(&self) -> &str {
2665        self.as_str()
2666    }
2667}
2668
2669impl fmt::Display for TagRefName<'_> {
2670    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2671        f.write_str(self.as_str())
2672    }
2673}
2674
2675impl<'a> From<TagRefName<'a>> for FullRefName<'a> {
2676    fn from(name: TagRefName<'a>) -> Self {
2677        name.as_full_ref_name()
2678    }
2679}
2680
2681#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2682pub struct TagRefNameBuf {
2683    name: String,
2684}
2685
2686impl TagRefNameBuf {
2687    pub fn from_tag_name(tag: &str) -> Result<Self> {
2688        // Mirror git's check_tag_ref(): reject a leading '-' or the literal
2689        // "HEAD", then validate refs/tags/<tag> with check_refname_format().
2690        if tag.starts_with('-') || tag == "HEAD" {
2691            return Err(GitError::InvalidPath(format!("invalid tag name {tag}")));
2692        }
2693        Self::from_tag_name_unrestricted(tag)
2694    }
2695
2696    /// Build `refs/tags/<tag>` validating only the refname format, without the
2697    /// creation-only restrictions (leading `-`, literal `HEAD`). Git's delete
2698    /// path does not run check_tag_ref(), so a tag literally named `HEAD` can
2699    /// still be removed.
2700    pub fn from_tag_name_unrestricted(tag: &str) -> Result<Self> {
2701        let name = format!("{}{}", TagRefName::PREFIX, tag);
2702        check_refname_format(&name, false)?;
2703        Ok(Self { name })
2704    }
2705
2706    pub fn from_full(name: impl Into<String>) -> Result<Self> {
2707        let name = name.into();
2708        TagRefName::from_full(&name)?;
2709        Ok(Self { name })
2710    }
2711
2712    pub fn as_ref_name(&self) -> TagRefName<'_> {
2713        TagRefName { name: &self.name }
2714    }
2715
2716    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
2717        FullRefName { name: &self.name }
2718    }
2719
2720    pub fn as_str(&self) -> &str {
2721        &self.name
2722    }
2723
2724    pub fn tag_name(&self) -> &str {
2725        self.short_name()
2726    }
2727
2728    pub fn short_name(&self) -> &str {
2729        &self.name[TagRefName::PREFIX.len()..]
2730    }
2731
2732    pub fn into_string(self) -> String {
2733        self.name
2734    }
2735}
2736
2737impl AsRef<str> for TagRefNameBuf {
2738    fn as_ref(&self) -> &str {
2739        self.as_str()
2740    }
2741}
2742
2743impl Borrow<str> for TagRefNameBuf {
2744    fn borrow(&self) -> &str {
2745        self.as_str()
2746    }
2747}
2748
2749impl Deref for TagRefNameBuf {
2750    type Target = str;
2751
2752    fn deref(&self) -> &Self::Target {
2753        self.as_str()
2754    }
2755}
2756
2757impl fmt::Display for TagRefNameBuf {
2758    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2759        f.write_str(self.as_str())
2760    }
2761}
2762
2763impl From<TagRefNameBuf> for FullRefNameBuf {
2764    fn from(name: TagRefNameBuf) -> Self {
2765        Self { name: name.name }
2766    }
2767}
2768
2769#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2770pub struct RemoteRefName<'a> {
2771    name: &'a str,
2772}
2773
2774impl<'a> RemoteRefName<'a> {
2775    pub const PREFIX: &'static str = "refs/remotes/";
2776
2777    pub fn from_full(name: &'a str) -> Result<Self> {
2778        let full = FullRefName::new(name)?;
2779        Self::from_full_ref(full)
2780    }
2781
2782    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
2783        validate_namespaced_ref(name.as_str(), Self::PREFIX, "remote")?;
2784        Ok(Self {
2785            name: name.into_str(),
2786        })
2787    }
2788
2789    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
2790        FullRefName { name: self.name }
2791    }
2792
2793    pub fn as_str(&self) -> &str {
2794        self.name
2795    }
2796
2797    pub fn short_name(&self) -> &str {
2798        &self.name[Self::PREFIX.len()..]
2799    }
2800
2801    pub fn remote_name(&self) -> &str {
2802        match self.short_name().split_once('/') {
2803            Some((remote, _branch)) => remote,
2804            None => self.short_name(),
2805        }
2806    }
2807
2808    pub fn remote_branch(&self) -> Option<&str> {
2809        self.short_name()
2810            .split_once('/')
2811            .map(|(_remote, branch)| branch)
2812    }
2813
2814    pub fn into_str(self) -> &'a str {
2815        self.name
2816    }
2817
2818    pub fn to_owned(&self) -> RemoteRefNameBuf {
2819        RemoteRefNameBuf {
2820            name: self.name.to_string(),
2821        }
2822    }
2823}
2824
2825impl AsRef<str> for RemoteRefName<'_> {
2826    fn as_ref(&self) -> &str {
2827        self.as_str()
2828    }
2829}
2830
2831impl fmt::Display for RemoteRefName<'_> {
2832    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2833        f.write_str(self.as_str())
2834    }
2835}
2836
2837impl<'a> From<RemoteRefName<'a>> for FullRefName<'a> {
2838    fn from(name: RemoteRefName<'a>) -> Self {
2839        name.as_full_ref_name()
2840    }
2841}
2842
2843#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2844pub struct RemoteRefNameBuf {
2845    name: String,
2846}
2847
2848impl RemoteRefNameBuf {
2849    pub fn from_short_name(name: &str) -> Result<Self> {
2850        validate_short_ref_name("remote ref", name)?;
2851        let name = format!("{}{}", RemoteRefName::PREFIX, name);
2852        Self::from_full(name)
2853    }
2854
2855    pub fn from_remote_branch(remote: &str, branch: &str) -> Result<Self> {
2856        validate_remote_name(remote)?;
2857        validate_short_ref_name("remote branch", branch)?;
2858        let name = format!("{}{}/{}", RemoteRefName::PREFIX, remote, branch);
2859        Self::from_full(name)
2860    }
2861
2862    pub fn from_full(name: impl Into<String>) -> Result<Self> {
2863        let name = name.into();
2864        RemoteRefName::from_full(&name)?;
2865        Ok(Self { name })
2866    }
2867
2868    pub fn as_ref_name(&self) -> RemoteRefName<'_> {
2869        RemoteRefName { name: &self.name }
2870    }
2871
2872    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
2873        FullRefName { name: &self.name }
2874    }
2875
2876    pub fn as_str(&self) -> &str {
2877        &self.name
2878    }
2879
2880    pub fn short_name(&self) -> &str {
2881        &self.name[RemoteRefName::PREFIX.len()..]
2882    }
2883
2884    pub fn remote_name(&self) -> &str {
2885        match self.short_name().split_once('/') {
2886            Some((remote, _branch)) => remote,
2887            None => self.short_name(),
2888        }
2889    }
2890
2891    pub fn remote_branch(&self) -> Option<&str> {
2892        self.short_name()
2893            .split_once('/')
2894            .map(|(_remote, branch)| branch)
2895    }
2896
2897    pub fn into_string(self) -> String {
2898        self.name
2899    }
2900}
2901
2902impl AsRef<str> for RemoteRefNameBuf {
2903    fn as_ref(&self) -> &str {
2904        self.as_str()
2905    }
2906}
2907
2908impl Borrow<str> for RemoteRefNameBuf {
2909    fn borrow(&self) -> &str {
2910        self.as_str()
2911    }
2912}
2913
2914impl Deref for RemoteRefNameBuf {
2915    type Target = str;
2916
2917    fn deref(&self) -> &Self::Target {
2918        self.as_str()
2919    }
2920}
2921
2922impl fmt::Display for RemoteRefNameBuf {
2923    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2924        f.write_str(self.as_str())
2925    }
2926}
2927
2928impl From<RemoteRefNameBuf> for FullRefNameBuf {
2929    fn from(name: RemoteRefNameBuf) -> Self {
2930        Self { name: name.name }
2931    }
2932}
2933
2934pub fn branch_ref_name(branch: &str) -> Result<String> {
2935    BranchRefNameBuf::from_branch_name(branch).map(BranchRefNameBuf::into_string)
2936}
2937
2938pub fn tag_ref_name(tag: &str) -> Result<String> {
2939    TagRefNameBuf::from_tag_name(tag).map(TagRefNameBuf::into_string)
2940}
2941
2942fn write_locked(path: &Path, bytes: &[u8]) -> Result<()> {
2943    let lock_path = lock_path_for(path)?;
2944    {
2945        let mut file = fs::OpenOptions::new()
2946            .write(true)
2947            .create_new(true)
2948            .open(&lock_path)?;
2949        file.write_all(bytes)?;
2950        file.sync_all()?;
2951    }
2952    match fs::rename(&lock_path, path) {
2953        Ok(()) => Ok(()),
2954        Err(err) => {
2955            let _ = fs::remove_file(lock_path);
2956            Err(GitError::Io(err.to_string()))
2957        }
2958    }
2959}
2960
2961fn lock_path_for(path: &Path) -> Result<PathBuf> {
2962    let file_name = path
2963        .file_name()
2964        .ok_or_else(|| GitError::InvalidPath("ref path has no filename".into()))?;
2965    let mut lock_name = file_name.to_os_string();
2966    lock_name.push(".lock");
2967    Ok(path.with_file_name(lock_name))
2968}
2969
2970/// Validate a ref name using git's `check_refname_format` rules.
2971pub fn check_refname_format(name: &str, allow_onelevel: bool) -> Result<()> {
2972    if name.is_empty()
2973        || name == "@"
2974        || name.starts_with('/')
2975        || name.ends_with('/')
2976        || name.ends_with('.')
2977        || name.contains("..")
2978        || name.contains("//")
2979        || name.contains("@{")
2980        || (!allow_onelevel && !name.contains('/'))
2981    {
2982        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
2983    }
2984    for component in name.split('/') {
2985        if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
2986            return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
2987        }
2988        for (idx, byte) in component.bytes().enumerate() {
2989            if byte <= b' '
2990                || byte == 0x7f
2991                || matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
2992            {
2993                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
2994            }
2995            if byte == b'.' && component.as_bytes().get(idx + 1) == Some(&b'.') {
2996                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
2997            }
2998            if byte == b'@' && component.as_bytes().get(idx + 1) == Some(&b'{') {
2999                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3000            }
3001        }
3002    }
3003    Ok(())
3004}
3005
3006/// Validate a symbolic ref name (HEAD, one-level pseudo-refs, or `refs/...`).
3007pub fn validate_symref_name(name: &str) -> Result<()> {
3008    if name == "HEAD" {
3009        return Ok(());
3010    }
3011    check_refname_format(name, true)
3012}
3013
3014/// Validate a symbolic ref target (one-level pseudo-refs or `refs/...`).
3015pub fn validate_symref_target(name: &str) -> Result<()> {
3016    check_refname_format(name, true)
3017}
3018
3019/// Follow symbolic ref chains until a direct OID is reached.
3020/// Remove empty directories starting at `start` and walking up toward
3021/// `boundary`, stopping at the first non-empty directory or when `boundary` is
3022/// reached (exclusive). `boundary` itself is never removed.
3023fn prune_empty_dirs_up_to(start: &Path, boundary: &Path) {
3024    let mut dir = start.to_path_buf();
3025    while dir.starts_with(boundary) && dir != *boundary {
3026        if fs::remove_dir(&dir).is_err() {
3027            break;
3028        }
3029        dir = match dir.parent() {
3030            Some(parent) => parent.to_path_buf(),
3031            None => break,
3032        };
3033    }
3034}
3035
3036pub fn resolve_ref_peeled(store: &FileRefStore, name: &str) -> Result<Option<ObjectId>> {
3037    let mut current = name.to_string();
3038    for _ in 0..16 {
3039        match store.read_ref(&current)? {
3040            Some(RefTarget::Direct(oid)) => return Ok(Some(oid)),
3041            Some(RefTarget::Symbolic(next)) => current = next,
3042            None => return Ok(None),
3043        }
3044    }
3045    Ok(None)
3046}
3047
3048fn validate_ref_name_for_read(name: &str) -> Result<()> {
3049    if validate_ref_name(name).is_ok() {
3050        return Ok(());
3051    }
3052    if is_root_ref_syntax(name) {
3053        return Ok(());
3054    }
3055    validate_symref_name(name)
3056}
3057
3058fn validate_ref_name_for_update(name: &str) -> Result<()> {
3059    if validate_ref_name(name).is_ok() {
3060        return Ok(());
3061    }
3062    if is_root_ref_syntax(name) {
3063        return Ok(());
3064    }
3065    validate_symref_name(name)
3066}
3067
3068/// git's is_root_ref_syntax (refs.c): a ref name made only of uppercase ASCII,
3069/// `-`, and `_` (e.g. HEAD, FETCH_HEAD, MERGE_HEAD). Such names live in the
3070/// per-worktree gitdir rather than the common refs/ tree. An empty name is not
3071/// root-ref syntax.
3072fn is_root_ref_syntax(name: &str) -> bool {
3073    !name.is_empty()
3074        && name
3075            .bytes()
3076            .all(|b| b.is_ascii_uppercase() || b == b'-' || b == b'_')
3077}
3078
3079pub fn validate_ref_name(name: &str) -> Result<()> {
3080    if name == "HEAD" {
3081        return Ok(());
3082    }
3083    let path = Path::new(name);
3084    if !name.starts_with("refs/")
3085        || name.contains("..")
3086        || name.contains('\\')
3087        || name.ends_with('/')
3088        || name.ends_with(".lock")
3089        || path.is_absolute()
3090        || path.components().any(|component| {
3091            matches!(
3092                component,
3093                std::path::Component::ParentDir | std::path::Component::Prefix(_)
3094            )
3095        })
3096    {
3097        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3098    }
3099    Ok(())
3100}
3101
3102fn ref_directory_conflict_error(new_ref: &str, existing_ref: &str) -> GitError {
3103    GitError::Transaction(format!(
3104        "cannot lock ref '{new_ref}': '{existing_ref}' exists; cannot create '{new_ref}'"
3105    ))
3106}
3107
3108fn parent_to_ref_name(base: &Path, parent: &Path) -> String {
3109    match parent.strip_prefix(base) {
3110        Ok(suffix) => suffix.to_string_lossy().replace('\\', "/"),
3111        Err(_) => parent.to_string_lossy().into_owned(),
3112    }
3113}
3114
3115fn validate_namespaced_ref(name: &str, prefix: &str, kind: &str) -> Result<()> {
3116    validate_ref_name(name)?;
3117    if name
3118        .strip_prefix(prefix)
3119        .is_none_or(|short_name| short_name.is_empty())
3120    {
3121        return Err(GitError::InvalidPath(format!(
3122            "invalid {kind} ref name {name}"
3123        )));
3124    }
3125    Ok(())
3126}
3127
3128fn validate_short_ref_name(kind: &str, name: &str) -> Result<()> {
3129    if name.is_empty()
3130        || name.starts_with('-')
3131        || name.starts_with('/')
3132        || name.ends_with('/')
3133        || name.contains(' ')
3134        || name.contains('\\')
3135    {
3136        return Err(GitError::InvalidPath(format!("invalid {kind} name {name}")));
3137    }
3138    Ok(())
3139}
3140
3141fn validate_remote_name(remote: &str) -> Result<()> {
3142    validate_short_ref_name("remote", remote)?;
3143    if remote.contains('/') {
3144        return Err(GitError::InvalidPath(format!(
3145            "invalid remote name {remote}"
3146        )));
3147    }
3148    Ok(())
3149}
3150
3151fn prepare_bundle_ref_updates<F>(
3152    refs: &[BundleRefUpdate],
3153    reflog: Option<&BundleRefUpdateReflog>,
3154    mut read_ref: F,
3155) -> Result<(Vec<RefUpdate>, Vec<AppliedBundleRefUpdate>)>
3156where
3157    F: FnMut(&str, &ObjectId) -> Result<Option<RefTarget>>,
3158{
3159    let mut seen = BTreeSet::new();
3160    let mut updates = Vec::with_capacity(refs.len());
3161    let mut applied = Vec::with_capacity(refs.len());
3162    for bundle_ref in refs {
3163        validate_ref_name(&bundle_ref.name)?;
3164        if !seen.insert(bundle_ref.name.clone()) {
3165            return Err(GitError::Transaction(format!(
3166                "duplicate bundle ref {}",
3167                bundle_ref.name
3168            )));
3169        }
3170        let old_oid = match read_ref(&bundle_ref.name, &bundle_ref.oid)? {
3171            Some(RefTarget::Direct(oid)) => Some(oid),
3172            Some(RefTarget::Symbolic(target)) => {
3173                return Err(GitError::Transaction(format!(
3174                    "bundle ref {} would overwrite symbolic ref {target}",
3175                    bundle_ref.name
3176                )));
3177            }
3178            None => None,
3179        };
3180        let reflog = match reflog {
3181            Some(reflog) => Some(ReflogEntry {
3182                old_oid: match &old_oid {
3183                    Some(oid) => *oid,
3184                    None => null_oid(bundle_ref.oid.format())?,
3185                },
3186                new_oid: bundle_ref.oid,
3187                committer: reflog.committer.clone(),
3188                message: reflog.message.clone(),
3189            }),
3190            None => None,
3191        };
3192        updates.push(RefUpdate {
3193            name: bundle_ref.name.clone(),
3194            expected: old_oid.map(RefTarget::Direct),
3195            new: RefTarget::Direct(bundle_ref.oid),
3196            reflog,
3197        });
3198        applied.push(AppliedBundleRefUpdate {
3199            name: bundle_ref.name.clone(),
3200            old_oid,
3201            new_oid: bundle_ref.oid,
3202        });
3203    }
3204    Ok((updates, applied))
3205}
3206
3207fn null_oid(format: ObjectFormat) -> Result<ObjectId> {
3208    Ok(ObjectId::null(format))
3209}
3210
3211#[cfg(test)]
3212mod tests {
3213    use super::*;
3214    use std::sync::atomic::{AtomicU64, Ordering};
3215
3216    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
3217
3218    #[test]
3219    fn loose_ref_round_trips_direct() {
3220        let oid = "ce013625030ba8dba906f756967f9e9ca394464a";
3221        let reference = parse_loose_ref(ObjectFormat::Sha1, "refs/heads/main", oid.as_bytes())
3222            .expect("test operation should succeed");
3223        assert_eq!(write_loose_ref(&reference), format!("{oid}\n").into_bytes());
3224    }
3225
3226    #[test]
3227    fn symref_names_allow_onelevel_pseudo_refs() {
3228        for name in ["NOTHEAD", "FOO", "ORIG_HEAD", "TEST_SYMREF"] {
3229            validate_symref_name(name).expect("symref name should be valid");
3230        }
3231        assert!(validate_ref_name("NOTHEAD").is_err());
3232        assert!(validate_symref_target("refs/heads/foo").is_ok());
3233        assert!(validate_symref_target("ORIG_HEAD").is_ok());
3234        assert!(validate_symref_target("foo..bar").is_err());
3235    }
3236
3237    #[test]
3238    fn resolve_ref_peeled_follows_symref_chains() {
3239        let git_dir = temp_git_dir();
3240        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3241        let oid = ObjectId::from_hex(
3242            ObjectFormat::Sha1,
3243            "ce013625030ba8dba906f756967f9e9ca394464a",
3244        )
3245        .expect("test operation should succeed");
3246        let mut tx = store.transaction();
3247        tx.update(RefUpdate {
3248            name: "refs/heads/target".into(),
3249            expected: None,
3250            new: RefTarget::Direct(oid),
3251            reflog: None,
3252        });
3253        tx.commit().expect("seed target ref");
3254        let mut tx = store.transaction();
3255        tx.update(RefUpdate {
3256            name: "refs/heads/alias".into(),
3257            expected: None,
3258            new: RefTarget::Symbolic("refs/heads/target".into()),
3259            reflog: None,
3260        });
3261        tx.commit().expect("seed alias ref");
3262        let mut tx = store.transaction();
3263        tx.update(RefUpdate {
3264            name: "ORIG_HEAD".into(),
3265            expected: None,
3266            new: RefTarget::Symbolic("refs/heads/alias".into()),
3267            reflog: None,
3268        });
3269        tx.commit().expect("seed ORIG_HEAD symref");
3270        assert_eq!(
3271            resolve_ref_peeled(&store, "ORIG_HEAD").expect("resolve ORIG_HEAD"),
3272            Some(oid)
3273        );
3274        let _ = fs::remove_dir_all(git_dir);
3275    }
3276
3277    #[test]
3278    fn symref_directory_conflict_is_reported_gracefully() {
3279        let git_dir = temp_git_dir();
3280        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3281        let oid = ObjectId::from_hex(
3282            ObjectFormat::Sha1,
3283            "ce013625030ba8dba906f756967f9e9ca394464a",
3284        )
3285        .expect("test operation should succeed");
3286        let mut tx = store.transaction();
3287        tx.update(RefUpdate {
3288            name: "refs/heads/df".into(),
3289            expected: None,
3290            new: RefTarget::Direct(oid),
3291            reflog: None,
3292        });
3293        tx.commit().expect("seed branch ref");
3294
3295        let mut tx = store.transaction();
3296        tx.update(RefUpdate {
3297            name: "refs/heads/df/conflict".into(),
3298            expected: None,
3299            new: RefTarget::Symbolic("refs/heads/df".into()),
3300            reflog: None,
3301        });
3302        let err = tx.commit().expect_err("child ref should conflict");
3303        assert!(
3304            matches!(err, GitError::Transaction(message) if message.contains(
3305            "cannot lock ref 'refs/heads/df/conflict'"
3306        ) && message.contains("refs/heads/df"))
3307        );
3308        let _ = fs::remove_dir_all(git_dir);
3309    }
3310
3311    #[test]
3312    fn transaction_checks_expected_value() {
3313        let oid = ObjectId::from_hex(
3314            ObjectFormat::Sha1,
3315            "ce013625030ba8dba906f756967f9e9ca394464a",
3316        )
3317        .expect("test operation should succeed");
3318        let mut store = RefStore::new();
3319        let mut tx = store.transaction();
3320        tx.update(RefUpdate {
3321            name: "refs/heads/main".into(),
3322            expected: None,
3323            new: RefTarget::Direct(oid),
3324            reflog: None,
3325        });
3326        tx.commit().expect("test operation should succeed");
3327        assert_eq!(store.get("refs/heads/main"), Some(&RefTarget::Direct(oid)));
3328    }
3329
3330    #[test]
3331    fn packed_refs_parse_peeled_refs() {
3332        let packed = b"# pack-refs with: peeled fully-peeled sorted \n\
3333ce013625030ba8dba906f756967f9e9ca394464a refs/tags/v1\n\
3334^e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n";
3335        let refs =
3336            parse_packed_refs(ObjectFormat::Sha1, packed).expect("test operation should succeed");
3337        assert_eq!(refs.len(), 1);
3338        assert_eq!(refs[0].reference.name, "refs/tags/v1");
3339        assert_eq!(
3340            refs[0]
3341                .peeled
3342                .as_ref()
3343                .expect("test operation should succeed")
3344                .to_hex(),
3345            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
3346        );
3347    }
3348
3349    #[test]
3350    fn packed_refs_write_sorted_with_peeled_refs() {
3351        let head_oid = ObjectId::from_hex(
3352            ObjectFormat::Sha1,
3353            "ce013625030ba8dba906f756967f9e9ca394464a",
3354        )
3355        .expect("test operation should succeed");
3356        let tag_oid = ObjectId::from_hex(
3357            ObjectFormat::Sha1,
3358            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
3359        )
3360        .expect("test operation should succeed");
3361        let peeled_oid = ObjectId::from_hex(
3362            ObjectFormat::Sha1,
3363            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
3364        )
3365        .expect("test operation should succeed");
3366        let refs = vec![
3367            PackedRef {
3368                reference: Ref {
3369                    name: "refs/tags/v1".into(),
3370                    target: RefTarget::Direct(tag_oid),
3371                },
3372                peeled: Some(peeled_oid),
3373            },
3374            PackedRef {
3375                reference: Ref {
3376                    name: "refs/heads/main".into(),
3377                    target: RefTarget::Direct(head_oid),
3378                },
3379                peeled: None,
3380            },
3381        ];
3382        let bytes = write_packed_refs(&refs).expect("test operation should succeed");
3383        let expected = format!(
3384            "# pack-refs with: peeled fully-peeled sorted \n\
3385{head_oid} refs/heads/main\n\
3386{tag_oid} refs/tags/v1\n\
3387^{peeled_oid}\n"
3388        );
3389        assert_eq!(
3390            String::from_utf8(bytes.clone()).expect("test operation should succeed"),
3391            expected
3392        );
3393        let parsed =
3394            parse_packed_refs(ObjectFormat::Sha1, &bytes).expect("test operation should succeed");
3395        assert_eq!(parsed[0], refs[1]);
3396        assert_eq!(parsed[1], refs[0]);
3397    }
3398
3399    #[test]
3400    fn full_ref_name_validates_and_round_trips_owned() {
3401        let full = FullRefName::new("refs/heads/main").expect("valid full branch ref");
3402        assert_eq!(full.as_str(), "refs/heads/main");
3403        assert_eq!(full.to_string(), "refs/heads/main");
3404        assert_eq!(full.to_owned().into_string(), "refs/heads/main");
3405
3406        let head = FullRefNameBuf::new("HEAD").expect("valid HEAD ref");
3407        assert_eq!(head.as_ref_name().into_str(), "HEAD");
3408
3409        assert!(FullRefName::new("main").is_err());
3410        assert!(FullRefNameBuf::new("refs/heads/bad.lock").is_err());
3411    }
3412
3413    #[test]
3414    fn branch_ref_name_helpers_validate_short_and_full_names() {
3415        let branch =
3416            BranchRefNameBuf::from_branch_name("feature/topic").expect("valid branch short name");
3417        assert_eq!(branch.as_str(), "refs/heads/feature/topic");
3418        assert_eq!(branch.branch_name(), "feature/topic");
3419        assert_eq!(
3420            branch.as_full_ref_name().as_str(),
3421            "refs/heads/feature/topic"
3422        );
3423        assert_eq!(
3424            branch_ref_name("feature/topic").expect("valid branch short name"),
3425            branch.as_str()
3426        );
3427
3428        let borrowed = BranchRefName::from_full("refs/heads/main").expect("valid full branch ref");
3429        assert_eq!(borrowed.branch_name(), "main");
3430        assert_eq!(borrowed.to_owned().into_string(), "refs/heads/main");
3431        assert_eq!(
3432            FullRefName::new("refs/heads/main")
3433                .expect("valid full branch ref")
3434                .as_branch()
3435                .expect("full ref is a branch")
3436                .branch_name(),
3437            "main"
3438        );
3439
3440        assert!(BranchRefName::from_full("refs/tags/main").is_err());
3441        assert!(BranchRefName::from_full("refs/heads").is_err());
3442        assert!(BranchRefNameBuf::from_branch_name("-bad").is_err());
3443    }
3444
3445    #[test]
3446    fn tag_ref_name_helpers_validate_short_and_full_names() {
3447        let tag = TagRefNameBuf::from_tag_name("v1.0").expect("valid tag short name");
3448        assert_eq!(tag.as_str(), "refs/tags/v1.0");
3449        assert_eq!(tag.tag_name(), "v1.0");
3450        assert_eq!(tag.as_full_ref_name().as_str(), "refs/tags/v1.0");
3451        assert_eq!(
3452            tag_ref_name("v1.0").expect("valid tag short name"),
3453            tag.as_str()
3454        );
3455
3456        let borrowed = TagRefName::from_full("refs/tags/release/1").expect("valid full tag ref");
3457        assert_eq!(borrowed.tag_name(), "release/1");
3458        assert_eq!(borrowed.to_owned().into_string(), "refs/tags/release/1");
3459        assert_eq!(
3460            FullRefName::new("refs/tags/release/1")
3461                .expect("valid full tag ref")
3462                .as_tag()
3463                .expect("full ref is a tag")
3464                .tag_name(),
3465            "release/1"
3466        );
3467
3468        assert!(TagRefName::from_full("refs/heads/v1.0").is_err());
3469        assert!(TagRefName::from_full("refs/tags").is_err());
3470        assert!(TagRefNameBuf::from_tag_name("bad tag").is_err());
3471    }
3472
3473    #[test]
3474    fn remote_ref_name_helpers_validate_namespace_and_components() {
3475        let remote = RemoteRefNameBuf::from_remote_branch("origin", "feature/topic")
3476            .expect("valid remote branch ref");
3477        assert_eq!(remote.as_str(), "refs/remotes/origin/feature/topic");
3478        assert_eq!(remote.short_name(), "origin/feature/topic");
3479        assert_eq!(remote.remote_name(), "origin");
3480        assert_eq!(remote.remote_branch(), Some("feature/topic"));
3481        assert_eq!(
3482            remote.as_full_ref_name().as_str(),
3483            "refs/remotes/origin/feature/topic"
3484        );
3485
3486        let head =
3487            RemoteRefName::from_full("refs/remotes/origin/HEAD").expect("valid remote HEAD ref");
3488        assert_eq!(head.remote_name(), "origin");
3489        assert_eq!(head.remote_branch(), Some("HEAD"));
3490        assert_eq!(
3491            FullRefName::new("refs/remotes/upstream/main")
3492                .expect("valid full remote ref")
3493                .as_remote()
3494                .expect("full ref is remote-tracking")
3495                .remote_name(),
3496            "upstream"
3497        );
3498
3499        let short =
3500            RemoteRefNameBuf::from_short_name("origin/main").expect("valid remote short ref");
3501        assert_eq!(short.as_str(), "refs/remotes/origin/main");
3502
3503        assert!(RemoteRefName::from_full("refs/heads/origin/main").is_err());
3504        assert!(RemoteRefName::from_full("refs/remotes/").is_err());
3505        assert!(RemoteRefNameBuf::from_remote_branch("origin/fork", "main").is_err());
3506    }
3507
3508    #[test]
3509    fn file_ref_store_writes_ref_and_reflog() {
3510        let git_dir = temp_git_dir();
3511        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3512        let oid = ObjectId::from_hex(
3513            ObjectFormat::Sha1,
3514            "ce013625030ba8dba906f756967f9e9ca394464a",
3515        )
3516        .expect("test operation should succeed");
3517        let mut tx = store.transaction();
3518        tx.update(RefUpdate {
3519            name: "refs/heads/main".into(),
3520            expected: None,
3521            new: RefTarget::Direct(oid),
3522            reflog: Some(ReflogEntry {
3523                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
3524                new_oid: oid,
3525                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3526                message: b"update by test".to_vec(),
3527            }),
3528        });
3529        tx.commit().expect("test operation should succeed");
3530        assert_eq!(
3531            store
3532                .read_ref("refs/heads/main")
3533                .expect("test operation should succeed"),
3534            Some(RefTarget::Direct(oid))
3535        );
3536        let log = store
3537            .read_reflog("refs/heads/main")
3538            .expect("test operation should succeed");
3539        assert_eq!(log.len(), 1);
3540        assert_eq!(log[0].message, b"update by test");
3541        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3542    }
3543
3544    #[test]
3545    fn file_ref_store_applies_bundle_refs_with_reflog() {
3546        let git_dir = temp_git_dir();
3547        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3548        let old_main = ObjectId::from_hex(
3549            ObjectFormat::Sha1,
3550            "ce013625030ba8dba906f756967f9e9ca394464a",
3551        )
3552        .expect("test operation should succeed");
3553        let new_main = ObjectId::from_hex(
3554            ObjectFormat::Sha1,
3555            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
3556        )
3557        .expect("test operation should succeed");
3558        let tag_oid = ObjectId::from_hex(
3559            ObjectFormat::Sha1,
3560            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
3561        )
3562        .expect("test operation should succeed");
3563        let mut tx = store.transaction();
3564        tx.update(RefUpdate {
3565            name: "refs/heads/main".into(),
3566            expected: None,
3567            new: RefTarget::Direct(old_main.clone()),
3568            reflog: None,
3569        });
3570        tx.commit().expect("test operation should succeed");
3571
3572        let applied = store
3573            .apply_bundle_ref_updates(
3574                &[
3575                    BundleRefUpdate {
3576                        name: "refs/heads/main".into(),
3577                        oid: new_main.clone(),
3578                    },
3579                    BundleRefUpdate {
3580                        name: "refs/tags/v1.0".into(),
3581                        oid: tag_oid,
3582                    },
3583                ],
3584                Some(BundleRefUpdateReflog {
3585                    committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3586                    message: b"bundle: import refs".to_vec(),
3587                }),
3588            )
3589            .expect("test operation should succeed");
3590
3591        assert_eq!(
3592            applied,
3593            vec![
3594                AppliedBundleRefUpdate {
3595                    name: "refs/heads/main".into(),
3596                    old_oid: Some(old_main.clone()),
3597                    new_oid: new_main.clone(),
3598                },
3599                AppliedBundleRefUpdate {
3600                    name: "refs/tags/v1.0".into(),
3601                    old_oid: None,
3602                    new_oid: tag_oid,
3603                }
3604            ]
3605        );
3606        assert_eq!(
3607            store
3608                .read_ref("refs/heads/main")
3609                .expect("test operation should succeed"),
3610            Some(RefTarget::Direct(new_main.clone()))
3611        );
3612        assert_eq!(
3613            store
3614                .read_ref("refs/tags/v1.0")
3615                .expect("test operation should succeed"),
3616            Some(RefTarget::Direct(tag_oid))
3617        );
3618        let main_log = store
3619            .read_reflog("refs/heads/main")
3620            .expect("test operation should succeed");
3621        assert_eq!(main_log.len(), 1);
3622        assert_eq!(main_log[0].old_oid, old_main);
3623        assert_eq!(main_log[0].new_oid, new_main);
3624        assert_eq!(main_log[0].message, b"bundle: import refs");
3625        let tag_log = store
3626            .read_reflog("refs/tags/v1.0")
3627            .expect("test operation should succeed");
3628        assert_eq!(tag_log.len(), 1);
3629        assert_eq!(
3630            tag_log[0].old_oid,
3631            zero_oid(ObjectFormat::Sha1).expect("test operation should succeed")
3632        );
3633        assert_eq!(tag_log[0].new_oid, tag_oid);
3634        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3635    }
3636
3637    #[test]
3638    fn file_ref_store_rejects_bad_bundle_ref_before_writing() {
3639        let git_dir = temp_git_dir();
3640        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3641        let oid = ObjectId::from_hex(
3642            ObjectFormat::Sha1,
3643            "ce013625030ba8dba906f756967f9e9ca394464a",
3644        )
3645        .expect("test operation should succeed");
3646
3647        let result = store.apply_bundle_ref_updates(
3648            &[
3649                BundleRefUpdate {
3650                    name: "refs/heads/main".into(),
3651                    oid,
3652                },
3653                BundleRefUpdate {
3654                    name: "refs/heads/bad.lock".into(),
3655                    oid,
3656                },
3657            ],
3658            None,
3659        );
3660
3661        assert!(result.is_err());
3662        assert_eq!(
3663            store
3664                .read_ref("refs/heads/main")
3665                .expect("test operation should succeed"),
3666            None
3667        );
3668        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3669    }
3670
3671    #[test]
3672    fn file_ref_store_rejects_bundle_ref_over_symbolic_ref() {
3673        let git_dir = temp_git_dir();
3674        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3675        let oid = ObjectId::from_hex(
3676            ObjectFormat::Sha1,
3677            "ce013625030ba8dba906f756967f9e9ca394464a",
3678        )
3679        .expect("test operation should succeed");
3680        let mut tx = store.transaction();
3681        tx.update(RefUpdate {
3682            name: "refs/heads/main".into(),
3683            expected: None,
3684            new: RefTarget::Symbolic("refs/heads/base".into()),
3685            reflog: None,
3686        });
3687        tx.commit().expect("test operation should succeed");
3688
3689        let result = store.apply_bundle_ref_updates(
3690            &[BundleRefUpdate {
3691                name: "refs/heads/main".into(),
3692                oid,
3693            }],
3694            None,
3695        );
3696
3697        assert!(result.is_err());
3698        assert_eq!(
3699            store
3700                .read_ref("refs/heads/main")
3701                .expect("test operation should succeed"),
3702            Some(RefTarget::Symbolic("refs/heads/base".into()))
3703        );
3704        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3705    }
3706
3707    #[test]
3708    fn file_ref_store_expires_reflog_entries_by_timestamp() {
3709        let git_dir = temp_git_dir();
3710        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3711        let first = ObjectId::from_hex(
3712            ObjectFormat::Sha1,
3713            "ce013625030ba8dba906f756967f9e9ca394464a",
3714        )
3715        .expect("test operation should succeed");
3716        let second = ObjectId::from_hex(
3717            ObjectFormat::Sha1,
3718            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
3719        )
3720        .expect("test operation should succeed");
3721        let mut tx = store.transaction();
3722        tx.update(RefUpdate {
3723            name: "refs/heads/main".into(),
3724            expected: None,
3725            new: RefTarget::Direct(first.clone()),
3726            reflog: Some(ReflogEntry {
3727                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
3728                new_oid: first.clone(),
3729                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3730                message: b"old".to_vec(),
3731            }),
3732        });
3733        tx.update(RefUpdate {
3734            name: "refs/heads/main".into(),
3735            expected: None,
3736            new: RefTarget::Direct(second.clone()),
3737            reflog: Some(ReflogEntry {
3738                old_oid: first,
3739                new_oid: second.clone(),
3740                committer: b"Git Rs <sley@example.invalid> 100 +0000".to_vec(),
3741                message: b"new".to_vec(),
3742            }),
3743        });
3744        tx.commit().expect("test operation should succeed");
3745
3746        let removed = store
3747            .expire_reflog_older_than("refs/heads/main", 50)
3748            .expect("test operation should succeed");
3749        assert_eq!(removed, 1);
3750        let log = store
3751            .read_reflog("refs/heads/main")
3752            .expect("test operation should succeed");
3753        assert_eq!(log.len(), 1);
3754        assert_eq!(log[0].new_oid, second);
3755        assert_eq!(log[0].message, b"new");
3756        assert!(
3757            !git_dir
3758                .join("logs")
3759                .join("refs")
3760                .join("heads")
3761                .join("main.lock")
3762                .exists()
3763        );
3764        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3765    }
3766
3767    #[test]
3768    fn file_ref_store_creates_branch() {
3769        let git_dir = temp_git_dir();
3770        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3771        let oid = ObjectId::from_hex(
3772            ObjectFormat::Sha1,
3773            "ce013625030ba8dba906f756967f9e9ca394464a",
3774        )
3775        .expect("test operation should succeed");
3776        let branch = store
3777            .create_branch(
3778                "feature",
3779                oid,
3780                b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3781                b"branch: Created from main".to_vec(),
3782            )
3783            .expect("test operation should succeed");
3784        assert_eq!(branch.name, "refs/heads/feature");
3785        assert_eq!(
3786            store
3787                .read_ref("refs/heads/feature")
3788                .expect("test operation should succeed"),
3789            Some(RefTarget::Direct(oid))
3790        );
3791        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3792    }
3793
3794    #[test]
3795    fn file_ref_store_deletes_loose_branch() {
3796        let git_dir = temp_git_dir();
3797        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3798        let oid = ObjectId::from_hex(
3799            ObjectFormat::Sha1,
3800            "ce013625030ba8dba906f756967f9e9ca394464a",
3801        )
3802        .expect("test operation should succeed");
3803        store
3804            .create_branch(
3805                "feature",
3806                oid,
3807                b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3808                b"branch: Created from main".to_vec(),
3809            )
3810            .expect("test operation should succeed");
3811        let deleted = store
3812            .delete_branch("feature")
3813            .expect("test operation should succeed");
3814        assert_eq!(deleted.name, "refs/heads/feature");
3815        assert_eq!(deleted.oid, oid);
3816        assert_eq!(
3817            store
3818                .read_ref("refs/heads/feature")
3819                .expect("test operation should succeed"),
3820            None
3821        );
3822        assert!(!git_dir.join("refs").join("heads").join("feature").exists());
3823        assert!(
3824            !git_dir
3825                .join("logs")
3826                .join("refs")
3827                .join("heads")
3828                .join("feature")
3829                .exists()
3830        );
3831        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3832    }
3833
3834    #[test]
3835    fn file_ref_store_deletes_generic_loose_ref() {
3836        let git_dir = temp_git_dir();
3837        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3838        let oid = ObjectId::from_hex(
3839            ObjectFormat::Sha1,
3840            "ce013625030ba8dba906f756967f9e9ca394464a",
3841        )
3842        .expect("test operation should succeed");
3843        let mut tx = store.transaction();
3844        tx.update(RefUpdate {
3845            name: "refs/heads/topic".into(),
3846            expected: None,
3847            new: RefTarget::Direct(oid),
3848            reflog: Some(ReflogEntry {
3849                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
3850                new_oid: oid,
3851                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3852                message: b"update by test".to_vec(),
3853            }),
3854        });
3855        tx.commit().expect("test operation should succeed");
3856        let deleted = store
3857            .delete_ref("refs/heads/topic")
3858            .expect("test operation should succeed");
3859        assert_eq!(deleted.name, "refs/heads/topic");
3860        assert_eq!(deleted.oid, oid);
3861        assert_eq!(
3862            store
3863                .read_ref("refs/heads/topic")
3864                .expect("test operation should succeed"),
3865            None
3866        );
3867        assert!(!git_dir.join("refs").join("heads").join("topic").exists());
3868        assert!(
3869            !git_dir
3870                .join("logs")
3871                .join("refs")
3872                .join("heads")
3873                .join("topic")
3874                .exists()
3875        );
3876        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3877    }
3878
3879    #[test]
3880    fn file_ref_store_delete_ref_checked_removes_reflog() {
3881        let git_dir = temp_git_dir();
3882        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3883        let oid = ObjectId::from_hex(
3884            ObjectFormat::Sha1,
3885            "ce013625030ba8dba906f756967f9e9ca394464a",
3886        )
3887        .expect("test operation should succeed");
3888        // Create the ref *with* a reflog entry so logs/refs/heads/main exists on
3889        // disk; git unlinks that file on delete rather than appending a deletion
3890        // entry, so the checked delete must remove it (mirroring delete_ref).
3891        let mut tx = store.transaction();
3892        tx.update(RefUpdate {
3893            name: "refs/heads/main".into(),
3894            expected: None,
3895            new: RefTarget::Direct(oid),
3896            reflog: Some(ReflogEntry {
3897                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
3898                new_oid: oid,
3899                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3900                message: b"create main".to_vec(),
3901            }),
3902        });
3903        tx.commit().expect("test operation should succeed");
3904        assert!(
3905            git_dir
3906                .join("logs")
3907                .join("refs")
3908                .join("heads")
3909                .join("main")
3910                .exists(),
3911            "reflog file should exist before the checked delete"
3912        );
3913
3914        let deleted = store
3915            .delete_ref_checked(DeleteRef {
3916                name: "refs/heads/main".into(),
3917                expected_old: Some(oid),
3918                reflog: Some(DeleteRefReflog {
3919                    committer: b"Git Rs <sley@example.invalid> 123 +0000".to_vec(),
3920                    message: b"delete main".to_vec(),
3921                }),
3922            })
3923            .expect("test operation should succeed");
3924
3925        assert_eq!(deleted.name, "refs/heads/main");
3926        assert_eq!(deleted.oid, oid);
3927        assert_eq!(
3928            store
3929                .read_ref("refs/heads/main")
3930                .expect("test operation should succeed"),
3931            None
3932        );
3933        // Git unlinks the reflog on delete: the file is gone and there is no
3934        // lingering deletion entry to read back.
3935        assert!(
3936            !git_dir
3937                .join("logs")
3938                .join("refs")
3939                .join("heads")
3940                .join("main")
3941                .exists(),
3942            "reflog file should be removed by the checked delete"
3943        );
3944        assert!(
3945            store
3946                .read_reflog("refs/heads/main")
3947                .expect("test operation should succeed")
3948                .is_empty()
3949        );
3950        assert!(
3951            !git_dir
3952                .join("refs")
3953                .join("heads")
3954                .join("main.lock")
3955                .exists()
3956        );
3957        assert!(!git_dir.join("packed-refs.lock").exists());
3958        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3959    }
3960
3961    #[test]
3962    fn file_ref_store_delete_ref_checked_stale_expected_leaves_ref_untouched() {
3963        let git_dir = temp_git_dir();
3964        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3965        let actual = ObjectId::from_hex(
3966            ObjectFormat::Sha1,
3967            "ce013625030ba8dba906f756967f9e9ca394464a",
3968        )
3969        .expect("test operation should succeed");
3970        let expected = ObjectId::from_hex(
3971            ObjectFormat::Sha1,
3972            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
3973        )
3974        .expect("test operation should succeed");
3975        let mut tx = store.transaction();
3976        tx.update(RefUpdate {
3977            name: "refs/heads/main".into(),
3978            expected: None,
3979            new: RefTarget::Direct(actual),
3980            reflog: None,
3981        });
3982        tx.commit().expect("test operation should succeed");
3983
3984        let err = store
3985            .delete_ref_checked(DeleteRef {
3986                name: "refs/heads/main".into(),
3987                expected_old: Some(expected),
3988                reflog: None,
3989            })
3990            .expect_err("stale expected must fail");
3991
3992        assert!(matches!(
3993            err,
3994            RefDeleteError::ExpectedMismatch {
3995                expected: Some(got_expected),
3996                actual: Some(got_actual),
3997            } if got_expected == expected && got_actual == actual
3998        ));
3999        assert_eq!(
4000            store
4001                .read_ref("refs/heads/main")
4002                .expect("test operation should succeed"),
4003            Some(RefTarget::Direct(actual))
4004        );
4005        assert!(
4006            !git_dir
4007                .join("refs")
4008                .join("heads")
4009                .join("main.lock")
4010                .exists()
4011        );
4012        assert!(!git_dir.join("packed-refs.lock").exists());
4013        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4014    }
4015
4016    #[test]
4017    fn file_ref_store_delete_ref_checked_missing_returns_not_found() {
4018        let git_dir = temp_git_dir();
4019        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4020
4021        let err = store
4022            .delete_ref_checked(DeleteRef {
4023                name: "refs/heads/missing".into(),
4024                expected_old: None,
4025                reflog: None,
4026            })
4027            .expect_err("missing ref must fail");
4028
4029        assert!(matches!(err, RefDeleteError::NotFound));
4030        assert!(
4031            !git_dir
4032                .join("refs")
4033                .join("heads")
4034                .join("missing.lock")
4035                .exists()
4036        );
4037        assert!(!git_dir.join("packed-refs.lock").exists());
4038        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4039    }
4040
4041    #[test]
4042    fn file_ref_store_delete_ref_checked_removes_packed_ref() {
4043        let git_dir = temp_git_dir();
4044        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4045        let oid = ObjectId::from_hex(
4046            ObjectFormat::Sha1,
4047            "ce013625030ba8dba906f756967f9e9ca394464a",
4048        )
4049        .expect("test operation should succeed");
4050        let other = ObjectId::from_hex(
4051            ObjectFormat::Sha1,
4052            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4053        )
4054        .expect("test operation should succeed");
4055        store
4056            .write_packed_refs(&[
4057                PackedRef {
4058                    reference: Ref {
4059                        name: "refs/heads/main".into(),
4060                        target: RefTarget::Direct(oid),
4061                    },
4062                    peeled: None,
4063                },
4064                PackedRef {
4065                    reference: Ref {
4066                        name: "refs/heads/other".into(),
4067                        target: RefTarget::Direct(other),
4068                    },
4069                    peeled: None,
4070                },
4071            ])
4072            .expect("test operation should succeed");
4073
4074        store
4075            .delete_ref_checked(DeleteRef {
4076                name: "refs/heads/main".into(),
4077                expected_old: Some(oid),
4078                reflog: None,
4079            })
4080            .expect("test operation should succeed");
4081
4082        assert_eq!(
4083            store
4084                .read_ref("refs/heads/main")
4085                .expect("test operation should succeed"),
4086            None
4087        );
4088        assert_eq!(
4089            store
4090                .read_ref("refs/heads/other")
4091                .expect("test operation should succeed"),
4092            Some(RefTarget::Direct(other))
4093        );
4094        let packed =
4095            fs::read_to_string(git_dir.join("packed-refs")).expect("test operation should succeed");
4096        assert!(!packed.contains("refs/heads/main"));
4097        assert!(packed.contains("refs/heads/other"));
4098        assert!(
4099            !git_dir
4100                .join("refs")
4101                .join("heads")
4102                .join("main.lock")
4103                .exists()
4104        );
4105        assert!(!git_dir.join("packed-refs.lock").exists());
4106        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4107    }
4108
4109    #[test]
4110    fn file_ref_store_delete_ref_checked_lock_conflict_returns_locked() {
4111        let git_dir = temp_git_dir();
4112        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4113        let oid = ObjectId::from_hex(
4114            ObjectFormat::Sha1,
4115            "ce013625030ba8dba906f756967f9e9ca394464a",
4116        )
4117        .expect("test operation should succeed");
4118        let mut tx = store.transaction();
4119        tx.update(RefUpdate {
4120            name: "refs/heads/main".into(),
4121            expected: None,
4122            new: RefTarget::Direct(oid),
4123            reflog: None,
4124        });
4125        tx.commit().expect("test operation should succeed");
4126        fs::write(
4127            git_dir.join("refs").join("heads").join("main.lock"),
4128            b"held\n",
4129        )
4130        .expect("test operation should succeed");
4131
4132        let err = store
4133            .delete_ref_checked(DeleteRef {
4134                name: "refs/heads/main".into(),
4135                expected_old: Some(oid),
4136                reflog: None,
4137            })
4138            .expect_err("held lock must fail");
4139
4140        assert!(matches!(err, RefDeleteError::Locked));
4141        assert_eq!(
4142            store
4143                .read_ref("refs/heads/main")
4144                .expect("test operation should succeed"),
4145            Some(RefTarget::Direct(oid))
4146        );
4147        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4148    }
4149
4150    #[test]
4151    fn file_ref_store_reports_current_branch() {
4152        let git_dir = temp_git_dir();
4153        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
4154            .expect("test operation should succeed");
4155        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4156        assert_eq!(
4157            store
4158                .current_branch_ref()
4159                .expect("test operation should succeed"),
4160            Some("refs/heads/main".into())
4161        );
4162        assert_eq!(
4163            store
4164                .current_branch()
4165                .expect("test operation should succeed"),
4166            Some("main".into())
4167        );
4168        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4169    }
4170
4171    #[test]
4172    fn file_ref_store_resolves_linked_worktree_head_through_common_refs() {
4173        let common = temp_git_dir();
4174        let admin = common.join("worktrees").join("linked");
4175        fs::create_dir_all(&admin).expect("test operation should succeed");
4176        fs::write(admin.join("commondir"), "../..\n").expect("test operation should succeed");
4177        fs::write(admin.join("HEAD"), b"ref: refs/heads/topic\n")
4178            .expect("test operation should succeed");
4179        let oid = ObjectId::from_hex(
4180            ObjectFormat::Sha256,
4181            "08ffba112b648c22b5425f01bec2c37ffc524c4d48ef04337779df3973733050",
4182        )
4183        .expect("test operation should succeed");
4184        fs::create_dir_all(common.join("refs").join("heads"))
4185            .expect("test operation should succeed");
4186        fs::write(
4187            common.join("refs").join("heads").join("topic"),
4188            format!("{oid}\n"),
4189        )
4190        .expect("test operation should succeed");
4191
4192        let store = FileRefStore::new(&admin, ObjectFormat::Sha256);
4193        assert_eq!(
4194            store
4195                .read_ref("HEAD")
4196                .expect("test operation should succeed"),
4197            Some(RefTarget::Symbolic("refs/heads/topic".into()))
4198        );
4199        assert_eq!(
4200            store
4201                .read_ref("refs/heads/topic")
4202                .expect("test operation should succeed"),
4203            Some(RefTarget::Direct(oid))
4204        );
4205
4206        fs::remove_dir_all(common).expect("test operation should succeed");
4207    }
4208
4209    #[test]
4210    fn file_ref_store_creates_tag() {
4211        let git_dir = temp_git_dir();
4212        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4213        let oid = ObjectId::from_hex(
4214            ObjectFormat::Sha1,
4215            "ce013625030ba8dba906f756967f9e9ca394464a",
4216        )
4217        .expect("test operation should succeed");
4218        let tag = store
4219            .create_tag("v1.0", oid)
4220            .expect("test operation should succeed");
4221        assert_eq!(tag.name, "refs/tags/v1.0");
4222        assert_eq!(
4223            store
4224                .read_ref("refs/tags/v1.0")
4225                .expect("test operation should succeed"),
4226            Some(RefTarget::Direct(oid))
4227        );
4228        assert!(
4229            store
4230                .read_reflog("refs/tags/v1.0")
4231                .expect("test operation should succeed")
4232                .is_empty()
4233        );
4234        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4235    }
4236
4237    #[test]
4238    fn file_ref_store_deletes_loose_tag() {
4239        let git_dir = temp_git_dir();
4240        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4241        let oid = ObjectId::from_hex(
4242            ObjectFormat::Sha1,
4243            "ce013625030ba8dba906f756967f9e9ca394464a",
4244        )
4245        .expect("test operation should succeed");
4246        store
4247            .create_tag("v1.0", oid)
4248            .expect("test operation should succeed");
4249        let deleted = store
4250            .delete_tag("v1.0")
4251            .expect("test operation should succeed");
4252        assert_eq!(deleted.name, "refs/tags/v1.0");
4253        assert_eq!(deleted.oid, oid);
4254        assert_eq!(
4255            store
4256                .read_ref("refs/tags/v1.0")
4257                .expect("test operation should succeed"),
4258            None
4259        );
4260        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
4261        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4262    }
4263
4264    #[test]
4265    fn file_ref_store_reads_packed_ref() {
4266        let git_dir = temp_git_dir();
4267        fs::write(
4268            git_dir.join("packed-refs"),
4269            b"ce013625030ba8dba906f756967f9e9ca394464a refs/heads/main\n",
4270        )
4271        .expect("test operation should succeed");
4272        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4273        assert!(matches!(
4274            store
4275                .read_ref("refs/heads/main")
4276                .expect("test operation should succeed"),
4277            Some(RefTarget::Direct(_))
4278        ));
4279        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4280    }
4281
4282    #[test]
4283    fn file_ref_store_lists_loose_refs_over_packed_refs() {
4284        let git_dir = temp_git_dir();
4285        fs::write(
4286            git_dir.join("packed-refs"),
4287            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n",
4288        )
4289        .expect("test operation should succeed");
4290        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4291        let oid = ObjectId::from_hex(
4292            ObjectFormat::Sha1,
4293            "ce013625030ba8dba906f756967f9e9ca394464a",
4294        )
4295        .expect("test operation should succeed");
4296        let mut tx = store.transaction();
4297        tx.update(RefUpdate {
4298            name: "refs/heads/main".into(),
4299            expected: None,
4300            new: RefTarget::Direct(oid),
4301            reflog: None,
4302        });
4303        tx.commit().expect("test operation should succeed");
4304        let refs = store.list_refs().expect("test operation should succeed");
4305        assert_eq!(refs.len(), 1);
4306        assert_eq!(refs[0].target, RefTarget::Direct(oid));
4307        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4308    }
4309
4310    #[test]
4311    fn file_ref_store_writes_packed_refs() {
4312        let git_dir = temp_git_dir();
4313        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4314        let oid = ObjectId::from_hex(
4315            ObjectFormat::Sha1,
4316            "ce013625030ba8dba906f756967f9e9ca394464a",
4317        )
4318        .expect("test operation should succeed");
4319        store
4320            .write_packed_refs(&[PackedRef {
4321                reference: Ref {
4322                    name: "refs/heads/main".into(),
4323                    target: RefTarget::Direct(oid),
4324                },
4325                peeled: None,
4326            }])
4327            .expect("test operation should succeed");
4328        assert_eq!(
4329            store
4330                .read_ref("refs/heads/main")
4331                .expect("test operation should succeed"),
4332            Some(RefTarget::Direct(oid))
4333        );
4334        let refs = store.list_refs().expect("test operation should succeed");
4335        assert_eq!(refs.len(), 1);
4336        assert_eq!(refs[0].target, RefTarget::Direct(oid));
4337        assert!(git_dir.join("packed-refs").exists());
4338        assert!(!git_dir.join("packed-refs.lock").exists());
4339        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4340    }
4341
4342    #[test]
4343    fn file_ref_store_reads_reftable_stack_and_ignores_dummy_head() {
4344        let git_dir = temp_git_dir();
4345        write_reftable_config(&git_dir);
4346        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/.invalid\n")
4347            .expect("test operation should succeed");
4348        let head_oid = ObjectId::from_hex(
4349            ObjectFormat::Sha1,
4350            "ce013625030ba8dba906f756967f9e9ca394464a",
4351        )
4352        .expect("test operation should succeed");
4353        let tag_oid = ObjectId::from_hex(
4354            ObjectFormat::Sha1,
4355            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
4356        )
4357        .expect("test operation should succeed");
4358        let peeled_oid = ObjectId::from_hex(
4359            ObjectFormat::Sha1,
4360            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4361        )
4362        .expect("test operation should succeed");
4363        write_reftable_stack(
4364            &git_dir,
4365            &[(
4366                "000000000001-000000000001-rust.ref",
4367                vec![
4368                    sley_formats::ReftableRefRecord {
4369                        name: "HEAD".into(),
4370                        update_index: 1,
4371                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
4372                    },
4373                    sley_formats::ReftableRefRecord {
4374                        name: "refs/heads/main".into(),
4375                        update_index: 1,
4376                        value: ReftableRefValue::Direct(head_oid),
4377                    },
4378                    sley_formats::ReftableRefRecord {
4379                        name: "refs/tags/v1.0".into(),
4380                        update_index: 1,
4381                        value: ReftableRefValue::Peeled {
4382                            target: tag_oid,
4383                            peeled: peeled_oid,
4384                        },
4385                    },
4386                ],
4387            )],
4388        );
4389
4390        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4391        assert_eq!(
4392            store
4393                .read_ref("HEAD")
4394                .expect("test operation should succeed"),
4395            Some(RefTarget::Symbolic("refs/heads/main".into()))
4396        );
4397        assert_eq!(
4398            store
4399                .read_ref("refs/heads/main")
4400                .expect("test operation should succeed"),
4401            Some(RefTarget::Direct(head_oid))
4402        );
4403        assert_eq!(
4404            store
4405                .read_ref("refs/tags/v1.0")
4406                .expect("test operation should succeed"),
4407            Some(RefTarget::Direct(tag_oid))
4408        );
4409        let refs = store.list_refs().expect("test operation should succeed");
4410        assert_eq!(
4411            refs,
4412            vec![
4413                Ref {
4414                    name: "refs/heads/main".into(),
4415                    target: RefTarget::Direct(head_oid),
4416                },
4417                Ref {
4418                    name: "refs/tags/v1.0".into(),
4419                    target: RefTarget::Direct(tag_oid),
4420                },
4421            ]
4422        );
4423
4424        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4425    }
4426
4427    #[test]
4428    fn file_ref_store_applies_reftable_stack_overrides_and_deletions() {
4429        let git_dir = temp_git_dir();
4430        write_reftable_config(&git_dir);
4431        let first = ObjectId::from_hex(
4432            ObjectFormat::Sha1,
4433            "ce013625030ba8dba906f756967f9e9ca394464a",
4434        )
4435        .expect("test operation should succeed");
4436        let second = ObjectId::from_hex(
4437            ObjectFormat::Sha1,
4438            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4439        )
4440        .expect("test operation should succeed");
4441        write_reftable_stack(
4442            &git_dir,
4443            &[
4444                (
4445                    "000000000001-000000000001-base.ref",
4446                    vec![
4447                        sley_formats::ReftableRefRecord {
4448                            name: "refs/heads/main".into(),
4449                            update_index: 1,
4450                            value: ReftableRefValue::Direct(first),
4451                        },
4452                        sley_formats::ReftableRefRecord {
4453                            name: "refs/heads/topic".into(),
4454                            update_index: 1,
4455                            value: ReftableRefValue::Direct(second.clone()),
4456                        },
4457                    ],
4458                ),
4459                (
4460                    "000000000002-000000000002-tip.ref",
4461                    vec![
4462                        sley_formats::ReftableRefRecord {
4463                            name: "refs/heads/main".into(),
4464                            update_index: 2,
4465                            value: ReftableRefValue::Direct(second.clone()),
4466                        },
4467                        sley_formats::ReftableRefRecord {
4468                            name: "refs/heads/topic".into(),
4469                            update_index: 2,
4470                            value: ReftableRefValue::Deletion,
4471                        },
4472                    ],
4473                ),
4474            ],
4475        );
4476
4477        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4478        assert_eq!(
4479            store
4480                .read_ref("refs/heads/main")
4481                .expect("test operation should succeed"),
4482            Some(RefTarget::Direct(second.clone()))
4483        );
4484        assert_eq!(
4485            store
4486                .read_ref("refs/heads/topic")
4487                .expect("test operation should succeed"),
4488            None
4489        );
4490        assert_eq!(
4491            store.list_refs().expect("test operation should succeed"),
4492            vec![Ref {
4493                name: "refs/heads/main".into(),
4494                target: RefTarget::Direct(second),
4495            }]
4496        );
4497
4498        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4499    }
4500
4501    #[test]
4502    fn file_ref_store_writes_reftable_transaction_table() {
4503        let git_dir = temp_git_dir();
4504        write_reftable_config(&git_dir);
4505        let first = ObjectId::from_hex(
4506            ObjectFormat::Sha1,
4507            "ce013625030ba8dba906f756967f9e9ca394464a",
4508        )
4509        .expect("test operation should succeed");
4510        let second = ObjectId::from_hex(
4511            ObjectFormat::Sha1,
4512            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4513        )
4514        .expect("test operation should succeed");
4515        write_reftable_stack(
4516            &git_dir,
4517            &[(
4518                "000000000001-000000000001-base.ref",
4519                vec![sley_formats::ReftableRefRecord {
4520                    name: "refs/heads/main".into(),
4521                    update_index: 1,
4522                    value: ReftableRefValue::Direct(first),
4523                }],
4524            )],
4525        );
4526
4527        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4528        let mut tx = store.transaction();
4529        tx.update(RefUpdate {
4530            name: "HEAD".into(),
4531            expected: None,
4532            new: RefTarget::Symbolic("refs/heads/main".into()),
4533            reflog: None,
4534        });
4535        tx.update(RefUpdate {
4536            name: "refs/heads/main".into(),
4537            expected: None,
4538            new: RefTarget::Direct(second.clone()),
4539            reflog: None,
4540        });
4541        tx.commit().expect("test operation should succeed");
4542
4543        assert_eq!(
4544            store
4545                .read_ref("HEAD")
4546                .expect("test operation should succeed"),
4547            Some(RefTarget::Symbolic("refs/heads/main".into()))
4548        );
4549        assert_eq!(
4550            store
4551                .read_ref("refs/heads/main")
4552                .expect("test operation should succeed"),
4553            Some(RefTarget::Direct(second.clone()))
4554        );
4555        assert_eq!(
4556            store
4557                .list_refs()
4558                .expect("test operation should succeed")
4559                .len(),
4560            1
4561        );
4562        assert!(!git_dir.join("HEAD").exists());
4563        let tables = fs::read_to_string(git_dir.join("reftable").join("tables.list"))
4564            .expect("test operation should succeed");
4565        assert_eq!(tables.lines().count(), 2);
4566        assert!(
4567            tables
4568                .lines()
4569                .last()
4570                .expect("test operation should succeed")
4571                .contains("sley"),
4572            "expected rust-written reftable in tables.list, got {tables}"
4573        );
4574
4575        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4576    }
4577
4578    #[test]
4579    fn file_ref_store_deletes_reftable_refs_with_tombstones() {
4580        let git_dir = temp_git_dir();
4581        write_reftable_config(&git_dir);
4582        let oid = ObjectId::from_hex(
4583            ObjectFormat::Sha1,
4584            "ce013625030ba8dba906f756967f9e9ca394464a",
4585        )
4586        .expect("test operation should succeed");
4587        write_reftable_stack(
4588            &git_dir,
4589            &[(
4590                "000000000001-000000000001-base.ref",
4591                vec![
4592                    sley_formats::ReftableRefRecord {
4593                        name: "refs/heads/main".into(),
4594                        update_index: 1,
4595                        value: ReftableRefValue::Direct(oid),
4596                    },
4597                    sley_formats::ReftableRefRecord {
4598                        name: "refs/alias/main".into(),
4599                        update_index: 1,
4600                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
4601                    },
4602                ],
4603            )],
4604        );
4605
4606        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4607        assert!(
4608            store
4609                .delete_symbolic_ref("refs/alias/main")
4610                .expect("test operation should succeed")
4611        );
4612        assert_eq!(
4613            store
4614                .read_ref("refs/alias/main")
4615                .expect("test operation should succeed"),
4616            None
4617        );
4618        let deleted = store
4619            .delete_ref("refs/heads/main")
4620            .expect("test operation should succeed");
4621        assert_eq!(deleted.oid, oid);
4622        assert_eq!(
4623            store
4624                .read_ref("refs/heads/main")
4625                .expect("test operation should succeed"),
4626            None
4627        );
4628        assert!(
4629            store
4630                .list_refs()
4631                .expect("test operation should succeed")
4632                .is_empty()
4633        );
4634        let tables = fs::read_to_string(git_dir.join("reftable").join("tables.list"))
4635            .expect("test operation should succeed");
4636        assert_eq!(tables.lines().count(), 3);
4637
4638        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4639    }
4640
4641    #[test]
4642    fn file_ref_store_deletes_packed_branch() {
4643        let git_dir = temp_git_dir();
4644        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4645        let branch_oid = ObjectId::from_hex(
4646            ObjectFormat::Sha1,
4647            "ce013625030ba8dba906f756967f9e9ca394464a",
4648        )
4649        .expect("test operation should succeed");
4650        let tag_oid = ObjectId::from_hex(
4651            ObjectFormat::Sha1,
4652            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4653        )
4654        .expect("test operation should succeed");
4655        store
4656            .write_packed_refs(&[
4657                PackedRef {
4658                    reference: Ref {
4659                        name: "refs/heads/feature".into(),
4660                        target: RefTarget::Direct(branch_oid),
4661                    },
4662                    peeled: None,
4663                },
4664                PackedRef {
4665                    reference: Ref {
4666                        name: "refs/tags/v1.0".into(),
4667                        target: RefTarget::Direct(tag_oid),
4668                    },
4669                    peeled: None,
4670                },
4671            ])
4672            .expect("test operation should succeed");
4673        let deleted = store
4674            .delete_branch("feature")
4675            .expect("test operation should succeed");
4676        assert_eq!(deleted.name, "refs/heads/feature");
4677        assert_eq!(deleted.oid, branch_oid);
4678        assert_eq!(
4679            store
4680                .read_ref("refs/heads/feature")
4681                .expect("test operation should succeed"),
4682            None
4683        );
4684        assert_eq!(
4685            store
4686                .read_ref("refs/tags/v1.0")
4687                .expect("test operation should succeed"),
4688            Some(RefTarget::Direct(tag_oid))
4689        );
4690        assert!(!git_dir.join("packed-refs.lock").exists());
4691        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4692    }
4693
4694    #[test]
4695    fn file_ref_store_deletes_packed_tag() {
4696        let git_dir = temp_git_dir();
4697        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4698        let oid = ObjectId::from_hex(
4699            ObjectFormat::Sha1,
4700            "ce013625030ba8dba906f756967f9e9ca394464a",
4701        )
4702        .expect("test operation should succeed");
4703        store
4704            .write_packed_refs(&[PackedRef {
4705                reference: Ref {
4706                    name: "refs/tags/v1.0".into(),
4707                    target: RefTarget::Direct(oid),
4708                },
4709                peeled: None,
4710            }])
4711            .expect("test operation should succeed");
4712        let deleted = store
4713            .delete_tag("v1.0")
4714            .expect("test operation should succeed");
4715        assert_eq!(deleted.name, "refs/tags/v1.0");
4716        assert_eq!(deleted.oid, oid);
4717        assert_eq!(
4718            store
4719                .read_ref("refs/tags/v1.0")
4720                .expect("test operation should succeed"),
4721            None
4722        );
4723        assert!(!git_dir.join("packed-refs.lock").exists());
4724        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4725    }
4726
4727    #[test]
4728    fn file_ref_store_packs_loose_refs_and_prunes() {
4729        let git_dir = temp_git_dir();
4730        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4731        let main_oid = ObjectId::from_hex(
4732            ObjectFormat::Sha1,
4733            "ce013625030ba8dba906f756967f9e9ca394464a",
4734        )
4735        .expect("test operation should succeed");
4736        let tag_oid = ObjectId::from_hex(
4737            ObjectFormat::Sha1,
4738            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4739        )
4740        .expect("test operation should succeed");
4741        let mut tx = store.transaction();
4742        tx.update(RefUpdate {
4743            name: "refs/heads/main".into(),
4744            expected: None,
4745            new: RefTarget::Direct(main_oid),
4746            reflog: None,
4747        });
4748        tx.update(RefUpdate {
4749            name: "refs/tags/v1.0".into(),
4750            expected: None,
4751            new: RefTarget::Direct(tag_oid),
4752            reflog: None,
4753        });
4754        tx.commit().expect("test operation should succeed");
4755
4756        let packed = store
4757            .pack_refs(true)
4758            .expect("test operation should succeed");
4759        assert_eq!(packed.len(), 2);
4760        assert_eq!(
4761            store
4762                .read_ref("refs/heads/main")
4763                .expect("test operation should succeed"),
4764            Some(RefTarget::Direct(main_oid))
4765        );
4766        assert_eq!(
4767            store
4768                .read_ref("refs/tags/v1.0")
4769                .expect("test operation should succeed"),
4770            Some(RefTarget::Direct(tag_oid))
4771        );
4772        assert!(!git_dir.join("refs").join("heads").join("main").exists());
4773        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
4774        assert!(git_dir.join("packed-refs").exists());
4775        assert!(!git_dir.join("packed-refs.lock").exists());
4776        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4777    }
4778
4779    #[test]
4780    fn file_ref_store_packs_loose_refs_without_pruning() {
4781        let git_dir = temp_git_dir();
4782        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4783        let oid = ObjectId::from_hex(
4784            ObjectFormat::Sha1,
4785            "ce013625030ba8dba906f756967f9e9ca394464a",
4786        )
4787        .expect("test operation should succeed");
4788        let mut tx = store.transaction();
4789        tx.update(RefUpdate {
4790            name: "refs/heads/main".into(),
4791            expected: None,
4792            new: RefTarget::Direct(oid),
4793            reflog: None,
4794        });
4795        tx.commit().expect("test operation should succeed");
4796
4797        let packed = store
4798            .pack_refs(false)
4799            .expect("test operation should succeed");
4800        assert_eq!(packed.len(), 1);
4801        assert!(git_dir.join("refs").join("heads").join("main").exists());
4802        assert_eq!(
4803            store
4804                .read_ref("refs/heads/main")
4805                .expect("test operation should succeed"),
4806            Some(RefTarget::Direct(oid))
4807        );
4808        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4809    }
4810
4811    #[test]
4812    fn file_ref_store_packs_loose_refs_with_peeled_ids() {
4813        let git_dir = temp_git_dir();
4814        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4815        let tag_oid = ObjectId::from_hex(
4816            ObjectFormat::Sha1,
4817            "ce013625030ba8dba906f756967f9e9ca394464a",
4818        )
4819        .expect("test operation should succeed");
4820        let peeled_oid = ObjectId::from_hex(
4821            ObjectFormat::Sha1,
4822            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4823        )
4824        .expect("test operation should succeed");
4825        let mut tx = store.transaction();
4826        tx.update(RefUpdate {
4827            name: "refs/tags/v1.0".into(),
4828            expected: None,
4829            new: RefTarget::Direct(tag_oid),
4830            reflog: None,
4831        });
4832        tx.commit().expect("test operation should succeed");
4833
4834        let packed = store
4835            .pack_refs_with_peeler(true, |name, oid| {
4836                if name == "refs/tags/v1.0" && oid == &tag_oid {
4837                    Ok(Some(peeled_oid))
4838                } else {
4839                    Ok(None)
4840                }
4841            })
4842            .expect("test operation should succeed");
4843        assert_eq!(packed.len(), 1);
4844        assert_eq!(packed[0].peeled, Some(peeled_oid));
4845        let bytes =
4846            fs::read_to_string(git_dir.join("packed-refs")).expect("test operation should succeed");
4847        assert!(bytes.contains(&format!("^{peeled_oid}\n")));
4848        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
4849        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4850    }
4851
4852    fn reflog_entry(new_oid: &ObjectId, timestamp: i64, message: &str) -> ReflogEntry {
4853        ReflogEntry {
4854            old_oid: zero_oid(new_oid.format()).expect("test operation should succeed"),
4855            new_oid: *new_oid,
4856            committer: format!("Git Rs <sley@example.invalid> {timestamp} +0000").into_bytes(),
4857            message: message.as_bytes().to_vec(),
4858        }
4859    }
4860
4861    #[test]
4862    fn expire_reflog_drops_old_entries_and_keeps_latest() {
4863        let oid_a = ObjectId::from_hex(
4864            ObjectFormat::Sha1,
4865            "ce013625030ba8dba906f756967f9e9ca394464a",
4866        )
4867        .expect("test operation should succeed");
4868        let oid_b = ObjectId::from_hex(
4869            ObjectFormat::Sha1,
4870            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4871        )
4872        .expect("test operation should succeed");
4873        let oid_c = ObjectId::from_hex(
4874            ObjectFormat::Sha1,
4875            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
4876        )
4877        .expect("test operation should succeed");
4878        let entries = vec![
4879            reflog_entry(&oid_a, 10, "oldest"),
4880            reflog_entry(&oid_b, 100, "middle"),
4881            reflog_entry(&oid_c, 20, "latest"),
4882        ];
4883
4884        // Cutoff drops the oldest entry; the most recent entry survives even
4885        // though its timestamp (20) is below the cutoff (50).
4886        let retained =
4887            expire_reflog(&entries, 50, None, |_| true).expect("test operation should succeed");
4888        assert_eq!(retained.len(), 2);
4889        assert_eq!(retained[0].message, b"middle");
4890        assert_eq!(retained[1].message, b"latest");
4891    }
4892
4893    #[test]
4894    fn expire_reflog_applies_stricter_unreachable_cutoff() {
4895        let reachable = ObjectId::from_hex(
4896            ObjectFormat::Sha1,
4897            "ce013625030ba8dba906f756967f9e9ca394464a",
4898        )
4899        .expect("test operation should succeed");
4900        let unreachable = ObjectId::from_hex(
4901            ObjectFormat::Sha1,
4902            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4903        )
4904        .expect("test operation should succeed");
4905        let tip = ObjectId::from_hex(
4906            ObjectFormat::Sha1,
4907            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
4908        )
4909        .expect("test operation should succeed");
4910        // Both candidate entries sit above the lenient cutoff (50) but below the
4911        // stricter unreachable cutoff (150). Only the unreachable one is dropped.
4912        let entries = vec![
4913            reflog_entry(&reachable, 100, "reachable"),
4914            reflog_entry(&unreachable, 100, "unreachable"),
4915            reflog_entry(&tip, 200, "tip"),
4916        ];
4917        let retained = expire_reflog(&entries, 50, Some(150), |oid| {
4918            oid == &reachable || oid == &tip
4919        })
4920        .expect("test operation should succeed");
4921        assert_eq!(retained.len(), 2);
4922        assert_eq!(retained[0].message, b"reachable");
4923        assert_eq!(retained[1].message, b"tip");
4924    }
4925
4926    #[test]
4927    fn expire_reflog_keeps_single_entry_below_cutoff() {
4928        let oid = ObjectId::from_hex(
4929            ObjectFormat::Sha1,
4930            "ce013625030ba8dba906f756967f9e9ca394464a",
4931        )
4932        .expect("test operation should succeed");
4933        let entries = vec![reflog_entry(&oid, 1, "only")];
4934        let retained = expire_reflog(&entries, i64::MAX, Some(i64::MAX), |_| false)
4935            .expect("test operation should succeed");
4936        assert_eq!(retained.len(), 1);
4937        assert_eq!(retained[0].message, b"only");
4938    }
4939
4940    #[test]
4941    fn file_ref_store_expire_reflog_file_rewrites_and_dry_runs() {
4942        let git_dir = temp_git_dir();
4943        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4944        let first = ObjectId::from_hex(
4945            ObjectFormat::Sha1,
4946            "ce013625030ba8dba906f756967f9e9ca394464a",
4947        )
4948        .expect("test operation should succeed");
4949        let second = ObjectId::from_hex(
4950            ObjectFormat::Sha1,
4951            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4952        )
4953        .expect("test operation should succeed");
4954        store
4955            .write_reflog(
4956                "refs/heads/main",
4957                &[
4958                    reflog_entry(&first, 10, "old"),
4959                    reflog_entry(&second, 100, "new"),
4960                ],
4961            )
4962            .expect("test operation should succeed");
4963
4964        // Dry run reports the removal count without touching the file.
4965        let would_remove = store
4966            .expire_reflog_file("refs/heads/main", 50, None, false, |_| true)
4967            .expect("test operation should succeed");
4968        assert_eq!(would_remove, 1);
4969        assert_eq!(
4970            store
4971                .read_reflog("refs/heads/main")
4972                .expect("test operation should succeed")
4973                .len(),
4974            2
4975        );
4976
4977        // Opt-in rewrite drops the stale entry and leaves the latest.
4978        let removed = store
4979            .expire_reflog_file("refs/heads/main", 50, None, true, |_| true)
4980            .expect("test operation should succeed");
4981        assert_eq!(removed, 1);
4982        let log = store
4983            .read_reflog("refs/heads/main")
4984            .expect("test operation should succeed");
4985        assert_eq!(log.len(), 1);
4986        assert_eq!(log[0].new_oid, second);
4987        assert!(
4988            !git_dir
4989                .join("logs")
4990                .join("refs")
4991                .join("heads")
4992                .join("main.lock")
4993                .exists()
4994        );
4995        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4996    }
4997
4998    #[test]
4999    fn file_ref_transaction_commits_all_refs_atomically() {
5000        let git_dir = temp_git_dir();
5001        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5002        let main_oid = ObjectId::from_hex(
5003            ObjectFormat::Sha1,
5004            "ce013625030ba8dba906f756967f9e9ca394464a",
5005        )
5006        .expect("test operation should succeed");
5007        let topic_oid = ObjectId::from_hex(
5008            ObjectFormat::Sha1,
5009            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5010        )
5011        .expect("test operation should succeed");
5012        let tag_oid = ObjectId::from_hex(
5013            ObjectFormat::Sha1,
5014            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5015        )
5016        .expect("test operation should succeed");
5017        let mut tx = store.transaction();
5018        tx.update(RefUpdate {
5019            name: "refs/heads/main".into(),
5020            expected: None,
5021            new: RefTarget::Direct(main_oid),
5022            reflog: Some(reflog_entry(&main_oid, 0, "create main")),
5023        });
5024        tx.update(RefUpdate {
5025            name: "refs/heads/topic".into(),
5026            expected: None,
5027            new: RefTarget::Direct(topic_oid),
5028            reflog: None,
5029        });
5030        tx.update(RefUpdate {
5031            name: "refs/tags/v1.0".into(),
5032            expected: None,
5033            new: RefTarget::Direct(tag_oid),
5034            reflog: None,
5035        });
5036        tx.commit().expect("test operation should succeed");
5037
5038        assert_eq!(
5039            store
5040                .read_ref("refs/heads/main")
5041                .expect("test operation should succeed"),
5042            Some(RefTarget::Direct(main_oid))
5043        );
5044        assert_eq!(
5045            store
5046                .read_ref("refs/heads/topic")
5047                .expect("test operation should succeed"),
5048            Some(RefTarget::Direct(topic_oid))
5049        );
5050        assert_eq!(
5051            store
5052                .read_ref("refs/tags/v1.0")
5053                .expect("test operation should succeed"),
5054            Some(RefTarget::Direct(tag_oid))
5055        );
5056        let main_log = store
5057            .read_reflog("refs/heads/main")
5058            .expect("test operation should succeed");
5059        assert_eq!(main_log.len(), 1);
5060        assert_eq!(main_log[0].new_oid, main_oid);
5061        // No lock files survive a successful commit.
5062        assert!(
5063            !git_dir
5064                .join("refs")
5065                .join("heads")
5066                .join("main.lock")
5067                .exists()
5068        );
5069        assert!(
5070            !git_dir
5071                .join("refs")
5072                .join("heads")
5073                .join("topic.lock")
5074                .exists()
5075        );
5076        assert!(!git_dir.join("refs").join("tags").join("v1.0.lock").exists());
5077        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5078    }
5079
5080    #[test]
5081    fn file_ref_transaction_rolls_back_all_refs_on_expected_mismatch() {
5082        let git_dir = temp_git_dir();
5083        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5084        let old_topic = ObjectId::from_hex(
5085            ObjectFormat::Sha1,
5086            "ce013625030ba8dba906f756967f9e9ca394464a",
5087        )
5088        .expect("test operation should succeed");
5089        let new_main = ObjectId::from_hex(
5090            ObjectFormat::Sha1,
5091            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5092        )
5093        .expect("test operation should succeed");
5094        let new_tag = ObjectId::from_hex(
5095            ObjectFormat::Sha1,
5096            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5097        )
5098        .expect("test operation should succeed");
5099        let wrong_expected = ObjectId::from_hex(
5100            ObjectFormat::Sha1,
5101            "0000000000000000000000000000000000000001",
5102        )
5103        .expect("test operation should succeed");
5104
5105        // Seed an existing topic ref so the failing update has a real prior value
5106        // to be compared against (and left untouched).
5107        let mut seed = store.transaction();
5108        seed.update(RefUpdate {
5109            name: "refs/heads/topic".into(),
5110            expected: None,
5111            new: RefTarget::Direct(old_topic.clone()),
5112            reflog: None,
5113        });
5114        seed.commit().expect("test operation should succeed");
5115
5116        let mut tx = store.transaction();
5117        // 1st ref: brand new, would succeed in isolation.
5118        tx.update(RefUpdate {
5119            name: "refs/heads/main".into(),
5120            expected: None,
5121            new: RefTarget::Direct(new_main.clone()),
5122            reflog: Some(reflog_entry(&new_main, 0, "create main")),
5123        });
5124        // 2nd ref: expected value does not match on disk -> whole tx must abort.
5125        tx.update(RefUpdate {
5126            name: "refs/heads/topic".into(),
5127            expected: Some(RefTarget::Direct(wrong_expected)),
5128            new: RefTarget::Direct(new_main.clone()),
5129            reflog: None,
5130        });
5131        // 3rd ref: brand new, must not be written because the tx aborts.
5132        tx.update(RefUpdate {
5133            name: "refs/tags/v1.0".into(),
5134            expected: None,
5135            new: RefTarget::Direct(new_tag),
5136            reflog: None,
5137        });
5138        let result = tx.commit();
5139        assert!(result.is_err());
5140
5141        // Nothing changed: the new refs were never created and the existing one
5142        // keeps its original value.
5143        assert_eq!(
5144            store
5145                .read_ref("refs/heads/main")
5146                .expect("test operation should succeed"),
5147            None
5148        );
5149        assert_eq!(
5150            store
5151                .read_ref("refs/heads/topic")
5152                .expect("test operation should succeed"),
5153            Some(RefTarget::Direct(old_topic))
5154        );
5155        assert_eq!(
5156            store
5157                .read_ref("refs/tags/v1.0")
5158                .expect("test operation should succeed"),
5159            None
5160        );
5161        assert!(
5162            store
5163                .read_reflog("refs/heads/main")
5164                .expect("test operation should succeed")
5165                .is_empty()
5166        );
5167
5168        // All lock files were released.
5169        assert!(
5170            !git_dir
5171                .join("refs")
5172                .join("heads")
5173                .join("main.lock")
5174                .exists()
5175        );
5176        assert!(
5177            !git_dir
5178                .join("refs")
5179                .join("heads")
5180                .join("topic.lock")
5181                .exists()
5182        );
5183        assert!(!git_dir.join("refs").join("tags").join("v1.0.lock").exists());
5184        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5185    }
5186
5187    #[test]
5188    fn file_ref_transaction_mixes_update_and_delete() {
5189        let git_dir = temp_git_dir();
5190        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5191        let old_main = ObjectId::from_hex(
5192            ObjectFormat::Sha1,
5193            "ce013625030ba8dba906f756967f9e9ca394464a",
5194        )
5195        .expect("test operation should succeed");
5196        let new_topic = ObjectId::from_hex(
5197            ObjectFormat::Sha1,
5198            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5199        )
5200        .expect("test operation should succeed");
5201        let mut seed = store.transaction();
5202        seed.update(RefUpdate {
5203            name: "refs/heads/main".into(),
5204            expected: None,
5205            new: RefTarget::Direct(old_main),
5206            reflog: None,
5207        });
5208        seed.commit().expect("test operation should succeed");
5209
5210        let mut tx = store.transaction();
5211        tx.update(RefUpdate {
5212            name: "refs/heads/topic".into(),
5213            expected: None,
5214            new: RefTarget::Direct(new_topic),
5215            reflog: None,
5216        });
5217        tx.delete_with_precondition(
5218            "refs/heads/main",
5219            RefDeletePrecondition::Direct(Some(old_main)),
5220            None,
5221        );
5222        tx.commit().expect("test operation should succeed");
5223
5224        assert_eq!(
5225            store
5226                .read_ref("refs/heads/main")
5227                .expect("test operation should succeed"),
5228            None
5229        );
5230        assert_eq!(
5231            store
5232                .read_ref("refs/heads/topic")
5233                .expect("test operation should succeed"),
5234            Some(RefTarget::Direct(new_topic))
5235        );
5236        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5237    }
5238
5239    #[test]
5240    fn file_ref_transaction_stale_delete_rolls_back_update() {
5241        let git_dir = temp_git_dir();
5242        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5243        let old_oid = ObjectId::from_hex(
5244            ObjectFormat::Sha1,
5245            "ce013625030ba8dba906f756967f9e9ca394464a",
5246        )
5247        .expect("test operation should succeed");
5248        let new_oid = ObjectId::from_hex(
5249            ObjectFormat::Sha1,
5250            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5251        )
5252        .expect("test operation should succeed");
5253        let mut seed = store.transaction();
5254        for name in ["refs/heads/main", "refs/heads/topic"] {
5255            seed.update(RefUpdate {
5256                name: name.into(),
5257                expected: None,
5258                new: RefTarget::Direct(old_oid),
5259                reflog: None,
5260            });
5261        }
5262        seed.commit().expect("test operation should succeed");
5263
5264        let mut tx = store.transaction();
5265        tx.update(RefUpdate {
5266            name: "refs/heads/topic".into(),
5267            expected: None,
5268            new: RefTarget::Direct(new_oid),
5269            reflog: None,
5270        });
5271        tx.delete_with_precondition(
5272            "refs/heads/main",
5273            RefDeletePrecondition::Direct(Some(new_oid)),
5274            None,
5275        );
5276        let err = tx.commit().expect_err("stale delete must abort");
5277        assert!(err.to_string().contains("expected ref refs/heads/main"));
5278
5279        assert_eq!(
5280            store
5281                .read_ref("refs/heads/main")
5282                .expect("test operation should succeed"),
5283            Some(RefTarget::Direct(old_oid))
5284        );
5285        assert_eq!(
5286            store
5287                .read_ref("refs/heads/topic")
5288                .expect("test operation should succeed"),
5289            Some(RefTarget::Direct(old_oid))
5290        );
5291        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5292    }
5293
5294    #[test]
5295    fn file_ref_transaction_rejects_duplicate_mixed_ref() {
5296        let git_dir = temp_git_dir();
5297        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5298        let oid = ObjectId::from_hex(
5299            ObjectFormat::Sha1,
5300            "ce013625030ba8dba906f756967f9e9ca394464a",
5301        )
5302        .expect("test operation should succeed");
5303        let mut tx = store.transaction();
5304        tx.update(RefUpdate {
5305            name: "refs/heads/main".into(),
5306            expected: None,
5307            new: RefTarget::Direct(oid),
5308            reflog: None,
5309        });
5310        tx.delete_with_precondition("refs/heads/main", RefDeletePrecondition::Any, None);
5311
5312        let err = tx.commit().expect_err("duplicate ref must fail");
5313        assert!(err.to_string().contains("refs/heads/main"));
5314        assert_eq!(
5315            store
5316                .read_ref("refs/heads/main")
5317                .expect("test operation should succeed"),
5318            None
5319        );
5320        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5321    }
5322
5323    #[test]
5324    fn file_ref_transaction_deletes_symbolic_ref_with_immediate_expectation() {
5325        let git_dir = temp_git_dir();
5326        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5327        let oid = ObjectId::from_hex(
5328            ObjectFormat::Sha1,
5329            "ce013625030ba8dba906f756967f9e9ca394464a",
5330        )
5331        .expect("test operation should succeed");
5332        let mut seed = store.transaction();
5333        seed.update(RefUpdate {
5334            name: "refs/heads/main".into(),
5335            expected: None,
5336            new: RefTarget::Direct(oid),
5337            reflog: None,
5338        });
5339        seed.update(RefUpdate {
5340            name: "refs/aliases/main".into(),
5341            expected: None,
5342            new: RefTarget::Symbolic("refs/heads/main".into()),
5343            reflog: None,
5344        });
5345        seed.commit().expect("test operation should succeed");
5346
5347        let mut tx = store.transaction();
5348        tx.delete_with_precondition(
5349            "refs/aliases/main",
5350            RefDeletePrecondition::Immediate(RefTarget::Symbolic("refs/heads/main".into())),
5351            None,
5352        );
5353        tx.commit().expect("test operation should succeed");
5354
5355        assert_eq!(
5356            store
5357                .read_ref("refs/aliases/main")
5358                .expect("test operation should succeed"),
5359            None
5360        );
5361        assert_eq!(
5362            store
5363                .read_ref("refs/heads/main")
5364                .expect("test operation should succeed"),
5365            Some(RefTarget::Direct(oid))
5366        );
5367        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5368    }
5369
5370    #[test]
5371    fn file_ref_transaction_rolls_back_delete_after_late_write_failure() {
5372        let git_dir = temp_git_dir();
5373        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5374        let old_oid = ObjectId::from_hex(
5375            ObjectFormat::Sha1,
5376            "ce013625030ba8dba906f756967f9e9ca394464a",
5377        )
5378        .expect("test operation should succeed");
5379        let new_oid = ObjectId::from_hex(
5380            ObjectFormat::Sha1,
5381            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5382        )
5383        .expect("test operation should succeed");
5384        let mut seed = store.transaction();
5385        for name in ["refs/heads/main", "refs/heads/topic"] {
5386            seed.update(RefUpdate {
5387                name: name.into(),
5388                expected: None,
5389                new: RefTarget::Direct(old_oid),
5390                reflog: None,
5391            });
5392        }
5393        seed.commit().expect("test operation should succeed");
5394
5395        set_fail_loose_commit_action_for_test(Some(1));
5396        let mut tx = store.transaction();
5397        tx.delete_with_precondition(
5398            "refs/heads/main",
5399            RefDeletePrecondition::Direct(Some(old_oid)),
5400            None,
5401        );
5402        tx.update(RefUpdate {
5403            name: "refs/heads/topic".into(),
5404            expected: None,
5405            new: RefTarget::Direct(new_oid),
5406            reflog: None,
5407        });
5408        let err = tx.commit().expect_err("injected failure must abort");
5409        assert!(
5410            err.to_string()
5411                .contains("injected loose ref transaction failure")
5412        );
5413
5414        assert_eq!(
5415            store
5416                .read_ref("refs/heads/main")
5417                .expect("test operation should succeed"),
5418            Some(RefTarget::Direct(old_oid))
5419        );
5420        assert_eq!(
5421            store
5422                .read_ref("refs/heads/topic")
5423                .expect("test operation should succeed"),
5424            Some(RefTarget::Direct(old_oid))
5425        );
5426        assert!(
5427            !git_dir
5428                .join("refs")
5429                .join("heads")
5430                .join("main.lock")
5431                .exists()
5432        );
5433        assert!(
5434            !git_dir
5435                .join("refs")
5436                .join("heads")
5437                .join("topic.lock")
5438                .exists()
5439        );
5440        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5441    }
5442
5443    fn temp_git_dir() -> PathBuf {
5444        let path = std::env::temp_dir().join(format!(
5445            "sley-refs-{}-{}",
5446            std::process::id(),
5447            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
5448        ));
5449        fs::create_dir_all(&path).expect("test operation should succeed");
5450        path
5451    }
5452
5453    fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
5454        Ok(ObjectId::null(format))
5455    }
5456
5457    fn write_reftable_config(git_dir: &Path) {
5458        fs::write(
5459            git_dir.join("config"),
5460            b"[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefStorage = reftable\n",
5461        )
5462        .expect("test operation should succeed");
5463    }
5464
5465    fn write_reftable_stack(
5466        git_dir: &Path,
5467        tables: &[(&str, Vec<sley_formats::ReftableRefRecord>)],
5468    ) {
5469        let reftable_dir = git_dir.join("reftable");
5470        fs::create_dir_all(&reftable_dir).expect("test operation should succeed");
5471        let mut list = String::new();
5472        for (idx, (name, refs)) in tables.iter().enumerate() {
5473            let update_index = (idx + 1) as u64;
5474            let bytes = sley_formats::Reftable::write_ref_only(
5475                ObjectFormat::Sha1,
5476                update_index,
5477                update_index,
5478                refs,
5479            )
5480            .expect("test operation should succeed");
5481            fs::write(reftable_dir.join(name), bytes).expect("test operation should succeed");
5482            list.push_str(name);
5483            list.push('\n');
5484        }
5485        fs::write(reftable_dir.join("tables.list"), list).expect("test operation should succeed");
5486    }
5487}