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