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    /// Mirror git's `files_log_ref_write`: when a transaction updates a branch
1469    /// that `HEAD` symbolically points at, the same reflog entry is also written
1470    /// to `logs/HEAD`. Without this, `git reflog` (which reads the HEAD reflog)
1471    /// misses commits/merges/resets done on the checked-out branch.
1472    ///
1473    /// `head_branch` is HEAD's symref target captured **before** the transaction
1474    /// mutated any refs — using the post-apply value would mis-mirror a
1475    /// transaction that re-points HEAD onto the branch it just updated (e.g.
1476    /// rebase's finish step, which manages `logs/HEAD` itself). When the
1477    /// transaction explicitly updates `HEAD` it owns the HEAD reflog and nothing
1478    /// is mirrored.
1479    fn head_reflog_mirror(
1480        head_branch: Option<&str>,
1481        reflogs: &[(String, ReflogEntry)],
1482    ) -> Vec<(String, ReflogEntry)> {
1483        let Some(head_branch) = head_branch else {
1484            return Vec::new();
1485        };
1486        // A transaction that touches HEAD directly is managing the HEAD reflog
1487        // on its own terms (detach, rebase finish, checkout); don't double-write.
1488        if reflogs.iter().any(|(name, _)| name == "HEAD") {
1489            return Vec::new();
1490        }
1491        reflogs
1492            .iter()
1493            .filter(|(name, _)| name == head_branch)
1494            .map(|(_, entry)| ("HEAD".to_string(), entry.clone()))
1495            .collect()
1496    }
1497
1498    /// HEAD's symref target (`refs/heads/<branch>`) if HEAD is symbolic, else
1499    /// `None`. Read once at the start of a transaction commit so the HEAD-reflog
1500    /// mirror reflects the pre-transaction state.
1501    fn head_symref_target(&self) -> Option<String> {
1502        match self.read_ref("HEAD") {
1503            Ok(Some(RefTarget::Symbolic(branch))) => Some(branch),
1504            _ => None,
1505        }
1506    }
1507
1508    pub fn append_reflog(&self, name: &str, entry: &ReflogEntry) -> Result<()> {
1509        validate_ref_name_for_read(name)?;
1510        let path = self.reflog_path(name);
1511        let parent = path
1512            .parent()
1513            .ok_or_else(|| GitError::InvalidPath("reflog path has no parent".into()))?;
1514        fs::create_dir_all(parent)?;
1515        let mut file = fs::OpenOptions::new()
1516            .create(true)
1517            .append(true)
1518            .open(path)?;
1519        file.write_all(&entry.to_line())?;
1520        file.sync_all()?;
1521        Ok(())
1522    }
1523
1524    fn ref_path(&self, name: &str) -> PathBuf {
1525        self.ref_base_dir(name).join(name)
1526    }
1527
1528    fn reflog_path(&self, name: &str) -> PathBuf {
1529        self.ref_base_dir(name).join("logs").join(name)
1530    }
1531
1532    fn ref_base_dir(&self, name: &str) -> &Path {
1533        if name == "HEAD" {
1534            &self.git_dir
1535        } else {
1536            &self.common_dir
1537        }
1538    }
1539
1540    fn check_ref_directory_conflict(&self, name: &str) -> Result<()> {
1541        let components = name.split('/').collect::<Vec<_>>();
1542        for index in 1..components.len() {
1543            let ancestor = components[..index].join("/");
1544            if self.read_ref_unchecked(&ancestor)?.is_some() {
1545                return Err(ref_directory_conflict_error(name, &ancestor));
1546            }
1547        }
1548        let child_prefix = format!("{name}/");
1549        for reference in self.list_refs()? {
1550            if reference.name.starts_with(&child_prefix) {
1551                return Err(ref_directory_conflict_error(name, &reference.name));
1552            }
1553        }
1554        Ok(())
1555    }
1556}
1557
1558fn reftable_ref_target(value: ReftableRefValue) -> Result<Option<RefTarget>> {
1559    match value {
1560        ReftableRefValue::Deletion => Ok(None),
1561        ReftableRefValue::Direct(oid) | ReftableRefValue::Peeled { target: oid, .. } => {
1562            Ok(Some(RefTarget::Direct(oid)))
1563        }
1564        ReftableRefValue::Symbolic(target) => Ok(Some(RefTarget::Symbolic(target))),
1565    }
1566}
1567
1568fn reftable_value_from_ref_target(target: &RefTarget) -> ReftableRefValue {
1569    match target {
1570        RefTarget::Direct(oid) => ReftableRefValue::Direct(*oid),
1571        RefTarget::Symbolic(target) => ReftableRefValue::Symbolic(target.clone()),
1572    }
1573}
1574
1575fn reftable_table_name(update_index: u64) -> String {
1576    let nanos = SystemTime::now()
1577        .duration_since(UNIX_EPOCH)
1578        .map(|duration| duration.as_nanos())
1579        .unwrap_or(0);
1580    format!("0x{update_index:012x}-0x{update_index:012x}-sley-{nanos:x}.ref")
1581}
1582
1583fn repository_common_dir(git_dir: &Path) -> PathBuf {
1584    if let Some(common_dir) = std::env::var_os("GIT_COMMON_DIR") {
1585        return PathBuf::from(common_dir);
1586    }
1587    let commondir = git_dir.join("commondir");
1588    if let Ok(value) = fs::read_to_string(&commondir) {
1589        let path = PathBuf::from(value.trim());
1590        let common = if path.is_absolute() {
1591            path
1592        } else {
1593            git_dir.join(path)
1594        };
1595        return fs::canonicalize(&common).unwrap_or(common);
1596    }
1597    git_dir.to_path_buf()
1598}
1599
1600pub struct FileRefTransaction<'a> {
1601    store: &'a FileRefStore,
1602    changes: Vec<QueuedRefChange>,
1603}
1604
1605/// One queued update inside a [`FileRefTransaction`], carrying the
1606/// compare-and-swap precondition to enforce under lock.
1607struct QueuedUpdate {
1608    name: String,
1609    precondition: RefPrecondition,
1610    new: RefTarget,
1611    reflog: Option<ReflogEntry>,
1612}
1613
1614struct QueuedDelete {
1615    name: String,
1616    precondition: RefDeletePrecondition,
1617}
1618
1619enum QueuedRefChange {
1620    Update(QueuedUpdate),
1621    Delete(QueuedDelete),
1622}
1623
1624/// The compare-and-delete precondition checked for a queued ref delete.
1625#[derive(Debug, Clone, PartialEq, Eq)]
1626pub enum RefDeletePrecondition {
1627    /// Any existing direct or symbolic ref may be deleted.
1628    Any,
1629    /// The ref's immediate target must match exactly.
1630    Immediate(RefTarget),
1631    /// The ref must be direct. When an object id is supplied, it must match.
1632    Direct(Option<ObjectId>),
1633    /// The ref may be symbolic, but its peeled direct target must match.
1634    Peeled(ObjectId),
1635}
1636
1637impl<'a> FileRefTransaction<'a> {
1638    /// Queue a ref update whose precondition comes from [`RefUpdate::expected`]
1639    /// (`None` = no check; `Some(target)` = the ref must currently match
1640    /// `target`). For create-only or match-or-create semantics use
1641    /// [`update_to`](FileRefTransaction::update_to).
1642    pub fn update(&mut self, update: RefUpdate) {
1643        self.changes.push(QueuedRefChange::Update(QueuedUpdate {
1644            name: update.name,
1645            precondition: RefPrecondition::from_expected(update.expected),
1646            new: update.new,
1647            reflog: update.reflog,
1648        }));
1649    }
1650
1651    /// Queue a ref update with an explicit compare-and-swap [`RefPrecondition`]
1652    /// (e.g. [`MustNotExist`](RefPrecondition::MustNotExist) for create-only, or
1653    /// [`ExistingMustMatch`](RefPrecondition::ExistingMustMatch) for
1654    /// match-or-create). The precondition is re-verified while the ref is
1655    /// locked.
1656    pub fn update_to(
1657        &mut self,
1658        name: impl Into<String>,
1659        new: RefTarget,
1660        precondition: RefPrecondition,
1661        reflog: Option<ReflogEntry>,
1662    ) {
1663        self.changes.push(QueuedRefChange::Update(QueuedUpdate {
1664            name: name.into(),
1665            precondition,
1666            new,
1667            reflog,
1668        }));
1669    }
1670
1671    /// Queue a direct ref delete using the historical checked-delete shape.
1672    ///
1673    /// `expected_old = None` means "delete any direct ref"; `Some(oid)` means
1674    /// the direct ref must currently point at that object id.
1675    pub fn delete(&mut self, delete: DeleteRef) {
1676        self.delete_with_precondition(
1677            delete.name,
1678            RefDeletePrecondition::Direct(delete.expected_old),
1679            delete.reflog,
1680        );
1681    }
1682
1683    /// Queue a ref delete with an explicit direct/symbolic precondition.
1684    ///
1685    /// `_reflog` is accepted for API compatibility but ignored: git unlinks the
1686    /// reflog on delete rather than writing a deletion entry, so a
1687    /// caller-supplied deletion message has no on-disk effect.
1688    pub fn delete_with_precondition(
1689        &mut self,
1690        name: impl Into<String>,
1691        precondition: RefDeletePrecondition,
1692        _reflog: Option<DeleteRefReflog>,
1693    ) {
1694        self.changes.push(QueuedRefChange::Delete(QueuedDelete {
1695            name: name.into(),
1696            precondition,
1697        }));
1698    }
1699
1700    /// Commit all queued updates and deletes atomically and durably.
1701    ///
1702    /// All ref changes succeed together or none take effect. For the loose-ref
1703    /// backend the sequence is:
1704    ///
1705    /// 1. Preserve the historical update-only coalescing behavior. Mixed
1706    ///    transactions reject duplicate ref names so a delete and write cannot
1707    ///    target the same ref ambiguously.
1708    /// 2. Take an exclusive `<ref>.lock` file for every ref up front, and lock
1709    ///    `packed-refs` before checked deletes can inspect or rewrite it.
1710    /// 3. Re-verify every precondition *while holding the locks*, closing
1711    ///    the check-then-write race that a pre-lock verification would leave open.
1712    /// 4. Stage every write, delete marker, and packed-refs rewrite.
1713    /// 5. Rename/remove staged paths, rolling back already-applied paths if a
1714    ///    later step fails.
1715    ///
1716    /// If any step fails, every path already changed in this commit is restored
1717    /// to the exact bytes it held beforehand (or removed if it did not exist),
1718    /// and all outstanding lock files are deleted. Reflog entries are appended
1719    /// only after every ref change has landed.
1720    pub fn commit(self) -> Result<()> {
1721        let FileRefTransaction { store, changes } = self;
1722        let changes = coalesce_ref_changes(changes)?;
1723        if store.uses_reftable()? {
1724            return store.commit_reftable(changes);
1725        }
1726        store.commit_loose(changes)
1727    }
1728}
1729
1730impl FileRefStore {
1731    fn commit_reftable(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
1732        // Capture HEAD's symref target before any ref is mutated so the
1733        // HEAD-reflog mirror reflects the pre-transaction checked-out branch.
1734        let head_branch = self.head_symref_target();
1735        let mut records = Vec::with_capacity(changes.len());
1736        let mut reflogs = Vec::new();
1737        let mut delete_names = Vec::new();
1738        for change in changes {
1739            match change {
1740                CoalescedRefChange::Update(update) => {
1741                    if !matches!(update.precondition, RefPrecondition::Any) {
1742                        let current = self.read_ref(&update.name)?;
1743                        if !update.precondition.is_satisfied_by(current.as_ref()) {
1744                            return Err(GitError::Transaction(
1745                                update.precondition.describe(&update.name),
1746                            ));
1747                        }
1748                    }
1749                    records.push(ReftableRefRecord {
1750                        name: update.name.clone(),
1751                        update_index: 0,
1752                        value: reftable_value_from_ref_target(&update.new),
1753                    });
1754                    for entry in update.reflog {
1755                        reflogs.push((update.name.clone(), entry));
1756                    }
1757                }
1758                CoalescedRefChange::Delete(delete) => {
1759                    let current = self.read_ref(&delete.name)?;
1760                    // Enforce the precondition; git unlinks logs/refs/<name> on
1761                    // delete rather than appending a deletion reflog entry, so the
1762                    // returned OID is unused.
1763                    verify_delete_precondition(
1764                        self,
1765                        &delete.name,
1766                        current.as_ref(),
1767                        &delete.precondition,
1768                    )?;
1769                    records.push(ReftableRefRecord {
1770                        name: delete.name.clone(),
1771                        update_index: 0,
1772                        value: ReftableRefValue::Deletion,
1773                    });
1774                    delete_names.push(delete.name.clone());
1775                }
1776            }
1777        }
1778        self.append_reftable_records(records)?;
1779        // Git unlinks logs/refs/<name> (pruning now-empty dirs) on delete; do
1780        // this before appending update reflogs so a delete+recreate does not race
1781        // the new ref's reflog file.
1782        for name in &delete_names {
1783            self.remove_reflog_file(name);
1784        }
1785        let head_mirror = Self::head_reflog_mirror(head_branch.as_deref(), &reflogs);
1786        reflogs.extend(head_mirror);
1787        for (name, entry) in reflogs {
1788            self.append_reflog(&name, &entry)?;
1789        }
1790        Ok(())
1791    }
1792
1793    /// Atomic, all-or-nothing commit for the loose-ref backend. See
1794    /// [`FileRefTransaction::commit`] for the full ordering and rollback rules.
1795    fn commit_loose(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
1796        // Capture HEAD's symref target before any ref is mutated so the
1797        // HEAD-reflog mirror reflects the pre-transaction checked-out branch.
1798        let head_branch = self.head_symref_target();
1799        let has_delete = changes
1800            .iter()
1801            .any(|change| matches!(change, CoalescedRefChange::Delete(_)));
1802        let mut pending = Vec::with_capacity(changes.len() + usize::from(has_delete));
1803        // Acquire every lock first; bail (releasing what we hold) on any failure.
1804        for change in &changes {
1805            let name = change.name();
1806            if matches!(change, CoalescedRefChange::Update(_))
1807                && let Err(err) = self.check_ref_directory_conflict(name)
1808            {
1809                release_pending_locks(&pending);
1810                return Err(err);
1811            }
1812            let path = self.ref_path(name);
1813            let parent = path
1814                .parent()
1815                .ok_or_else(|| GitError::InvalidPath("ref path has no parent".into()))?;
1816            if let Err(err) = fs::create_dir_all(parent) {
1817                release_pending_locks(&pending);
1818                if err.kind() == std::io::ErrorKind::NotADirectory {
1819                    return Err(ref_directory_conflict_error(
1820                        name,
1821                        &parent_to_ref_name(self.ref_base_dir(name), parent),
1822                    ));
1823                }
1824                return Err(GitError::Io(err.to_string()));
1825            }
1826            let lock_path = match lock_path_for(&path) {
1827                Ok(lock_path) => lock_path,
1828                Err(err) => {
1829                    release_pending_locks(&pending);
1830                    return Err(err);
1831                }
1832            };
1833            if let Err(err) = fs::OpenOptions::new()
1834                .write(true)
1835                .create_new(true)
1836                .open(&lock_path)
1837            {
1838                release_pending_locks(&pending);
1839                return Err(GitError::Io(format!("could not lock ref {name}: {err}")));
1840            }
1841            let action = match change {
1842                CoalescedRefChange::Update(update) => PendingPathAction::Write {
1843                    contents: write_loose_ref(&Ref {
1844                        name: update.name.clone(),
1845                        target: update.new.clone(),
1846                    }),
1847                },
1848                CoalescedRefChange::Delete(_) => PendingPathAction::Delete,
1849            };
1850            pending.push(PendingPathChange {
1851                name: name.to_string(),
1852                path,
1853                lock_path,
1854                original: None,
1855                action,
1856            });
1857        }
1858
1859        let packed_path = self.common_dir.join("packed-refs");
1860        let mut packed_refs = Vec::new();
1861        if has_delete {
1862            let packed_lock_path = match lock_path_for(&packed_path) {
1863                Ok(lock_path) => lock_path,
1864                Err(err) => {
1865                    release_pending_locks(&pending);
1866                    return Err(err);
1867                }
1868            };
1869            if let Err(err) = fs::OpenOptions::new()
1870                .write(true)
1871                .create_new(true)
1872                .open(&packed_lock_path)
1873            {
1874                release_pending_locks(&pending);
1875                return Err(GitError::Io(format!("could not lock packed-refs: {err}")));
1876            }
1877            let packed_original = match fs::read(&packed_path) {
1878                Ok(bytes) => Some(bytes),
1879                Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
1880                Err(err) => {
1881                    release_pending_locks(&pending);
1882                    let _ = fs::remove_file(&packed_lock_path);
1883                    return Err(GitError::Io(err.to_string()));
1884                }
1885            };
1886            packed_refs = match &packed_original {
1887                Some(bytes) => match parse_packed_refs(self.format, bytes) {
1888                    Ok(refs) => refs,
1889                    Err(err) => {
1890                        release_pending_locks(&pending);
1891                        let _ = fs::remove_file(&packed_lock_path);
1892                        return Err(err);
1893                    }
1894                },
1895                None => Vec::new(),
1896            };
1897            pending.push(PendingPathChange {
1898                name: "packed-refs".into(),
1899                path: packed_path.clone(),
1900                lock_path: packed_lock_path,
1901                original: packed_original,
1902                action: PendingPathAction::ReleaseLock,
1903            });
1904        }
1905
1906        // Verify expectations under lock, then capture prior on-disk state for
1907        // rollback. Mixed transactions read packed refs from the snapshot held
1908        // behind packed-refs.lock so deletes cannot race a packed rewrite.
1909        let mut reflogs = Vec::new();
1910        let mut delete_names = BTreeSet::new();
1911        for index in 0..changes.len() {
1912            match &changes[index] {
1913                CoalescedRefChange::Update(update) => {
1914                    if !matches!(update.precondition, RefPrecondition::Any) {
1915                        let current = if has_delete {
1916                            match self.read_ref_from_locked_packed(&update.name, &packed_refs) {
1917                                Ok(current) => current,
1918                                Err(err) => {
1919                                    release_pending_locks(&pending);
1920                                    return Err(err);
1921                                }
1922                            }
1923                        } else {
1924                            match self.read_ref(&update.name) {
1925                                Ok(current) => current,
1926                                Err(err) => {
1927                                    release_pending_locks(&pending);
1928                                    return Err(err);
1929                                }
1930                            }
1931                        };
1932                        if !update.precondition.is_satisfied_by(current.as_ref()) {
1933                            release_pending_locks(&pending);
1934                            return Err(GitError::Transaction(
1935                                update.precondition.describe(&update.name),
1936                            ));
1937                        }
1938                    }
1939                    pending[index].original = match read_optional_file(&pending[index].path) {
1940                        Ok(original) => original,
1941                        Err(err) => {
1942                            release_pending_locks(&pending);
1943                            return Err(err);
1944                        }
1945                    };
1946                    for entry in &update.reflog {
1947                        reflogs.push((update.name.clone(), entry.clone()));
1948                    }
1949                }
1950                CoalescedRefChange::Delete(delete) => {
1951                    let state = match self.read_locked_ref_state(&delete.name, &packed_refs) {
1952                        Ok(state) => state,
1953                        Err(err) => {
1954                            release_pending_locks(&pending);
1955                            return Err(err);
1956                        }
1957                    };
1958                    // Enforce the delete precondition under lock; the returned
1959                    // OID is unused because git unlinks logs/refs/<name> on
1960                    // delete rather than appending a deletion reflog entry.
1961                    if let Err(err) = verify_delete_precondition(
1962                        self,
1963                        &delete.name,
1964                        state.current.as_ref(),
1965                        &delete.precondition,
1966                    ) {
1967                        release_pending_locks(&pending);
1968                        return Err(err);
1969                    }
1970                    pending[index].original = if state.has_loose {
1971                        match read_optional_file(&pending[index].path) {
1972                            Ok(original) => original,
1973                            Err(err) => {
1974                                release_pending_locks(&pending);
1975                                return Err(err);
1976                            }
1977                        }
1978                    } else {
1979                        None
1980                    };
1981                    delete_names.insert(delete.name.clone());
1982                }
1983            }
1984        }
1985
1986        if has_delete {
1987            let old_len = packed_refs.len();
1988            packed_refs.retain(|reference| !delete_names.contains(&reference.reference.name));
1989            if packed_refs.len() != old_len {
1990                let packed_bytes = match write_packed_refs(&packed_refs) {
1991                    Ok(bytes) => bytes,
1992                    Err(err) => {
1993                        release_pending_locks(&pending);
1994                        return Err(err);
1995                    }
1996                };
1997                if let Some(packed) = pending.last_mut() {
1998                    packed.action = PendingPathAction::Write {
1999                        contents: packed_bytes,
2000                    };
2001                }
2002            }
2003        }
2004
2005        // Stage every new value or delete marker into its lock file. Nothing has
2006        // been renamed or removed yet, so on failure we only drop lock files.
2007        for change in &pending {
2008            if let Err(err) = stage_pending_change(change) {
2009                release_pending_locks(&pending);
2010                return Err(err);
2011            }
2012        }
2013
2014        // Apply each staged path change; on failure restore paths already
2015        // changed and drop the remaining lock files.
2016        for index in 0..pending.len() {
2017            if let Err(err) = maybe_fail_loose_commit_action(index) {
2018                rollback_after_apply(&pending, index);
2019                return Err(err);
2020            }
2021            if let Err(err) = apply_pending_change(&pending[index]) {
2022                rollback_after_apply(&pending, index + 1);
2023                return Err(err);
2024            }
2025        }
2026
2027        for change in &pending {
2028            if matches!(change.action, PendingPathAction::Delete) && change.original.is_some() {
2029                self.prune_empty_ref_dirs(&change.name);
2030            }
2031        }
2032        // Git unlinks logs/refs/<name> (and prunes now-empty log dirs) on delete;
2033        // do this before appending update reflogs so a delete+recreate in the
2034        // same direction does not race the new ref's reflog file.
2035        for name in &delete_names {
2036            self.remove_reflog_file(name);
2037        }
2038        // git's `files_log_ref_write` mirrors a checked-out branch's reflog
2039        // entry into logs/HEAD; `head_branch` was captured before any mutation.
2040        let head_mirror = Self::head_reflog_mirror(head_branch.as_deref(), &reflogs);
2041        reflogs.extend(head_mirror);
2042        // All refs are durable; append reflogs last, matching git's ordering.
2043        for (name, entry) in reflogs {
2044            self.append_reflog(&name, &entry)?;
2045        }
2046        Ok(())
2047    }
2048
2049    fn read_ref_from_locked_packed(
2050        &self,
2051        name: &str,
2052        packed_refs: &[PackedRef],
2053    ) -> Result<Option<RefTarget>> {
2054        let state = self.read_locked_ref_state(name, packed_refs)?;
2055        Ok(state.current)
2056    }
2057
2058    fn read_locked_ref_state(
2059        &self,
2060        name: &str,
2061        packed_refs: &[PackedRef],
2062    ) -> Result<LockedRefState> {
2063        let loose = self.read_loose_ref(name)?;
2064        let packed_index = packed_refs
2065            .iter()
2066            .position(|reference| reference.reference.name == name);
2067        let current = if let Some(reference) = loose.as_ref() {
2068            Some(reference.target.clone())
2069        } else {
2070            packed_index.map(|index| packed_refs[index].reference.target.clone())
2071        };
2072        Ok(LockedRefState {
2073            current,
2074            has_loose: loose.is_some(),
2075        })
2076    }
2077}
2078
2079struct LockedRefState {
2080    current: Option<RefTarget>,
2081    has_loose: bool,
2082}
2083
2084enum CoalescedRefChange {
2085    Update(CoalescedRefUpdate),
2086    Delete(CoalescedRefDelete),
2087}
2088
2089impl CoalescedRefChange {
2090    fn name(&self) -> &str {
2091        match self {
2092            Self::Update(update) => &update.name,
2093            Self::Delete(delete) => &delete.name,
2094        }
2095    }
2096}
2097
2098/// A ref update with all writes that targeted the same name folded together.
2099struct CoalescedRefUpdate {
2100    name: String,
2101    precondition: RefPrecondition,
2102    new: RefTarget,
2103    reflog: Vec<ReflogEntry>,
2104}
2105
2106struct CoalescedRefDelete {
2107    name: String,
2108    precondition: RefDeletePrecondition,
2109}
2110
2111fn coalesce_ref_changes(changes: Vec<QueuedRefChange>) -> Result<Vec<CoalescedRefChange>> {
2112    let has_delete = changes
2113        .iter()
2114        .any(|change| matches!(change, QueuedRefChange::Delete(_)));
2115    if !has_delete {
2116        let updates = changes
2117            .into_iter()
2118            .map(|change| match change {
2119                QueuedRefChange::Update(update) => update,
2120                QueuedRefChange::Delete(_) => unreachable!("has_delete was false"),
2121            })
2122            .collect::<Vec<_>>();
2123        return coalesce_ref_updates(updates).map(|updates| {
2124            updates
2125                .into_iter()
2126                .map(CoalescedRefChange::Update)
2127                .collect()
2128        });
2129    }
2130
2131    let mut seen = BTreeSet::new();
2132    let mut coalesced = Vec::with_capacity(changes.len());
2133    for change in changes {
2134        let name = match &change {
2135            QueuedRefChange::Update(update) => &update.name,
2136            QueuedRefChange::Delete(delete) => &delete.name,
2137        };
2138        validate_ref_name_for_update(name)?;
2139        if !seen.insert(name.clone()) {
2140            return Err(GitError::Transaction(format!(
2141                "ref {name} appears more than once in transaction"
2142            )));
2143        }
2144        coalesced.push(match change {
2145            QueuedRefChange::Update(update) => CoalescedRefChange::Update(CoalescedRefUpdate {
2146                name: update.name,
2147                precondition: update.precondition,
2148                new: update.new,
2149                reflog: update.reflog.into_iter().collect(),
2150            }),
2151            QueuedRefChange::Delete(delete) => CoalescedRefChange::Delete(CoalescedRefDelete {
2152                name: delete.name,
2153                precondition: delete.precondition,
2154            }),
2155        });
2156    }
2157    Ok(coalesced)
2158}
2159
2160/// Fold repeated updates to the same ref into one, preserving first-seen order.
2161/// The last queued value wins, reflog entries accumulate in order, and the
2162/// precondition is taken from the first update (the state the caller
2163/// asserted before any change in this transaction).
2164fn coalesce_ref_updates(updates: Vec<QueuedUpdate>) -> Result<Vec<CoalescedRefUpdate>> {
2165    let mut order: Vec<String> = Vec::new();
2166    let mut by_name: HashMap<String, CoalescedRefUpdate> = HashMap::new();
2167    for update in updates {
2168        validate_ref_name_for_update(&update.name)?;
2169        match by_name.get_mut(&update.name) {
2170            Some(existing) => {
2171                existing.new = update.new;
2172                if let Some(entry) = update.reflog {
2173                    existing.reflog.push(entry);
2174                }
2175            }
2176            None => {
2177                order.push(update.name.clone());
2178                by_name.insert(
2179                    update.name.clone(),
2180                    CoalescedRefUpdate {
2181                        name: update.name,
2182                        precondition: update.precondition,
2183                        new: update.new,
2184                        reflog: update.reflog.into_iter().collect(),
2185                    },
2186                );
2187            }
2188        }
2189    }
2190    let mut coalesced = Vec::with_capacity(order.len());
2191    for name in order {
2192        if let Some(update) = by_name.remove(&name) {
2193            coalesced.push(update);
2194        }
2195    }
2196    Ok(coalesced)
2197}
2198
2199/// A staged path change: the target path, its lock file, and original bytes for
2200/// rollback.
2201struct PendingPathChange {
2202    name: String,
2203    path: PathBuf,
2204    lock_path: PathBuf,
2205    original: Option<Vec<u8>>,
2206    action: PendingPathAction,
2207}
2208
2209enum PendingPathAction {
2210    Write { contents: Vec<u8> },
2211    Delete,
2212    ReleaseLock,
2213}
2214
2215struct RefDirPruneGuard<'a> {
2216    store: &'a FileRefStore,
2217    name: String,
2218}
2219
2220impl Drop for RefDirPruneGuard<'_> {
2221    fn drop(&mut self) {
2222        self.store.prune_empty_ref_dirs(&self.name);
2223    }
2224}
2225
2226struct DeleteLock {
2227    path: PathBuf,
2228    file: Option<fs::File>,
2229    active: bool,
2230}
2231
2232impl DeleteLock {
2233    fn acquire(path: PathBuf) -> std::result::Result<Self, RefDeleteError> {
2234        match fs::OpenOptions::new()
2235            .write(true)
2236            .create_new(true)
2237            .open(&path)
2238        {
2239            Ok(file) => Ok(Self {
2240                path,
2241                file: Some(file),
2242                active: true,
2243            }),
2244            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
2245                Err(RefDeleteError::Locked)
2246            }
2247            Err(err) => Err(RefDeleteError::Io(err)),
2248        }
2249    }
2250
2251    fn write_all(&mut self, bytes: &[u8]) -> std::result::Result<(), RefDeleteError> {
2252        let Some(file) = self.file.as_mut() else {
2253            return Err(RefDeleteError::Io(std::io::Error::other(
2254                "lock file is already closed",
2255            )));
2256        };
2257        file.set_len(0)?;
2258        file.write_all(bytes)?;
2259        file.sync_all()?;
2260        Ok(())
2261    }
2262
2263    fn close(mut self) -> PathBuf {
2264        self.active = false;
2265        let _ = self.file.take();
2266        self.path.clone()
2267    }
2268
2269    fn remove(mut self) {
2270        self.active = false;
2271        let _ = self.file.take();
2272        let _ = fs::remove_file(&self.path);
2273    }
2274}
2275
2276impl Drop for DeleteLock {
2277    fn drop(&mut self) {
2278        if self.active {
2279            let _ = self.file.take();
2280            let _ = fs::remove_file(&self.path);
2281        }
2282    }
2283}
2284
2285fn checked_delete_oid(
2286    expected: Option<ObjectId>,
2287    current: Option<RefTarget>,
2288) -> std::result::Result<ObjectId, RefDeleteError> {
2289    let Some(current) = current else {
2290        return Err(RefDeleteError::NotFound);
2291    };
2292    let RefTarget::Direct(actual) = current else {
2293        return Err(RefDeleteError::ExpectedMismatch {
2294            expected,
2295            actual: None,
2296        });
2297    };
2298    if let Some(expected_oid) = expected
2299        && expected_oid != actual
2300    {
2301        return Err(RefDeleteError::ExpectedMismatch {
2302            expected: Some(expected_oid),
2303            actual: Some(actual),
2304        });
2305    }
2306    Ok(actual)
2307}
2308
2309/// Verify a queued/checked delete may proceed, dying on a precondition
2310/// mismatch. Git unlinks the reflog on delete (it never writes a deletion
2311/// entry), so this validates only — the peeled OID is no longer plumbed out.
2312/// `peeled_oid_for_delete` is still invoked where the precondition requires the
2313/// peeled value, so a broken/unpeelable ref is still reported.
2314fn verify_delete_precondition(
2315    store: &FileRefStore,
2316    name: &str,
2317    current: Option<&RefTarget>,
2318    precondition: &RefDeletePrecondition,
2319) -> Result<()> {
2320    let Some(current) = current else {
2321        return Err(GitError::Transaction(format!("ref {name} not found")));
2322    };
2323    match precondition {
2324        RefDeletePrecondition::Any => {
2325            peeled_oid_for_delete(store, current)?;
2326            Ok(())
2327        }
2328        RefDeletePrecondition::Immediate(expected) if current == expected => {
2329            peeled_oid_for_delete(store, current)?;
2330            Ok(())
2331        }
2332        RefDeletePrecondition::Immediate(_) => Err(delete_precondition_mismatch(name)),
2333        RefDeletePrecondition::Direct(expected) => {
2334            let RefTarget::Direct(actual) = current else {
2335                return Err(delete_precondition_mismatch(name));
2336            };
2337            if let Some(expected) = expected
2338                && expected != actual
2339            {
2340                return Err(delete_precondition_mismatch(name));
2341            }
2342            Ok(())
2343        }
2344        RefDeletePrecondition::Peeled(expected) => {
2345            let actual = peeled_oid_for_delete(store, current)?;
2346            if actual == Some(*expected) {
2347                Ok(())
2348            } else {
2349                Err(delete_precondition_mismatch(name))
2350            }
2351        }
2352    }
2353}
2354
2355fn peeled_oid_for_delete(store: &FileRefStore, target: &RefTarget) -> Result<Option<ObjectId>> {
2356    match target {
2357        RefTarget::Direct(oid) => Ok(Some(*oid)),
2358        RefTarget::Symbolic(name) => resolve_ref_peeled(store, name),
2359    }
2360}
2361
2362fn delete_precondition_mismatch(name: &str) -> GitError {
2363    GitError::Transaction(format!("expected ref {name} to match"))
2364}
2365
2366fn ref_delete_error_from_git(err: GitError) -> RefDeleteError {
2367    match err {
2368        GitError::InvalidPath(_) => RefDeleteError::InvalidName,
2369        GitError::NotFound(_) => RefDeleteError::NotFound,
2370        GitError::Io(message) if message.contains("File exists") => RefDeleteError::Locked,
2371        GitError::Io(message) if message.contains("could not lock") => RefDeleteError::Locked,
2372        GitError::Transaction(message) if message.contains("could not lock") => {
2373            RefDeleteError::Locked
2374        }
2375        other => RefDeleteError::Io(std::io::Error::other(other.to_string())),
2376    }
2377}
2378
2379fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
2380    match fs::read(path) {
2381        Ok(bytes) => Ok(Some(bytes)),
2382        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
2383        Err(err) => Err(GitError::Io(err.to_string())),
2384    }
2385}
2386
2387fn stage_lock_file(lock_path: &Path, contents: &[u8]) -> Result<()> {
2388    let mut file = fs::OpenOptions::new()
2389        .write(true)
2390        .truncate(true)
2391        .open(lock_path)?;
2392    file.write_all(contents)?;
2393    file.sync_all()?;
2394    Ok(())
2395}
2396
2397fn stage_pending_change(change: &PendingPathChange) -> Result<()> {
2398    match &change.action {
2399        PendingPathAction::Write { contents } => stage_lock_file(&change.lock_path, contents),
2400        PendingPathAction::Delete => stage_lock_file(&change.lock_path, b"delete\n"),
2401        PendingPathAction::ReleaseLock => Ok(()),
2402    }
2403}
2404
2405fn apply_pending_change(change: &PendingPathChange) -> Result<()> {
2406    match &change.action {
2407        PendingPathAction::Write { .. } => {
2408            fs::rename(&change.lock_path, &change.path).map_err(|err| GitError::Io(err.to_string()))
2409        }
2410        PendingPathAction::Delete => {
2411            if change.original.is_some() {
2412                fs::remove_file(&change.path).map_err(|err| GitError::Io(err.to_string()))?;
2413            }
2414            fs::remove_file(&change.lock_path).map_err(|err| GitError::Io(err.to_string()))
2415        }
2416        PendingPathAction::ReleaseLock => {
2417            fs::remove_file(&change.lock_path).map_err(|err| GitError::Io(err.to_string()))
2418        }
2419    }
2420}
2421
2422/// Delete every still-held lock file. Used when a transaction aborts before any
2423/// path change, so nothing on disk has changed yet.
2424fn release_pending_locks(pending: &[PendingPathChange]) {
2425    for change in pending {
2426        let _ = fs::remove_file(&change.lock_path);
2427    }
2428}
2429
2430/// Roll back after `applied` path changes have already landed: restore each to
2431/// its captured bytes (or remove it if it did not previously exist), then drop
2432/// the lock files that have not yet been applied.
2433fn rollback_after_apply(pending: &[PendingPathChange], applied: usize) {
2434    for change in pending.iter().take(applied) {
2435        if matches!(change.action, PendingPathAction::ReleaseLock) {
2436            let _ = fs::remove_file(&change.lock_path);
2437            continue;
2438        }
2439        match &change.original {
2440            Some(bytes) => {
2441                let _ = restore_file_atomically(&change.path, bytes);
2442            }
2443            None => {
2444                let _ = fs::remove_file(&change.path);
2445            }
2446        }
2447        let _ = fs::remove_file(&change.lock_path);
2448    }
2449    for change in pending.iter().skip(applied) {
2450        let _ = fs::remove_file(&change.lock_path);
2451    }
2452}
2453
2454#[cfg(test)]
2455thread_local! {
2456    static FAIL_LOOSE_COMMIT_ACTION: std::cell::Cell<Option<usize>> =
2457        const { std::cell::Cell::new(None) };
2458}
2459
2460#[cfg(test)]
2461fn set_fail_loose_commit_action_for_test(index: Option<usize>) {
2462    FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.set(index));
2463}
2464
2465#[cfg(test)]
2466fn maybe_fail_loose_commit_action(index: usize) -> Result<()> {
2467    let should_fail = FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.get() == Some(index));
2468    if should_fail {
2469        FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.set(None));
2470        return Err(GitError::Io(format!(
2471            "injected loose ref transaction failure at action {index}"
2472        )));
2473    }
2474    Ok(())
2475}
2476
2477#[cfg(not(test))]
2478fn maybe_fail_loose_commit_action(_index: usize) -> Result<()> {
2479    Ok(())
2480}
2481
2482/// Best-effort atomic restore of `path` to `bytes` during rollback, reusing the
2483/// write-to-temp-then-rename dance so a crash mid-rollback cannot truncate a ref.
2484fn restore_file_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
2485    write_locked(path, bytes)
2486}
2487
2488#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2489pub struct FullRefName<'a> {
2490    name: &'a str,
2491}
2492
2493impl<'a> FullRefName<'a> {
2494    pub fn new(name: &'a str) -> Result<Self> {
2495        validate_ref_name(name)?;
2496        Ok(Self { name })
2497    }
2498
2499    pub fn as_str(&self) -> &str {
2500        self.name
2501    }
2502
2503    pub fn into_str(self) -> &'a str {
2504        self.name
2505    }
2506
2507    pub fn to_owned(&self) -> FullRefNameBuf {
2508        FullRefNameBuf {
2509            name: self.name.to_string(),
2510        }
2511    }
2512
2513    pub fn as_branch(&self) -> Result<BranchRefName<'a>> {
2514        BranchRefName::from_full_ref(*self)
2515    }
2516
2517    pub fn as_tag(&self) -> Result<TagRefName<'a>> {
2518        TagRefName::from_full_ref(*self)
2519    }
2520
2521    pub fn as_remote(&self) -> Result<RemoteRefName<'a>> {
2522        RemoteRefName::from_full_ref(*self)
2523    }
2524}
2525
2526impl AsRef<str> for FullRefName<'_> {
2527    fn as_ref(&self) -> &str {
2528        self.as_str()
2529    }
2530}
2531
2532impl fmt::Display for FullRefName<'_> {
2533    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2534        f.write_str(self.as_str())
2535    }
2536}
2537
2538#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2539pub struct FullRefNameBuf {
2540    name: String,
2541}
2542
2543impl FullRefNameBuf {
2544    pub fn new(name: impl Into<String>) -> Result<Self> {
2545        let name = name.into();
2546        validate_ref_name(&name)?;
2547        Ok(Self { name })
2548    }
2549
2550    pub fn as_ref_name(&self) -> FullRefName<'_> {
2551        FullRefName { name: &self.name }
2552    }
2553
2554    pub fn as_str(&self) -> &str {
2555        &self.name
2556    }
2557
2558    pub fn into_string(self) -> String {
2559        self.name
2560    }
2561
2562    pub fn as_branch(&self) -> Result<BranchRefName<'_>> {
2563        self.as_ref_name().as_branch()
2564    }
2565
2566    pub fn as_tag(&self) -> Result<TagRefName<'_>> {
2567        self.as_ref_name().as_tag()
2568    }
2569
2570    pub fn as_remote(&self) -> Result<RemoteRefName<'_>> {
2571        self.as_ref_name().as_remote()
2572    }
2573}
2574
2575impl AsRef<str> for FullRefNameBuf {
2576    fn as_ref(&self) -> &str {
2577        self.as_str()
2578    }
2579}
2580
2581impl Borrow<str> for FullRefNameBuf {
2582    fn borrow(&self) -> &str {
2583        self.as_str()
2584    }
2585}
2586
2587impl Deref for FullRefNameBuf {
2588    type Target = str;
2589
2590    fn deref(&self) -> &Self::Target {
2591        self.as_str()
2592    }
2593}
2594
2595impl fmt::Display for FullRefNameBuf {
2596    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2597        f.write_str(self.as_str())
2598    }
2599}
2600
2601#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2602pub struct BranchRefName<'a> {
2603    name: &'a str,
2604}
2605
2606impl<'a> BranchRefName<'a> {
2607    pub const PREFIX: &'static str = "refs/heads/";
2608
2609    pub fn from_full(name: &'a str) -> Result<Self> {
2610        let full = FullRefName::new(name)?;
2611        Self::from_full_ref(full)
2612    }
2613
2614    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
2615        validate_namespaced_ref(name.as_str(), Self::PREFIX, "branch")?;
2616        Ok(Self {
2617            name: name.into_str(),
2618        })
2619    }
2620
2621    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
2622        FullRefName { name: self.name }
2623    }
2624
2625    pub fn as_str(&self) -> &str {
2626        self.name
2627    }
2628
2629    pub fn branch_name(&self) -> &str {
2630        self.short_name()
2631    }
2632
2633    pub fn short_name(&self) -> &str {
2634        &self.name[Self::PREFIX.len()..]
2635    }
2636
2637    pub fn into_str(self) -> &'a str {
2638        self.name
2639    }
2640
2641    pub fn to_owned(&self) -> BranchRefNameBuf {
2642        BranchRefNameBuf {
2643            name: self.name.to_string(),
2644        }
2645    }
2646}
2647
2648impl AsRef<str> for BranchRefName<'_> {
2649    fn as_ref(&self) -> &str {
2650        self.as_str()
2651    }
2652}
2653
2654impl fmt::Display for BranchRefName<'_> {
2655    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2656        f.write_str(self.as_str())
2657    }
2658}
2659
2660impl<'a> From<BranchRefName<'a>> for FullRefName<'a> {
2661    fn from(name: BranchRefName<'a>) -> Self {
2662        name.as_full_ref_name()
2663    }
2664}
2665
2666#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2667pub struct BranchRefNameBuf {
2668    name: String,
2669}
2670
2671impl BranchRefNameBuf {
2672    pub fn from_branch_name(branch: &str) -> Result<Self> {
2673        validate_short_ref_name("branch", branch)?;
2674        let name = format!("{}{}", BranchRefName::PREFIX, branch);
2675        Self::from_full(name)
2676    }
2677
2678    pub fn from_full(name: impl Into<String>) -> Result<Self> {
2679        let name = name.into();
2680        BranchRefName::from_full(&name)?;
2681        Ok(Self { name })
2682    }
2683
2684    pub fn as_ref_name(&self) -> BranchRefName<'_> {
2685        BranchRefName { name: &self.name }
2686    }
2687
2688    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
2689        FullRefName { name: &self.name }
2690    }
2691
2692    pub fn as_str(&self) -> &str {
2693        &self.name
2694    }
2695
2696    pub fn branch_name(&self) -> &str {
2697        self.short_name()
2698    }
2699
2700    pub fn short_name(&self) -> &str {
2701        &self.name[BranchRefName::PREFIX.len()..]
2702    }
2703
2704    pub fn into_string(self) -> String {
2705        self.name
2706    }
2707}
2708
2709impl AsRef<str> for BranchRefNameBuf {
2710    fn as_ref(&self) -> &str {
2711        self.as_str()
2712    }
2713}
2714
2715impl Borrow<str> for BranchRefNameBuf {
2716    fn borrow(&self) -> &str {
2717        self.as_str()
2718    }
2719}
2720
2721impl Deref for BranchRefNameBuf {
2722    type Target = str;
2723
2724    fn deref(&self) -> &Self::Target {
2725        self.as_str()
2726    }
2727}
2728
2729impl fmt::Display for BranchRefNameBuf {
2730    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2731        f.write_str(self.as_str())
2732    }
2733}
2734
2735impl From<BranchRefNameBuf> for FullRefNameBuf {
2736    fn from(name: BranchRefNameBuf) -> Self {
2737        Self { name: name.name }
2738    }
2739}
2740
2741#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2742pub struct TagRefName<'a> {
2743    name: &'a str,
2744}
2745
2746impl<'a> TagRefName<'a> {
2747    pub const PREFIX: &'static str = "refs/tags/";
2748
2749    pub fn from_full(name: &'a str) -> Result<Self> {
2750        let full = FullRefName::new(name)?;
2751        Self::from_full_ref(full)
2752    }
2753
2754    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
2755        validate_namespaced_ref(name.as_str(), Self::PREFIX, "tag")?;
2756        Ok(Self {
2757            name: name.into_str(),
2758        })
2759    }
2760
2761    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
2762        FullRefName { name: self.name }
2763    }
2764
2765    pub fn as_str(&self) -> &str {
2766        self.name
2767    }
2768
2769    pub fn tag_name(&self) -> &str {
2770        self.short_name()
2771    }
2772
2773    pub fn short_name(&self) -> &str {
2774        &self.name[Self::PREFIX.len()..]
2775    }
2776
2777    pub fn into_str(self) -> &'a str {
2778        self.name
2779    }
2780
2781    pub fn to_owned(&self) -> TagRefNameBuf {
2782        TagRefNameBuf {
2783            name: self.name.to_string(),
2784        }
2785    }
2786}
2787
2788impl AsRef<str> for TagRefName<'_> {
2789    fn as_ref(&self) -> &str {
2790        self.as_str()
2791    }
2792}
2793
2794impl fmt::Display for TagRefName<'_> {
2795    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2796        f.write_str(self.as_str())
2797    }
2798}
2799
2800impl<'a> From<TagRefName<'a>> for FullRefName<'a> {
2801    fn from(name: TagRefName<'a>) -> Self {
2802        name.as_full_ref_name()
2803    }
2804}
2805
2806#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2807pub struct TagRefNameBuf {
2808    name: String,
2809}
2810
2811impl TagRefNameBuf {
2812    pub fn from_tag_name(tag: &str) -> Result<Self> {
2813        // Mirror git's check_tag_ref(): reject a leading '-' or the literal
2814        // "HEAD", then validate refs/tags/<tag> with check_refname_format().
2815        if tag.starts_with('-') || tag == "HEAD" {
2816            return Err(GitError::InvalidPath(format!("invalid tag name {tag}")));
2817        }
2818        Self::from_tag_name_unrestricted(tag)
2819    }
2820
2821    /// Build `refs/tags/<tag>` validating only the refname format, without the
2822    /// creation-only restrictions (leading `-`, literal `HEAD`). Git's delete
2823    /// path does not run check_tag_ref(), so a tag literally named `HEAD` can
2824    /// still be removed.
2825    pub fn from_tag_name_unrestricted(tag: &str) -> Result<Self> {
2826        let name = format!("{}{}", TagRefName::PREFIX, tag);
2827        check_refname_format(&name, false)?;
2828        Ok(Self { name })
2829    }
2830
2831    pub fn from_full(name: impl Into<String>) -> Result<Self> {
2832        let name = name.into();
2833        TagRefName::from_full(&name)?;
2834        Ok(Self { name })
2835    }
2836
2837    pub fn as_ref_name(&self) -> TagRefName<'_> {
2838        TagRefName { name: &self.name }
2839    }
2840
2841    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
2842        FullRefName { name: &self.name }
2843    }
2844
2845    pub fn as_str(&self) -> &str {
2846        &self.name
2847    }
2848
2849    pub fn tag_name(&self) -> &str {
2850        self.short_name()
2851    }
2852
2853    pub fn short_name(&self) -> &str {
2854        &self.name[TagRefName::PREFIX.len()..]
2855    }
2856
2857    pub fn into_string(self) -> String {
2858        self.name
2859    }
2860}
2861
2862impl AsRef<str> for TagRefNameBuf {
2863    fn as_ref(&self) -> &str {
2864        self.as_str()
2865    }
2866}
2867
2868impl Borrow<str> for TagRefNameBuf {
2869    fn borrow(&self) -> &str {
2870        self.as_str()
2871    }
2872}
2873
2874impl Deref for TagRefNameBuf {
2875    type Target = str;
2876
2877    fn deref(&self) -> &Self::Target {
2878        self.as_str()
2879    }
2880}
2881
2882impl fmt::Display for TagRefNameBuf {
2883    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2884        f.write_str(self.as_str())
2885    }
2886}
2887
2888impl From<TagRefNameBuf> for FullRefNameBuf {
2889    fn from(name: TagRefNameBuf) -> Self {
2890        Self { name: name.name }
2891    }
2892}
2893
2894#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2895pub struct RemoteRefName<'a> {
2896    name: &'a str,
2897}
2898
2899impl<'a> RemoteRefName<'a> {
2900    pub const PREFIX: &'static str = "refs/remotes/";
2901
2902    pub fn from_full(name: &'a str) -> Result<Self> {
2903        let full = FullRefName::new(name)?;
2904        Self::from_full_ref(full)
2905    }
2906
2907    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
2908        validate_namespaced_ref(name.as_str(), Self::PREFIX, "remote")?;
2909        Ok(Self {
2910            name: name.into_str(),
2911        })
2912    }
2913
2914    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
2915        FullRefName { name: self.name }
2916    }
2917
2918    pub fn as_str(&self) -> &str {
2919        self.name
2920    }
2921
2922    pub fn short_name(&self) -> &str {
2923        &self.name[Self::PREFIX.len()..]
2924    }
2925
2926    pub fn remote_name(&self) -> &str {
2927        match self.short_name().split_once('/') {
2928            Some((remote, _branch)) => remote,
2929            None => self.short_name(),
2930        }
2931    }
2932
2933    pub fn remote_branch(&self) -> Option<&str> {
2934        self.short_name()
2935            .split_once('/')
2936            .map(|(_remote, branch)| branch)
2937    }
2938
2939    pub fn into_str(self) -> &'a str {
2940        self.name
2941    }
2942
2943    pub fn to_owned(&self) -> RemoteRefNameBuf {
2944        RemoteRefNameBuf {
2945            name: self.name.to_string(),
2946        }
2947    }
2948}
2949
2950impl AsRef<str> for RemoteRefName<'_> {
2951    fn as_ref(&self) -> &str {
2952        self.as_str()
2953    }
2954}
2955
2956impl fmt::Display for RemoteRefName<'_> {
2957    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2958        f.write_str(self.as_str())
2959    }
2960}
2961
2962impl<'a> From<RemoteRefName<'a>> for FullRefName<'a> {
2963    fn from(name: RemoteRefName<'a>) -> Self {
2964        name.as_full_ref_name()
2965    }
2966}
2967
2968#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2969pub struct RemoteRefNameBuf {
2970    name: String,
2971}
2972
2973impl RemoteRefNameBuf {
2974    pub fn from_short_name(name: &str) -> Result<Self> {
2975        validate_short_ref_name("remote ref", name)?;
2976        let name = format!("{}{}", RemoteRefName::PREFIX, name);
2977        Self::from_full(name)
2978    }
2979
2980    pub fn from_remote_branch(remote: &str, branch: &str) -> Result<Self> {
2981        validate_remote_name(remote)?;
2982        validate_short_ref_name("remote branch", branch)?;
2983        let name = format!("{}{}/{}", RemoteRefName::PREFIX, remote, branch);
2984        Self::from_full(name)
2985    }
2986
2987    pub fn from_full(name: impl Into<String>) -> Result<Self> {
2988        let name = name.into();
2989        RemoteRefName::from_full(&name)?;
2990        Ok(Self { name })
2991    }
2992
2993    pub fn as_ref_name(&self) -> RemoteRefName<'_> {
2994        RemoteRefName { name: &self.name }
2995    }
2996
2997    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
2998        FullRefName { name: &self.name }
2999    }
3000
3001    pub fn as_str(&self) -> &str {
3002        &self.name
3003    }
3004
3005    pub fn short_name(&self) -> &str {
3006        &self.name[RemoteRefName::PREFIX.len()..]
3007    }
3008
3009    pub fn remote_name(&self) -> &str {
3010        match self.short_name().split_once('/') {
3011            Some((remote, _branch)) => remote,
3012            None => self.short_name(),
3013        }
3014    }
3015
3016    pub fn remote_branch(&self) -> Option<&str> {
3017        self.short_name()
3018            .split_once('/')
3019            .map(|(_remote, branch)| branch)
3020    }
3021
3022    pub fn into_string(self) -> String {
3023        self.name
3024    }
3025}
3026
3027impl AsRef<str> for RemoteRefNameBuf {
3028    fn as_ref(&self) -> &str {
3029        self.as_str()
3030    }
3031}
3032
3033impl Borrow<str> for RemoteRefNameBuf {
3034    fn borrow(&self) -> &str {
3035        self.as_str()
3036    }
3037}
3038
3039impl Deref for RemoteRefNameBuf {
3040    type Target = str;
3041
3042    fn deref(&self) -> &Self::Target {
3043        self.as_str()
3044    }
3045}
3046
3047impl fmt::Display for RemoteRefNameBuf {
3048    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3049        f.write_str(self.as_str())
3050    }
3051}
3052
3053impl From<RemoteRefNameBuf> for FullRefNameBuf {
3054    fn from(name: RemoteRefNameBuf) -> Self {
3055        Self { name: name.name }
3056    }
3057}
3058
3059pub fn branch_ref_name(branch: &str) -> Result<String> {
3060    BranchRefNameBuf::from_branch_name(branch).map(BranchRefNameBuf::into_string)
3061}
3062
3063pub fn tag_ref_name(tag: &str) -> Result<String> {
3064    TagRefNameBuf::from_tag_name(tag).map(TagRefNameBuf::into_string)
3065}
3066
3067fn write_locked(path: &Path, bytes: &[u8]) -> Result<()> {
3068    let lock_path = lock_path_for(path)?;
3069    {
3070        let mut file = fs::OpenOptions::new()
3071            .write(true)
3072            .create_new(true)
3073            .open(&lock_path)?;
3074        file.write_all(bytes)?;
3075        file.sync_all()?;
3076    }
3077    match fs::rename(&lock_path, path) {
3078        Ok(()) => Ok(()),
3079        Err(err) => {
3080            let _ = fs::remove_file(lock_path);
3081            Err(GitError::Io(err.to_string()))
3082        }
3083    }
3084}
3085
3086fn lock_path_for(path: &Path) -> Result<PathBuf> {
3087    let file_name = path
3088        .file_name()
3089        .ok_or_else(|| GitError::InvalidPath("ref path has no filename".into()))?;
3090    let mut lock_name = file_name.to_os_string();
3091    lock_name.push(".lock");
3092    Ok(path.with_file_name(lock_name))
3093}
3094
3095/// Validate a ref name using git's `check_refname_format` rules.
3096pub fn check_refname_format(name: &str, allow_onelevel: bool) -> Result<()> {
3097    if name.is_empty()
3098        || name == "@"
3099        || name.starts_with('/')
3100        || name.ends_with('/')
3101        || name.ends_with('.')
3102        || name.contains("..")
3103        || name.contains("//")
3104        || name.contains("@{")
3105        || (!allow_onelevel && !name.contains('/'))
3106    {
3107        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3108    }
3109    for component in name.split('/') {
3110        if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
3111            return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3112        }
3113        for (idx, byte) in component.bytes().enumerate() {
3114            if byte <= b' '
3115                || byte == 0x7f
3116                || matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
3117            {
3118                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3119            }
3120            if byte == b'.' && component.as_bytes().get(idx + 1) == Some(&b'.') {
3121                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3122            }
3123            if byte == b'@' && component.as_bytes().get(idx + 1) == Some(&b'{') {
3124                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3125            }
3126        }
3127    }
3128    Ok(())
3129}
3130
3131/// Validate a symbolic ref name (HEAD, one-level pseudo-refs, or `refs/...`).
3132pub fn validate_symref_name(name: &str) -> Result<()> {
3133    if name == "HEAD" {
3134        return Ok(());
3135    }
3136    check_refname_format(name, true)
3137}
3138
3139/// Validate a symbolic ref target (one-level pseudo-refs or `refs/...`).
3140pub fn validate_symref_target(name: &str) -> Result<()> {
3141    check_refname_format(name, true)
3142}
3143
3144/// Follow symbolic ref chains until a direct OID is reached.
3145/// Remove empty directories starting at `start` and walking up toward
3146/// `boundary`, stopping at the first non-empty directory or when `boundary` is
3147/// reached (exclusive). `boundary` itself is never removed.
3148fn prune_empty_dirs_up_to(start: &Path, boundary: &Path) {
3149    let mut dir = start.to_path_buf();
3150    while dir.starts_with(boundary) && dir != *boundary {
3151        if fs::remove_dir(&dir).is_err() {
3152            break;
3153        }
3154        dir = match dir.parent() {
3155            Some(parent) => parent.to_path_buf(),
3156            None => break,
3157        };
3158    }
3159}
3160
3161pub fn resolve_ref_peeled(store: &FileRefStore, name: &str) -> Result<Option<ObjectId>> {
3162    let mut current = name.to_string();
3163    for _ in 0..16 {
3164        match store.read_ref(&current)? {
3165            Some(RefTarget::Direct(oid)) => return Ok(Some(oid)),
3166            Some(RefTarget::Symbolic(next)) => current = next,
3167            None => return Ok(None),
3168        }
3169    }
3170    Ok(None)
3171}
3172
3173fn validate_ref_name_for_read(name: &str) -> Result<()> {
3174    if validate_ref_name(name).is_ok() {
3175        return Ok(());
3176    }
3177    if is_root_ref_syntax(name) {
3178        return Ok(());
3179    }
3180    validate_symref_name(name)
3181}
3182
3183fn validate_ref_name_for_update(name: &str) -> Result<()> {
3184    if validate_ref_name(name).is_ok() {
3185        return Ok(());
3186    }
3187    if is_root_ref_syntax(name) {
3188        return Ok(());
3189    }
3190    validate_symref_name(name)
3191}
3192
3193/// git's is_root_ref_syntax (refs.c): a ref name made only of uppercase ASCII,
3194/// `-`, and `_` (e.g. HEAD, FETCH_HEAD, MERGE_HEAD). Such names live in the
3195/// per-worktree gitdir rather than the common refs/ tree. An empty name is not
3196/// root-ref syntax.
3197fn is_root_ref_syntax(name: &str) -> bool {
3198    !name.is_empty()
3199        && name
3200            .bytes()
3201            .all(|b| b.is_ascii_uppercase() || b == b'-' || b == b'_')
3202}
3203
3204pub fn validate_ref_name(name: &str) -> Result<()> {
3205    if name == "HEAD" {
3206        return Ok(());
3207    }
3208    let path = Path::new(name);
3209    if !name.starts_with("refs/")
3210        || name.contains("..")
3211        || name.contains('\\')
3212        || name.ends_with('/')
3213        || name.ends_with(".lock")
3214        || path.is_absolute()
3215        || path.components().any(|component| {
3216            matches!(
3217                component,
3218                std::path::Component::ParentDir | std::path::Component::Prefix(_)
3219            )
3220        })
3221    {
3222        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3223    }
3224    Ok(())
3225}
3226
3227fn ref_directory_conflict_error(new_ref: &str, existing_ref: &str) -> GitError {
3228    GitError::Transaction(format!(
3229        "cannot lock ref '{new_ref}': '{existing_ref}' exists; cannot create '{new_ref}'"
3230    ))
3231}
3232
3233fn parent_to_ref_name(base: &Path, parent: &Path) -> String {
3234    match parent.strip_prefix(base) {
3235        Ok(suffix) => suffix.to_string_lossy().replace('\\', "/"),
3236        Err(_) => parent.to_string_lossy().into_owned(),
3237    }
3238}
3239
3240fn validate_namespaced_ref(name: &str, prefix: &str, kind: &str) -> Result<()> {
3241    validate_ref_name(name)?;
3242    if name
3243        .strip_prefix(prefix)
3244        .is_none_or(|short_name| short_name.is_empty())
3245    {
3246        return Err(GitError::InvalidPath(format!(
3247            "invalid {kind} ref name {name}"
3248        )));
3249    }
3250    Ok(())
3251}
3252
3253fn validate_short_ref_name(kind: &str, name: &str) -> Result<()> {
3254    if name.is_empty()
3255        || name.starts_with('-')
3256        || name.starts_with('/')
3257        || name.ends_with('/')
3258        || name.contains(' ')
3259        || name.contains('\\')
3260    {
3261        return Err(GitError::InvalidPath(format!("invalid {kind} name {name}")));
3262    }
3263    Ok(())
3264}
3265
3266fn validate_remote_name(remote: &str) -> Result<()> {
3267    validate_short_ref_name("remote", remote)?;
3268    if remote.contains('/') {
3269        return Err(GitError::InvalidPath(format!(
3270            "invalid remote name {remote}"
3271        )));
3272    }
3273    Ok(())
3274}
3275
3276fn prepare_bundle_ref_updates<F>(
3277    refs: &[BundleRefUpdate],
3278    reflog: Option<&BundleRefUpdateReflog>,
3279    mut read_ref: F,
3280) -> Result<(Vec<RefUpdate>, Vec<AppliedBundleRefUpdate>)>
3281where
3282    F: FnMut(&str, &ObjectId) -> Result<Option<RefTarget>>,
3283{
3284    let mut seen = BTreeSet::new();
3285    let mut updates = Vec::with_capacity(refs.len());
3286    let mut applied = Vec::with_capacity(refs.len());
3287    for bundle_ref in refs {
3288        validate_ref_name(&bundle_ref.name)?;
3289        if !seen.insert(bundle_ref.name.clone()) {
3290            return Err(GitError::Transaction(format!(
3291                "duplicate bundle ref {}",
3292                bundle_ref.name
3293            )));
3294        }
3295        let old_oid = match read_ref(&bundle_ref.name, &bundle_ref.oid)? {
3296            Some(RefTarget::Direct(oid)) => Some(oid),
3297            Some(RefTarget::Symbolic(target)) => {
3298                return Err(GitError::Transaction(format!(
3299                    "bundle ref {} would overwrite symbolic ref {target}",
3300                    bundle_ref.name
3301                )));
3302            }
3303            None => None,
3304        };
3305        let reflog = match reflog {
3306            Some(reflog) => Some(ReflogEntry {
3307                old_oid: match &old_oid {
3308                    Some(oid) => *oid,
3309                    None => null_oid(bundle_ref.oid.format())?,
3310                },
3311                new_oid: bundle_ref.oid,
3312                committer: reflog.committer.clone(),
3313                message: reflog.message.clone(),
3314            }),
3315            None => None,
3316        };
3317        updates.push(RefUpdate {
3318            name: bundle_ref.name.clone(),
3319            expected: old_oid.map(RefTarget::Direct),
3320            new: RefTarget::Direct(bundle_ref.oid),
3321            reflog,
3322        });
3323        applied.push(AppliedBundleRefUpdate {
3324            name: bundle_ref.name.clone(),
3325            old_oid,
3326            new_oid: bundle_ref.oid,
3327        });
3328    }
3329    Ok((updates, applied))
3330}
3331
3332fn null_oid(format: ObjectFormat) -> Result<ObjectId> {
3333    Ok(ObjectId::null(format))
3334}
3335
3336#[cfg(test)]
3337mod tests {
3338    use super::*;
3339    use std::sync::atomic::{AtomicU64, Ordering};
3340
3341    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
3342
3343    #[test]
3344    fn loose_ref_round_trips_direct() {
3345        let oid = "ce013625030ba8dba906f756967f9e9ca394464a";
3346        let reference = parse_loose_ref(ObjectFormat::Sha1, "refs/heads/main", oid.as_bytes())
3347            .expect("test operation should succeed");
3348        assert_eq!(write_loose_ref(&reference), format!("{oid}\n").into_bytes());
3349    }
3350
3351    #[test]
3352    fn symref_names_allow_onelevel_pseudo_refs() {
3353        for name in ["NOTHEAD", "FOO", "ORIG_HEAD", "TEST_SYMREF"] {
3354            validate_symref_name(name).expect("symref name should be valid");
3355        }
3356        assert!(validate_ref_name("NOTHEAD").is_err());
3357        assert!(validate_symref_target("refs/heads/foo").is_ok());
3358        assert!(validate_symref_target("ORIG_HEAD").is_ok());
3359        assert!(validate_symref_target("foo..bar").is_err());
3360    }
3361
3362    #[test]
3363    fn resolve_ref_peeled_follows_symref_chains() {
3364        let git_dir = temp_git_dir();
3365        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3366        let oid = ObjectId::from_hex(
3367            ObjectFormat::Sha1,
3368            "ce013625030ba8dba906f756967f9e9ca394464a",
3369        )
3370        .expect("test operation should succeed");
3371        let mut tx = store.transaction();
3372        tx.update(RefUpdate {
3373            name: "refs/heads/target".into(),
3374            expected: None,
3375            new: RefTarget::Direct(oid),
3376            reflog: None,
3377        });
3378        tx.commit().expect("seed target ref");
3379        let mut tx = store.transaction();
3380        tx.update(RefUpdate {
3381            name: "refs/heads/alias".into(),
3382            expected: None,
3383            new: RefTarget::Symbolic("refs/heads/target".into()),
3384            reflog: None,
3385        });
3386        tx.commit().expect("seed alias ref");
3387        let mut tx = store.transaction();
3388        tx.update(RefUpdate {
3389            name: "ORIG_HEAD".into(),
3390            expected: None,
3391            new: RefTarget::Symbolic("refs/heads/alias".into()),
3392            reflog: None,
3393        });
3394        tx.commit().expect("seed ORIG_HEAD symref");
3395        assert_eq!(
3396            resolve_ref_peeled(&store, "ORIG_HEAD").expect("resolve ORIG_HEAD"),
3397            Some(oid)
3398        );
3399        let _ = fs::remove_dir_all(git_dir);
3400    }
3401
3402    #[test]
3403    fn symref_directory_conflict_is_reported_gracefully() {
3404        let git_dir = temp_git_dir();
3405        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3406        let oid = ObjectId::from_hex(
3407            ObjectFormat::Sha1,
3408            "ce013625030ba8dba906f756967f9e9ca394464a",
3409        )
3410        .expect("test operation should succeed");
3411        let mut tx = store.transaction();
3412        tx.update(RefUpdate {
3413            name: "refs/heads/df".into(),
3414            expected: None,
3415            new: RefTarget::Direct(oid),
3416            reflog: None,
3417        });
3418        tx.commit().expect("seed branch ref");
3419
3420        let mut tx = store.transaction();
3421        tx.update(RefUpdate {
3422            name: "refs/heads/df/conflict".into(),
3423            expected: None,
3424            new: RefTarget::Symbolic("refs/heads/df".into()),
3425            reflog: None,
3426        });
3427        let err = tx.commit().expect_err("child ref should conflict");
3428        assert!(
3429            matches!(err, GitError::Transaction(message) if message.contains(
3430            "cannot lock ref 'refs/heads/df/conflict'"
3431        ) && message.contains("refs/heads/df"))
3432        );
3433        let _ = fs::remove_dir_all(git_dir);
3434    }
3435
3436    #[test]
3437    fn transaction_checks_expected_value() {
3438        let oid = ObjectId::from_hex(
3439            ObjectFormat::Sha1,
3440            "ce013625030ba8dba906f756967f9e9ca394464a",
3441        )
3442        .expect("test operation should succeed");
3443        let mut store = RefStore::new();
3444        let mut tx = store.transaction();
3445        tx.update(RefUpdate {
3446            name: "refs/heads/main".into(),
3447            expected: None,
3448            new: RefTarget::Direct(oid),
3449            reflog: None,
3450        });
3451        tx.commit().expect("test operation should succeed");
3452        assert_eq!(store.get("refs/heads/main"), Some(&RefTarget::Direct(oid)));
3453    }
3454
3455    #[test]
3456    fn packed_refs_parse_peeled_refs() {
3457        let packed = b"# pack-refs with: peeled fully-peeled sorted \n\
3458ce013625030ba8dba906f756967f9e9ca394464a refs/tags/v1\n\
3459^e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n";
3460        let refs =
3461            parse_packed_refs(ObjectFormat::Sha1, packed).expect("test operation should succeed");
3462        assert_eq!(refs.len(), 1);
3463        assert_eq!(refs[0].reference.name, "refs/tags/v1");
3464        assert_eq!(
3465            refs[0]
3466                .peeled
3467                .as_ref()
3468                .expect("test operation should succeed")
3469                .to_hex(),
3470            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
3471        );
3472    }
3473
3474    #[test]
3475    fn packed_refs_write_sorted_with_peeled_refs() {
3476        let head_oid = ObjectId::from_hex(
3477            ObjectFormat::Sha1,
3478            "ce013625030ba8dba906f756967f9e9ca394464a",
3479        )
3480        .expect("test operation should succeed");
3481        let tag_oid = ObjectId::from_hex(
3482            ObjectFormat::Sha1,
3483            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
3484        )
3485        .expect("test operation should succeed");
3486        let peeled_oid = ObjectId::from_hex(
3487            ObjectFormat::Sha1,
3488            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
3489        )
3490        .expect("test operation should succeed");
3491        let refs = vec![
3492            PackedRef {
3493                reference: Ref {
3494                    name: "refs/tags/v1".into(),
3495                    target: RefTarget::Direct(tag_oid),
3496                },
3497                peeled: Some(peeled_oid),
3498            },
3499            PackedRef {
3500                reference: Ref {
3501                    name: "refs/heads/main".into(),
3502                    target: RefTarget::Direct(head_oid),
3503                },
3504                peeled: None,
3505            },
3506        ];
3507        let bytes = write_packed_refs(&refs).expect("test operation should succeed");
3508        let expected = format!(
3509            "# pack-refs with: peeled fully-peeled sorted \n\
3510{head_oid} refs/heads/main\n\
3511{tag_oid} refs/tags/v1\n\
3512^{peeled_oid}\n"
3513        );
3514        assert_eq!(
3515            String::from_utf8(bytes.clone()).expect("test operation should succeed"),
3516            expected
3517        );
3518        let parsed =
3519            parse_packed_refs(ObjectFormat::Sha1, &bytes).expect("test operation should succeed");
3520        assert_eq!(parsed[0], refs[1]);
3521        assert_eq!(parsed[1], refs[0]);
3522    }
3523
3524    #[test]
3525    fn full_ref_name_validates_and_round_trips_owned() {
3526        let full = FullRefName::new("refs/heads/main").expect("valid full branch ref");
3527        assert_eq!(full.as_str(), "refs/heads/main");
3528        assert_eq!(full.to_string(), "refs/heads/main");
3529        assert_eq!(full.to_owned().into_string(), "refs/heads/main");
3530
3531        let head = FullRefNameBuf::new("HEAD").expect("valid HEAD ref");
3532        assert_eq!(head.as_ref_name().into_str(), "HEAD");
3533
3534        assert!(FullRefName::new("main").is_err());
3535        assert!(FullRefNameBuf::new("refs/heads/bad.lock").is_err());
3536    }
3537
3538    #[test]
3539    fn branch_ref_name_helpers_validate_short_and_full_names() {
3540        let branch =
3541            BranchRefNameBuf::from_branch_name("feature/topic").expect("valid branch short name");
3542        assert_eq!(branch.as_str(), "refs/heads/feature/topic");
3543        assert_eq!(branch.branch_name(), "feature/topic");
3544        assert_eq!(
3545            branch.as_full_ref_name().as_str(),
3546            "refs/heads/feature/topic"
3547        );
3548        assert_eq!(
3549            branch_ref_name("feature/topic").expect("valid branch short name"),
3550            branch.as_str()
3551        );
3552
3553        let borrowed = BranchRefName::from_full("refs/heads/main").expect("valid full branch ref");
3554        assert_eq!(borrowed.branch_name(), "main");
3555        assert_eq!(borrowed.to_owned().into_string(), "refs/heads/main");
3556        assert_eq!(
3557            FullRefName::new("refs/heads/main")
3558                .expect("valid full branch ref")
3559                .as_branch()
3560                .expect("full ref is a branch")
3561                .branch_name(),
3562            "main"
3563        );
3564
3565        assert!(BranchRefName::from_full("refs/tags/main").is_err());
3566        assert!(BranchRefName::from_full("refs/heads").is_err());
3567        assert!(BranchRefNameBuf::from_branch_name("-bad").is_err());
3568    }
3569
3570    #[test]
3571    fn tag_ref_name_helpers_validate_short_and_full_names() {
3572        let tag = TagRefNameBuf::from_tag_name("v1.0").expect("valid tag short name");
3573        assert_eq!(tag.as_str(), "refs/tags/v1.0");
3574        assert_eq!(tag.tag_name(), "v1.0");
3575        assert_eq!(tag.as_full_ref_name().as_str(), "refs/tags/v1.0");
3576        assert_eq!(
3577            tag_ref_name("v1.0").expect("valid tag short name"),
3578            tag.as_str()
3579        );
3580
3581        let borrowed = TagRefName::from_full("refs/tags/release/1").expect("valid full tag ref");
3582        assert_eq!(borrowed.tag_name(), "release/1");
3583        assert_eq!(borrowed.to_owned().into_string(), "refs/tags/release/1");
3584        assert_eq!(
3585            FullRefName::new("refs/tags/release/1")
3586                .expect("valid full tag ref")
3587                .as_tag()
3588                .expect("full ref is a tag")
3589                .tag_name(),
3590            "release/1"
3591        );
3592
3593        assert!(TagRefName::from_full("refs/heads/v1.0").is_err());
3594        assert!(TagRefName::from_full("refs/tags").is_err());
3595        assert!(TagRefNameBuf::from_tag_name("bad tag").is_err());
3596    }
3597
3598    #[test]
3599    fn remote_ref_name_helpers_validate_namespace_and_components() {
3600        let remote = RemoteRefNameBuf::from_remote_branch("origin", "feature/topic")
3601            .expect("valid remote branch ref");
3602        assert_eq!(remote.as_str(), "refs/remotes/origin/feature/topic");
3603        assert_eq!(remote.short_name(), "origin/feature/topic");
3604        assert_eq!(remote.remote_name(), "origin");
3605        assert_eq!(remote.remote_branch(), Some("feature/topic"));
3606        assert_eq!(
3607            remote.as_full_ref_name().as_str(),
3608            "refs/remotes/origin/feature/topic"
3609        );
3610
3611        let head =
3612            RemoteRefName::from_full("refs/remotes/origin/HEAD").expect("valid remote HEAD ref");
3613        assert_eq!(head.remote_name(), "origin");
3614        assert_eq!(head.remote_branch(), Some("HEAD"));
3615        assert_eq!(
3616            FullRefName::new("refs/remotes/upstream/main")
3617                .expect("valid full remote ref")
3618                .as_remote()
3619                .expect("full ref is remote-tracking")
3620                .remote_name(),
3621            "upstream"
3622        );
3623
3624        let short =
3625            RemoteRefNameBuf::from_short_name("origin/main").expect("valid remote short ref");
3626        assert_eq!(short.as_str(), "refs/remotes/origin/main");
3627
3628        assert!(RemoteRefName::from_full("refs/heads/origin/main").is_err());
3629        assert!(RemoteRefName::from_full("refs/remotes/").is_err());
3630        assert!(RemoteRefNameBuf::from_remote_branch("origin/fork", "main").is_err());
3631    }
3632
3633    #[test]
3634    fn file_ref_store_writes_ref_and_reflog() {
3635        let git_dir = temp_git_dir();
3636        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3637        let oid = ObjectId::from_hex(
3638            ObjectFormat::Sha1,
3639            "ce013625030ba8dba906f756967f9e9ca394464a",
3640        )
3641        .expect("test operation should succeed");
3642        let mut tx = store.transaction();
3643        tx.update(RefUpdate {
3644            name: "refs/heads/main".into(),
3645            expected: None,
3646            new: RefTarget::Direct(oid),
3647            reflog: Some(ReflogEntry {
3648                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
3649                new_oid: oid,
3650                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3651                message: b"update by test".to_vec(),
3652            }),
3653        });
3654        tx.commit().expect("test operation should succeed");
3655        assert_eq!(
3656            store
3657                .read_ref("refs/heads/main")
3658                .expect("test operation should succeed"),
3659            Some(RefTarget::Direct(oid))
3660        );
3661        let log = store
3662            .read_reflog("refs/heads/main")
3663            .expect("test operation should succeed");
3664        assert_eq!(log.len(), 1);
3665        assert_eq!(log[0].message, b"update by test");
3666        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3667    }
3668
3669    #[test]
3670    fn file_ref_store_applies_bundle_refs_with_reflog() {
3671        let git_dir = temp_git_dir();
3672        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3673        let old_main = ObjectId::from_hex(
3674            ObjectFormat::Sha1,
3675            "ce013625030ba8dba906f756967f9e9ca394464a",
3676        )
3677        .expect("test operation should succeed");
3678        let new_main = ObjectId::from_hex(
3679            ObjectFormat::Sha1,
3680            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
3681        )
3682        .expect("test operation should succeed");
3683        let tag_oid = ObjectId::from_hex(
3684            ObjectFormat::Sha1,
3685            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
3686        )
3687        .expect("test operation should succeed");
3688        let mut tx = store.transaction();
3689        tx.update(RefUpdate {
3690            name: "refs/heads/main".into(),
3691            expected: None,
3692            new: RefTarget::Direct(old_main.clone()),
3693            reflog: None,
3694        });
3695        tx.commit().expect("test operation should succeed");
3696
3697        let applied = store
3698            .apply_bundle_ref_updates(
3699                &[
3700                    BundleRefUpdate {
3701                        name: "refs/heads/main".into(),
3702                        oid: new_main.clone(),
3703                    },
3704                    BundleRefUpdate {
3705                        name: "refs/tags/v1.0".into(),
3706                        oid: tag_oid,
3707                    },
3708                ],
3709                Some(BundleRefUpdateReflog {
3710                    committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3711                    message: b"bundle: import refs".to_vec(),
3712                }),
3713            )
3714            .expect("test operation should succeed");
3715
3716        assert_eq!(
3717            applied,
3718            vec![
3719                AppliedBundleRefUpdate {
3720                    name: "refs/heads/main".into(),
3721                    old_oid: Some(old_main.clone()),
3722                    new_oid: new_main.clone(),
3723                },
3724                AppliedBundleRefUpdate {
3725                    name: "refs/tags/v1.0".into(),
3726                    old_oid: None,
3727                    new_oid: tag_oid,
3728                }
3729            ]
3730        );
3731        assert_eq!(
3732            store
3733                .read_ref("refs/heads/main")
3734                .expect("test operation should succeed"),
3735            Some(RefTarget::Direct(new_main.clone()))
3736        );
3737        assert_eq!(
3738            store
3739                .read_ref("refs/tags/v1.0")
3740                .expect("test operation should succeed"),
3741            Some(RefTarget::Direct(tag_oid))
3742        );
3743        let main_log = store
3744            .read_reflog("refs/heads/main")
3745            .expect("test operation should succeed");
3746        assert_eq!(main_log.len(), 1);
3747        assert_eq!(main_log[0].old_oid, old_main);
3748        assert_eq!(main_log[0].new_oid, new_main);
3749        assert_eq!(main_log[0].message, b"bundle: import refs");
3750        let tag_log = store
3751            .read_reflog("refs/tags/v1.0")
3752            .expect("test operation should succeed");
3753        assert_eq!(tag_log.len(), 1);
3754        assert_eq!(
3755            tag_log[0].old_oid,
3756            zero_oid(ObjectFormat::Sha1).expect("test operation should succeed")
3757        );
3758        assert_eq!(tag_log[0].new_oid, tag_oid);
3759        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3760    }
3761
3762    #[test]
3763    fn file_ref_store_rejects_bad_bundle_ref_before_writing() {
3764        let git_dir = temp_git_dir();
3765        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3766        let oid = ObjectId::from_hex(
3767            ObjectFormat::Sha1,
3768            "ce013625030ba8dba906f756967f9e9ca394464a",
3769        )
3770        .expect("test operation should succeed");
3771
3772        let result = store.apply_bundle_ref_updates(
3773            &[
3774                BundleRefUpdate {
3775                    name: "refs/heads/main".into(),
3776                    oid,
3777                },
3778                BundleRefUpdate {
3779                    name: "refs/heads/bad.lock".into(),
3780                    oid,
3781                },
3782            ],
3783            None,
3784        );
3785
3786        assert!(result.is_err());
3787        assert_eq!(
3788            store
3789                .read_ref("refs/heads/main")
3790                .expect("test operation should succeed"),
3791            None
3792        );
3793        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3794    }
3795
3796    #[test]
3797    fn file_ref_store_rejects_bundle_ref_over_symbolic_ref() {
3798        let git_dir = temp_git_dir();
3799        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3800        let oid = ObjectId::from_hex(
3801            ObjectFormat::Sha1,
3802            "ce013625030ba8dba906f756967f9e9ca394464a",
3803        )
3804        .expect("test operation should succeed");
3805        let mut tx = store.transaction();
3806        tx.update(RefUpdate {
3807            name: "refs/heads/main".into(),
3808            expected: None,
3809            new: RefTarget::Symbolic("refs/heads/base".into()),
3810            reflog: None,
3811        });
3812        tx.commit().expect("test operation should succeed");
3813
3814        let result = store.apply_bundle_ref_updates(
3815            &[BundleRefUpdate {
3816                name: "refs/heads/main".into(),
3817                oid,
3818            }],
3819            None,
3820        );
3821
3822        assert!(result.is_err());
3823        assert_eq!(
3824            store
3825                .read_ref("refs/heads/main")
3826                .expect("test operation should succeed"),
3827            Some(RefTarget::Symbolic("refs/heads/base".into()))
3828        );
3829        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3830    }
3831
3832    #[test]
3833    fn file_ref_store_expires_reflog_entries_by_timestamp() {
3834        let git_dir = temp_git_dir();
3835        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3836        let first = ObjectId::from_hex(
3837            ObjectFormat::Sha1,
3838            "ce013625030ba8dba906f756967f9e9ca394464a",
3839        )
3840        .expect("test operation should succeed");
3841        let second = ObjectId::from_hex(
3842            ObjectFormat::Sha1,
3843            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
3844        )
3845        .expect("test operation should succeed");
3846        let mut tx = store.transaction();
3847        tx.update(RefUpdate {
3848            name: "refs/heads/main".into(),
3849            expected: None,
3850            new: RefTarget::Direct(first.clone()),
3851            reflog: Some(ReflogEntry {
3852                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
3853                new_oid: first.clone(),
3854                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3855                message: b"old".to_vec(),
3856            }),
3857        });
3858        tx.update(RefUpdate {
3859            name: "refs/heads/main".into(),
3860            expected: None,
3861            new: RefTarget::Direct(second.clone()),
3862            reflog: Some(ReflogEntry {
3863                old_oid: first,
3864                new_oid: second.clone(),
3865                committer: b"Git Rs <sley@example.invalid> 100 +0000".to_vec(),
3866                message: b"new".to_vec(),
3867            }),
3868        });
3869        tx.commit().expect("test operation should succeed");
3870
3871        let removed = store
3872            .expire_reflog_older_than("refs/heads/main", 50)
3873            .expect("test operation should succeed");
3874        assert_eq!(removed, 1);
3875        let log = store
3876            .read_reflog("refs/heads/main")
3877            .expect("test operation should succeed");
3878        assert_eq!(log.len(), 1);
3879        assert_eq!(log[0].new_oid, second);
3880        assert_eq!(log[0].message, b"new");
3881        assert!(
3882            !git_dir
3883                .join("logs")
3884                .join("refs")
3885                .join("heads")
3886                .join("main.lock")
3887                .exists()
3888        );
3889        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3890    }
3891
3892    #[test]
3893    fn file_ref_store_creates_branch() {
3894        let git_dir = temp_git_dir();
3895        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3896        let oid = ObjectId::from_hex(
3897            ObjectFormat::Sha1,
3898            "ce013625030ba8dba906f756967f9e9ca394464a",
3899        )
3900        .expect("test operation should succeed");
3901        let branch = store
3902            .create_branch(
3903                "feature",
3904                oid,
3905                b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3906                b"branch: Created from main".to_vec(),
3907            )
3908            .expect("test operation should succeed");
3909        assert_eq!(branch.name, "refs/heads/feature");
3910        assert_eq!(
3911            store
3912                .read_ref("refs/heads/feature")
3913                .expect("test operation should succeed"),
3914            Some(RefTarget::Direct(oid))
3915        );
3916        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3917    }
3918
3919    #[test]
3920    fn file_ref_store_deletes_loose_branch() {
3921        let git_dir = temp_git_dir();
3922        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3923        let oid = ObjectId::from_hex(
3924            ObjectFormat::Sha1,
3925            "ce013625030ba8dba906f756967f9e9ca394464a",
3926        )
3927        .expect("test operation should succeed");
3928        store
3929            .create_branch(
3930                "feature",
3931                oid,
3932                b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3933                b"branch: Created from main".to_vec(),
3934            )
3935            .expect("test operation should succeed");
3936        let deleted = store
3937            .delete_branch("feature")
3938            .expect("test operation should succeed");
3939        assert_eq!(deleted.name, "refs/heads/feature");
3940        assert_eq!(deleted.oid, oid);
3941        assert_eq!(
3942            store
3943                .read_ref("refs/heads/feature")
3944                .expect("test operation should succeed"),
3945            None
3946        );
3947        assert!(!git_dir.join("refs").join("heads").join("feature").exists());
3948        assert!(
3949            !git_dir
3950                .join("logs")
3951                .join("refs")
3952                .join("heads")
3953                .join("feature")
3954                .exists()
3955        );
3956        fs::remove_dir_all(git_dir).expect("test operation should succeed");
3957    }
3958
3959    #[test]
3960    fn file_ref_store_deletes_generic_loose_ref() {
3961        let git_dir = temp_git_dir();
3962        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3963        let oid = ObjectId::from_hex(
3964            ObjectFormat::Sha1,
3965            "ce013625030ba8dba906f756967f9e9ca394464a",
3966        )
3967        .expect("test operation should succeed");
3968        let mut tx = store.transaction();
3969        tx.update(RefUpdate {
3970            name: "refs/heads/topic".into(),
3971            expected: None,
3972            new: RefTarget::Direct(oid),
3973            reflog: Some(ReflogEntry {
3974                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
3975                new_oid: oid,
3976                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
3977                message: b"update by test".to_vec(),
3978            }),
3979        });
3980        tx.commit().expect("test operation should succeed");
3981        let deleted = store
3982            .delete_ref("refs/heads/topic")
3983            .expect("test operation should succeed");
3984        assert_eq!(deleted.name, "refs/heads/topic");
3985        assert_eq!(deleted.oid, oid);
3986        assert_eq!(
3987            store
3988                .read_ref("refs/heads/topic")
3989                .expect("test operation should succeed"),
3990            None
3991        );
3992        assert!(!git_dir.join("refs").join("heads").join("topic").exists());
3993        assert!(
3994            !git_dir
3995                .join("logs")
3996                .join("refs")
3997                .join("heads")
3998                .join("topic")
3999                .exists()
4000        );
4001        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4002    }
4003
4004    #[test]
4005    fn file_ref_store_delete_ref_checked_removes_reflog() {
4006        let git_dir = temp_git_dir();
4007        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4008        let oid = ObjectId::from_hex(
4009            ObjectFormat::Sha1,
4010            "ce013625030ba8dba906f756967f9e9ca394464a",
4011        )
4012        .expect("test operation should succeed");
4013        // Create the ref *with* a reflog entry so logs/refs/heads/main exists on
4014        // disk; git unlinks that file on delete rather than appending a deletion
4015        // entry, so the checked delete must remove it (mirroring delete_ref).
4016        let mut tx = store.transaction();
4017        tx.update(RefUpdate {
4018            name: "refs/heads/main".into(),
4019            expected: None,
4020            new: RefTarget::Direct(oid),
4021            reflog: Some(ReflogEntry {
4022                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
4023                new_oid: oid,
4024                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
4025                message: b"create main".to_vec(),
4026            }),
4027        });
4028        tx.commit().expect("test operation should succeed");
4029        assert!(
4030            git_dir
4031                .join("logs")
4032                .join("refs")
4033                .join("heads")
4034                .join("main")
4035                .exists(),
4036            "reflog file should exist before the checked delete"
4037        );
4038
4039        let deleted = store
4040            .delete_ref_checked(DeleteRef {
4041                name: "refs/heads/main".into(),
4042                expected_old: Some(oid),
4043                reflog: Some(DeleteRefReflog {
4044                    committer: b"Git Rs <sley@example.invalid> 123 +0000".to_vec(),
4045                    message: b"delete main".to_vec(),
4046                }),
4047            })
4048            .expect("test operation should succeed");
4049
4050        assert_eq!(deleted.name, "refs/heads/main");
4051        assert_eq!(deleted.oid, oid);
4052        assert_eq!(
4053            store
4054                .read_ref("refs/heads/main")
4055                .expect("test operation should succeed"),
4056            None
4057        );
4058        // Git unlinks the reflog on delete: the file is gone and there is no
4059        // lingering deletion entry to read back.
4060        assert!(
4061            !git_dir
4062                .join("logs")
4063                .join("refs")
4064                .join("heads")
4065                .join("main")
4066                .exists(),
4067            "reflog file should be removed by the checked delete"
4068        );
4069        assert!(
4070            store
4071                .read_reflog("refs/heads/main")
4072                .expect("test operation should succeed")
4073                .is_empty()
4074        );
4075        assert!(
4076            !git_dir
4077                .join("refs")
4078                .join("heads")
4079                .join("main.lock")
4080                .exists()
4081        );
4082        assert!(!git_dir.join("packed-refs.lock").exists());
4083        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4084    }
4085
4086    #[test]
4087    fn file_ref_store_delete_ref_checked_stale_expected_leaves_ref_untouched() {
4088        let git_dir = temp_git_dir();
4089        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4090        let actual = ObjectId::from_hex(
4091            ObjectFormat::Sha1,
4092            "ce013625030ba8dba906f756967f9e9ca394464a",
4093        )
4094        .expect("test operation should succeed");
4095        let expected = ObjectId::from_hex(
4096            ObjectFormat::Sha1,
4097            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4098        )
4099        .expect("test operation should succeed");
4100        let mut tx = store.transaction();
4101        tx.update(RefUpdate {
4102            name: "refs/heads/main".into(),
4103            expected: None,
4104            new: RefTarget::Direct(actual),
4105            reflog: None,
4106        });
4107        tx.commit().expect("test operation should succeed");
4108
4109        let err = store
4110            .delete_ref_checked(DeleteRef {
4111                name: "refs/heads/main".into(),
4112                expected_old: Some(expected),
4113                reflog: None,
4114            })
4115            .expect_err("stale expected must fail");
4116
4117        assert!(matches!(
4118            err,
4119            RefDeleteError::ExpectedMismatch {
4120                expected: Some(got_expected),
4121                actual: Some(got_actual),
4122            } if got_expected == expected && got_actual == actual
4123        ));
4124        assert_eq!(
4125            store
4126                .read_ref("refs/heads/main")
4127                .expect("test operation should succeed"),
4128            Some(RefTarget::Direct(actual))
4129        );
4130        assert!(
4131            !git_dir
4132                .join("refs")
4133                .join("heads")
4134                .join("main.lock")
4135                .exists()
4136        );
4137        assert!(!git_dir.join("packed-refs.lock").exists());
4138        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4139    }
4140
4141    #[test]
4142    fn file_ref_store_delete_ref_checked_missing_returns_not_found() {
4143        let git_dir = temp_git_dir();
4144        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4145
4146        let err = store
4147            .delete_ref_checked(DeleteRef {
4148                name: "refs/heads/missing".into(),
4149                expected_old: None,
4150                reflog: None,
4151            })
4152            .expect_err("missing ref must fail");
4153
4154        assert!(matches!(err, RefDeleteError::NotFound));
4155        assert!(
4156            !git_dir
4157                .join("refs")
4158                .join("heads")
4159                .join("missing.lock")
4160                .exists()
4161        );
4162        assert!(!git_dir.join("packed-refs.lock").exists());
4163        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4164    }
4165
4166    #[test]
4167    fn file_ref_store_delete_ref_checked_removes_packed_ref() {
4168        let git_dir = temp_git_dir();
4169        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4170        let oid = ObjectId::from_hex(
4171            ObjectFormat::Sha1,
4172            "ce013625030ba8dba906f756967f9e9ca394464a",
4173        )
4174        .expect("test operation should succeed");
4175        let other = ObjectId::from_hex(
4176            ObjectFormat::Sha1,
4177            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4178        )
4179        .expect("test operation should succeed");
4180        store
4181            .write_packed_refs(&[
4182                PackedRef {
4183                    reference: Ref {
4184                        name: "refs/heads/main".into(),
4185                        target: RefTarget::Direct(oid),
4186                    },
4187                    peeled: None,
4188                },
4189                PackedRef {
4190                    reference: Ref {
4191                        name: "refs/heads/other".into(),
4192                        target: RefTarget::Direct(other),
4193                    },
4194                    peeled: None,
4195                },
4196            ])
4197            .expect("test operation should succeed");
4198
4199        store
4200            .delete_ref_checked(DeleteRef {
4201                name: "refs/heads/main".into(),
4202                expected_old: Some(oid),
4203                reflog: None,
4204            })
4205            .expect("test operation should succeed");
4206
4207        assert_eq!(
4208            store
4209                .read_ref("refs/heads/main")
4210                .expect("test operation should succeed"),
4211            None
4212        );
4213        assert_eq!(
4214            store
4215                .read_ref("refs/heads/other")
4216                .expect("test operation should succeed"),
4217            Some(RefTarget::Direct(other))
4218        );
4219        let packed =
4220            fs::read_to_string(git_dir.join("packed-refs")).expect("test operation should succeed");
4221        assert!(!packed.contains("refs/heads/main"));
4222        assert!(packed.contains("refs/heads/other"));
4223        assert!(
4224            !git_dir
4225                .join("refs")
4226                .join("heads")
4227                .join("main.lock")
4228                .exists()
4229        );
4230        assert!(!git_dir.join("packed-refs.lock").exists());
4231        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4232    }
4233
4234    #[test]
4235    fn file_ref_store_delete_ref_checked_lock_conflict_returns_locked() {
4236        let git_dir = temp_git_dir();
4237        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4238        let oid = ObjectId::from_hex(
4239            ObjectFormat::Sha1,
4240            "ce013625030ba8dba906f756967f9e9ca394464a",
4241        )
4242        .expect("test operation should succeed");
4243        let mut tx = store.transaction();
4244        tx.update(RefUpdate {
4245            name: "refs/heads/main".into(),
4246            expected: None,
4247            new: RefTarget::Direct(oid),
4248            reflog: None,
4249        });
4250        tx.commit().expect("test operation should succeed");
4251        fs::write(
4252            git_dir.join("refs").join("heads").join("main.lock"),
4253            b"held\n",
4254        )
4255        .expect("test operation should succeed");
4256
4257        let err = store
4258            .delete_ref_checked(DeleteRef {
4259                name: "refs/heads/main".into(),
4260                expected_old: Some(oid),
4261                reflog: None,
4262            })
4263            .expect_err("held lock must fail");
4264
4265        assert!(matches!(err, RefDeleteError::Locked));
4266        assert_eq!(
4267            store
4268                .read_ref("refs/heads/main")
4269                .expect("test operation should succeed"),
4270            Some(RefTarget::Direct(oid))
4271        );
4272        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4273    }
4274
4275    #[test]
4276    fn file_ref_store_reports_current_branch() {
4277        let git_dir = temp_git_dir();
4278        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
4279            .expect("test operation should succeed");
4280        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4281        assert_eq!(
4282            store
4283                .current_branch_ref()
4284                .expect("test operation should succeed"),
4285            Some("refs/heads/main".into())
4286        );
4287        assert_eq!(
4288            store
4289                .current_branch()
4290                .expect("test operation should succeed"),
4291            Some("main".into())
4292        );
4293        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4294    }
4295
4296    #[test]
4297    fn file_ref_store_resolves_linked_worktree_head_through_common_refs() {
4298        let common = temp_git_dir();
4299        let admin = common.join("worktrees").join("linked");
4300        fs::create_dir_all(&admin).expect("test operation should succeed");
4301        fs::write(admin.join("commondir"), "../..\n").expect("test operation should succeed");
4302        fs::write(admin.join("HEAD"), b"ref: refs/heads/topic\n")
4303            .expect("test operation should succeed");
4304        let oid = ObjectId::from_hex(
4305            ObjectFormat::Sha256,
4306            "08ffba112b648c22b5425f01bec2c37ffc524c4d48ef04337779df3973733050",
4307        )
4308        .expect("test operation should succeed");
4309        fs::create_dir_all(common.join("refs").join("heads"))
4310            .expect("test operation should succeed");
4311        fs::write(
4312            common.join("refs").join("heads").join("topic"),
4313            format!("{oid}\n"),
4314        )
4315        .expect("test operation should succeed");
4316
4317        let store = FileRefStore::new(&admin, ObjectFormat::Sha256);
4318        assert_eq!(
4319            store
4320                .read_ref("HEAD")
4321                .expect("test operation should succeed"),
4322            Some(RefTarget::Symbolic("refs/heads/topic".into()))
4323        );
4324        assert_eq!(
4325            store
4326                .read_ref("refs/heads/topic")
4327                .expect("test operation should succeed"),
4328            Some(RefTarget::Direct(oid))
4329        );
4330
4331        fs::remove_dir_all(common).expect("test operation should succeed");
4332    }
4333
4334    #[test]
4335    fn file_ref_store_creates_tag() {
4336        let git_dir = temp_git_dir();
4337        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4338        let oid = ObjectId::from_hex(
4339            ObjectFormat::Sha1,
4340            "ce013625030ba8dba906f756967f9e9ca394464a",
4341        )
4342        .expect("test operation should succeed");
4343        let tag = store
4344            .create_tag("v1.0", oid)
4345            .expect("test operation should succeed");
4346        assert_eq!(tag.name, "refs/tags/v1.0");
4347        assert_eq!(
4348            store
4349                .read_ref("refs/tags/v1.0")
4350                .expect("test operation should succeed"),
4351            Some(RefTarget::Direct(oid))
4352        );
4353        assert!(
4354            store
4355                .read_reflog("refs/tags/v1.0")
4356                .expect("test operation should succeed")
4357                .is_empty()
4358        );
4359        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4360    }
4361
4362    #[test]
4363    fn file_ref_store_deletes_loose_tag() {
4364        let git_dir = temp_git_dir();
4365        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4366        let oid = ObjectId::from_hex(
4367            ObjectFormat::Sha1,
4368            "ce013625030ba8dba906f756967f9e9ca394464a",
4369        )
4370        .expect("test operation should succeed");
4371        store
4372            .create_tag("v1.0", oid)
4373            .expect("test operation should succeed");
4374        let deleted = store
4375            .delete_tag("v1.0")
4376            .expect("test operation should succeed");
4377        assert_eq!(deleted.name, "refs/tags/v1.0");
4378        assert_eq!(deleted.oid, oid);
4379        assert_eq!(
4380            store
4381                .read_ref("refs/tags/v1.0")
4382                .expect("test operation should succeed"),
4383            None
4384        );
4385        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
4386        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4387    }
4388
4389    #[test]
4390    fn file_ref_store_reads_packed_ref() {
4391        let git_dir = temp_git_dir();
4392        fs::write(
4393            git_dir.join("packed-refs"),
4394            b"ce013625030ba8dba906f756967f9e9ca394464a refs/heads/main\n",
4395        )
4396        .expect("test operation should succeed");
4397        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4398        assert!(matches!(
4399            store
4400                .read_ref("refs/heads/main")
4401                .expect("test operation should succeed"),
4402            Some(RefTarget::Direct(_))
4403        ));
4404        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4405    }
4406
4407    #[test]
4408    fn file_ref_store_lists_loose_refs_over_packed_refs() {
4409        let git_dir = temp_git_dir();
4410        fs::write(
4411            git_dir.join("packed-refs"),
4412            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n",
4413        )
4414        .expect("test operation should succeed");
4415        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4416        let oid = ObjectId::from_hex(
4417            ObjectFormat::Sha1,
4418            "ce013625030ba8dba906f756967f9e9ca394464a",
4419        )
4420        .expect("test operation should succeed");
4421        let mut tx = store.transaction();
4422        tx.update(RefUpdate {
4423            name: "refs/heads/main".into(),
4424            expected: None,
4425            new: RefTarget::Direct(oid),
4426            reflog: None,
4427        });
4428        tx.commit().expect("test operation should succeed");
4429        let refs = store.list_refs().expect("test operation should succeed");
4430        assert_eq!(refs.len(), 1);
4431        assert_eq!(refs[0].target, RefTarget::Direct(oid));
4432        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4433    }
4434
4435    #[test]
4436    fn file_ref_store_writes_packed_refs() {
4437        let git_dir = temp_git_dir();
4438        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4439        let oid = ObjectId::from_hex(
4440            ObjectFormat::Sha1,
4441            "ce013625030ba8dba906f756967f9e9ca394464a",
4442        )
4443        .expect("test operation should succeed");
4444        store
4445            .write_packed_refs(&[PackedRef {
4446                reference: Ref {
4447                    name: "refs/heads/main".into(),
4448                    target: RefTarget::Direct(oid),
4449                },
4450                peeled: None,
4451            }])
4452            .expect("test operation should succeed");
4453        assert_eq!(
4454            store
4455                .read_ref("refs/heads/main")
4456                .expect("test operation should succeed"),
4457            Some(RefTarget::Direct(oid))
4458        );
4459        let refs = store.list_refs().expect("test operation should succeed");
4460        assert_eq!(refs.len(), 1);
4461        assert_eq!(refs[0].target, RefTarget::Direct(oid));
4462        assert!(git_dir.join("packed-refs").exists());
4463        assert!(!git_dir.join("packed-refs.lock").exists());
4464        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4465    }
4466
4467    #[test]
4468    fn file_ref_store_checks_ref_prefix_in_packed_refs() {
4469        let git_dir = temp_git_dir();
4470        fs::write(
4471            git_dir.join("packed-refs"),
4472            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n\
4473              ce013625030ba8dba906f756967f9e9ca394464a refs/replace/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n",
4474        )
4475        .expect("test operation should succeed");
4476        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4477        assert!(
4478            store
4479                .has_refs_with_prefix("refs/replace/")
4480                .expect("test operation should succeed")
4481        );
4482        assert!(
4483            !store
4484                .has_refs_with_prefix("refs/notes/")
4485                .expect("test operation should succeed")
4486        );
4487        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4488    }
4489
4490    #[test]
4491    fn file_ref_store_checks_ref_prefix_in_loose_refs() {
4492        let git_dir = temp_git_dir();
4493        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4494        let oid = ObjectId::from_hex(
4495            ObjectFormat::Sha1,
4496            "ce013625030ba8dba906f756967f9e9ca394464a",
4497        )
4498        .expect("test operation should succeed");
4499        let mut tx = store.transaction();
4500        tx.update(RefUpdate {
4501            name: "refs/replace/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391".into(),
4502            expected: None,
4503            new: RefTarget::Direct(oid),
4504            reflog: None,
4505        });
4506        tx.commit().expect("test operation should succeed");
4507        assert!(
4508            store
4509                .has_refs_with_prefix("refs/replace/")
4510                .expect("test operation should succeed")
4511        );
4512        assert!(
4513            !store
4514                .has_refs_with_prefix("refs/notes/")
4515                .expect("test operation should succeed")
4516        );
4517        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4518    }
4519
4520    #[test]
4521    fn file_ref_store_reads_reftable_stack_and_ignores_dummy_head() {
4522        let git_dir = temp_git_dir();
4523        write_reftable_config(&git_dir);
4524        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/.invalid\n")
4525            .expect("test operation should succeed");
4526        let head_oid = ObjectId::from_hex(
4527            ObjectFormat::Sha1,
4528            "ce013625030ba8dba906f756967f9e9ca394464a",
4529        )
4530        .expect("test operation should succeed");
4531        let tag_oid = ObjectId::from_hex(
4532            ObjectFormat::Sha1,
4533            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
4534        )
4535        .expect("test operation should succeed");
4536        let peeled_oid = ObjectId::from_hex(
4537            ObjectFormat::Sha1,
4538            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4539        )
4540        .expect("test operation should succeed");
4541        write_reftable_stack(
4542            &git_dir,
4543            &[(
4544                "000000000001-000000000001-rust.ref",
4545                vec![
4546                    sley_formats::ReftableRefRecord {
4547                        name: "HEAD".into(),
4548                        update_index: 1,
4549                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
4550                    },
4551                    sley_formats::ReftableRefRecord {
4552                        name: "refs/heads/main".into(),
4553                        update_index: 1,
4554                        value: ReftableRefValue::Direct(head_oid),
4555                    },
4556                    sley_formats::ReftableRefRecord {
4557                        name: "refs/tags/v1.0".into(),
4558                        update_index: 1,
4559                        value: ReftableRefValue::Peeled {
4560                            target: tag_oid,
4561                            peeled: peeled_oid,
4562                        },
4563                    },
4564                ],
4565            )],
4566        );
4567
4568        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4569        assert_eq!(
4570            store
4571                .read_ref("HEAD")
4572                .expect("test operation should succeed"),
4573            Some(RefTarget::Symbolic("refs/heads/main".into()))
4574        );
4575        assert_eq!(
4576            store
4577                .read_ref("refs/heads/main")
4578                .expect("test operation should succeed"),
4579            Some(RefTarget::Direct(head_oid))
4580        );
4581        assert_eq!(
4582            store
4583                .read_ref("refs/tags/v1.0")
4584                .expect("test operation should succeed"),
4585            Some(RefTarget::Direct(tag_oid))
4586        );
4587        let refs = store.list_refs().expect("test operation should succeed");
4588        assert_eq!(
4589            refs,
4590            vec![
4591                Ref {
4592                    name: "refs/heads/main".into(),
4593                    target: RefTarget::Direct(head_oid),
4594                },
4595                Ref {
4596                    name: "refs/tags/v1.0".into(),
4597                    target: RefTarget::Direct(tag_oid),
4598                },
4599            ]
4600        );
4601
4602        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4603    }
4604
4605    #[test]
4606    fn file_ref_store_applies_reftable_stack_overrides_and_deletions() {
4607        let git_dir = temp_git_dir();
4608        write_reftable_config(&git_dir);
4609        let first = ObjectId::from_hex(
4610            ObjectFormat::Sha1,
4611            "ce013625030ba8dba906f756967f9e9ca394464a",
4612        )
4613        .expect("test operation should succeed");
4614        let second = ObjectId::from_hex(
4615            ObjectFormat::Sha1,
4616            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4617        )
4618        .expect("test operation should succeed");
4619        write_reftable_stack(
4620            &git_dir,
4621            &[
4622                (
4623                    "000000000001-000000000001-base.ref",
4624                    vec![
4625                        sley_formats::ReftableRefRecord {
4626                            name: "refs/heads/main".into(),
4627                            update_index: 1,
4628                            value: ReftableRefValue::Direct(first),
4629                        },
4630                        sley_formats::ReftableRefRecord {
4631                            name: "refs/heads/topic".into(),
4632                            update_index: 1,
4633                            value: ReftableRefValue::Direct(second.clone()),
4634                        },
4635                    ],
4636                ),
4637                (
4638                    "000000000002-000000000002-tip.ref",
4639                    vec![
4640                        sley_formats::ReftableRefRecord {
4641                            name: "refs/heads/main".into(),
4642                            update_index: 2,
4643                            value: ReftableRefValue::Direct(second.clone()),
4644                        },
4645                        sley_formats::ReftableRefRecord {
4646                            name: "refs/heads/topic".into(),
4647                            update_index: 2,
4648                            value: ReftableRefValue::Deletion,
4649                        },
4650                    ],
4651                ),
4652            ],
4653        );
4654
4655        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4656        assert_eq!(
4657            store
4658                .read_ref("refs/heads/main")
4659                .expect("test operation should succeed"),
4660            Some(RefTarget::Direct(second.clone()))
4661        );
4662        assert_eq!(
4663            store
4664                .read_ref("refs/heads/topic")
4665                .expect("test operation should succeed"),
4666            None
4667        );
4668        assert_eq!(
4669            store.list_refs().expect("test operation should succeed"),
4670            vec![Ref {
4671                name: "refs/heads/main".into(),
4672                target: RefTarget::Direct(second),
4673            }]
4674        );
4675
4676        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4677    }
4678
4679    #[test]
4680    fn file_ref_store_writes_reftable_transaction_table() {
4681        let git_dir = temp_git_dir();
4682        write_reftable_config(&git_dir);
4683        let first = ObjectId::from_hex(
4684            ObjectFormat::Sha1,
4685            "ce013625030ba8dba906f756967f9e9ca394464a",
4686        )
4687        .expect("test operation should succeed");
4688        let second = ObjectId::from_hex(
4689            ObjectFormat::Sha1,
4690            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4691        )
4692        .expect("test operation should succeed");
4693        write_reftable_stack(
4694            &git_dir,
4695            &[(
4696                "000000000001-000000000001-base.ref",
4697                vec![sley_formats::ReftableRefRecord {
4698                    name: "refs/heads/main".into(),
4699                    update_index: 1,
4700                    value: ReftableRefValue::Direct(first),
4701                }],
4702            )],
4703        );
4704
4705        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4706        let mut tx = store.transaction();
4707        tx.update(RefUpdate {
4708            name: "HEAD".into(),
4709            expected: None,
4710            new: RefTarget::Symbolic("refs/heads/main".into()),
4711            reflog: None,
4712        });
4713        tx.update(RefUpdate {
4714            name: "refs/heads/main".into(),
4715            expected: None,
4716            new: RefTarget::Direct(second.clone()),
4717            reflog: None,
4718        });
4719        tx.commit().expect("test operation should succeed");
4720
4721        assert_eq!(
4722            store
4723                .read_ref("HEAD")
4724                .expect("test operation should succeed"),
4725            Some(RefTarget::Symbolic("refs/heads/main".into()))
4726        );
4727        assert_eq!(
4728            store
4729                .read_ref("refs/heads/main")
4730                .expect("test operation should succeed"),
4731            Some(RefTarget::Direct(second.clone()))
4732        );
4733        assert_eq!(
4734            store
4735                .list_refs()
4736                .expect("test operation should succeed")
4737                .len(),
4738            1
4739        );
4740        assert!(!git_dir.join("HEAD").exists());
4741        let tables = fs::read_to_string(git_dir.join("reftable").join("tables.list"))
4742            .expect("test operation should succeed");
4743        assert_eq!(tables.lines().count(), 2);
4744        assert!(
4745            tables
4746                .lines()
4747                .last()
4748                .expect("test operation should succeed")
4749                .contains("sley"),
4750            "expected rust-written reftable in tables.list, got {tables}"
4751        );
4752
4753        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4754    }
4755
4756    #[test]
4757    fn file_ref_store_deletes_reftable_refs_with_tombstones() {
4758        let git_dir = temp_git_dir();
4759        write_reftable_config(&git_dir);
4760        let oid = ObjectId::from_hex(
4761            ObjectFormat::Sha1,
4762            "ce013625030ba8dba906f756967f9e9ca394464a",
4763        )
4764        .expect("test operation should succeed");
4765        write_reftable_stack(
4766            &git_dir,
4767            &[(
4768                "000000000001-000000000001-base.ref",
4769                vec![
4770                    sley_formats::ReftableRefRecord {
4771                        name: "refs/heads/main".into(),
4772                        update_index: 1,
4773                        value: ReftableRefValue::Direct(oid),
4774                    },
4775                    sley_formats::ReftableRefRecord {
4776                        name: "refs/alias/main".into(),
4777                        update_index: 1,
4778                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
4779                    },
4780                ],
4781            )],
4782        );
4783
4784        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4785        assert!(
4786            store
4787                .delete_symbolic_ref("refs/alias/main")
4788                .expect("test operation should succeed")
4789        );
4790        assert_eq!(
4791            store
4792                .read_ref("refs/alias/main")
4793                .expect("test operation should succeed"),
4794            None
4795        );
4796        let deleted = store
4797            .delete_ref("refs/heads/main")
4798            .expect("test operation should succeed");
4799        assert_eq!(deleted.oid, oid);
4800        assert_eq!(
4801            store
4802                .read_ref("refs/heads/main")
4803                .expect("test operation should succeed"),
4804            None
4805        );
4806        assert!(
4807            store
4808                .list_refs()
4809                .expect("test operation should succeed")
4810                .is_empty()
4811        );
4812        let tables = fs::read_to_string(git_dir.join("reftable").join("tables.list"))
4813            .expect("test operation should succeed");
4814        assert_eq!(tables.lines().count(), 3);
4815
4816        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4817    }
4818
4819    #[test]
4820    fn file_ref_store_deletes_packed_branch() {
4821        let git_dir = temp_git_dir();
4822        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4823        let branch_oid = ObjectId::from_hex(
4824            ObjectFormat::Sha1,
4825            "ce013625030ba8dba906f756967f9e9ca394464a",
4826        )
4827        .expect("test operation should succeed");
4828        let tag_oid = ObjectId::from_hex(
4829            ObjectFormat::Sha1,
4830            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4831        )
4832        .expect("test operation should succeed");
4833        store
4834            .write_packed_refs(&[
4835                PackedRef {
4836                    reference: Ref {
4837                        name: "refs/heads/feature".into(),
4838                        target: RefTarget::Direct(branch_oid),
4839                    },
4840                    peeled: None,
4841                },
4842                PackedRef {
4843                    reference: Ref {
4844                        name: "refs/tags/v1.0".into(),
4845                        target: RefTarget::Direct(tag_oid),
4846                    },
4847                    peeled: None,
4848                },
4849            ])
4850            .expect("test operation should succeed");
4851        let deleted = store
4852            .delete_branch("feature")
4853            .expect("test operation should succeed");
4854        assert_eq!(deleted.name, "refs/heads/feature");
4855        assert_eq!(deleted.oid, branch_oid);
4856        assert_eq!(
4857            store
4858                .read_ref("refs/heads/feature")
4859                .expect("test operation should succeed"),
4860            None
4861        );
4862        assert_eq!(
4863            store
4864                .read_ref("refs/tags/v1.0")
4865                .expect("test operation should succeed"),
4866            Some(RefTarget::Direct(tag_oid))
4867        );
4868        assert!(!git_dir.join("packed-refs.lock").exists());
4869        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4870    }
4871
4872    #[test]
4873    fn file_ref_store_deletes_packed_tag() {
4874        let git_dir = temp_git_dir();
4875        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4876        let oid = ObjectId::from_hex(
4877            ObjectFormat::Sha1,
4878            "ce013625030ba8dba906f756967f9e9ca394464a",
4879        )
4880        .expect("test operation should succeed");
4881        store
4882            .write_packed_refs(&[PackedRef {
4883                reference: Ref {
4884                    name: "refs/tags/v1.0".into(),
4885                    target: RefTarget::Direct(oid),
4886                },
4887                peeled: None,
4888            }])
4889            .expect("test operation should succeed");
4890        let deleted = store
4891            .delete_tag("v1.0")
4892            .expect("test operation should succeed");
4893        assert_eq!(deleted.name, "refs/tags/v1.0");
4894        assert_eq!(deleted.oid, oid);
4895        assert_eq!(
4896            store
4897                .read_ref("refs/tags/v1.0")
4898                .expect("test operation should succeed"),
4899            None
4900        );
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_and_prunes() {
4907        let git_dir = temp_git_dir();
4908        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4909        let main_oid = ObjectId::from_hex(
4910            ObjectFormat::Sha1,
4911            "ce013625030ba8dba906f756967f9e9ca394464a",
4912        )
4913        .expect("test operation should succeed");
4914        let tag_oid = ObjectId::from_hex(
4915            ObjectFormat::Sha1,
4916            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4917        )
4918        .expect("test operation should succeed");
4919        let mut tx = store.transaction();
4920        tx.update(RefUpdate {
4921            name: "refs/heads/main".into(),
4922            expected: None,
4923            new: RefTarget::Direct(main_oid),
4924            reflog: None,
4925        });
4926        tx.update(RefUpdate {
4927            name: "refs/tags/v1.0".into(),
4928            expected: None,
4929            new: RefTarget::Direct(tag_oid),
4930            reflog: None,
4931        });
4932        tx.commit().expect("test operation should succeed");
4933
4934        let packed = store
4935            .pack_refs(true)
4936            .expect("test operation should succeed");
4937        assert_eq!(packed.len(), 2);
4938        assert_eq!(
4939            store
4940                .read_ref("refs/heads/main")
4941                .expect("test operation should succeed"),
4942            Some(RefTarget::Direct(main_oid))
4943        );
4944        assert_eq!(
4945            store
4946                .read_ref("refs/tags/v1.0")
4947                .expect("test operation should succeed"),
4948            Some(RefTarget::Direct(tag_oid))
4949        );
4950        assert!(!git_dir.join("refs").join("heads").join("main").exists());
4951        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
4952        assert!(git_dir.join("packed-refs").exists());
4953        assert!(!git_dir.join("packed-refs.lock").exists());
4954        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4955    }
4956
4957    #[test]
4958    fn file_ref_store_packs_loose_refs_without_pruning() {
4959        let git_dir = temp_git_dir();
4960        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4961        let oid = ObjectId::from_hex(
4962            ObjectFormat::Sha1,
4963            "ce013625030ba8dba906f756967f9e9ca394464a",
4964        )
4965        .expect("test operation should succeed");
4966        let mut tx = store.transaction();
4967        tx.update(RefUpdate {
4968            name: "refs/heads/main".into(),
4969            expected: None,
4970            new: RefTarget::Direct(oid),
4971            reflog: None,
4972        });
4973        tx.commit().expect("test operation should succeed");
4974
4975        let packed = store
4976            .pack_refs(false)
4977            .expect("test operation should succeed");
4978        assert_eq!(packed.len(), 1);
4979        assert!(git_dir.join("refs").join("heads").join("main").exists());
4980        assert_eq!(
4981            store
4982                .read_ref("refs/heads/main")
4983                .expect("test operation should succeed"),
4984            Some(RefTarget::Direct(oid))
4985        );
4986        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4987    }
4988
4989    #[test]
4990    fn file_ref_store_packs_loose_refs_with_peeled_ids() {
4991        let git_dir = temp_git_dir();
4992        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4993        let tag_oid = ObjectId::from_hex(
4994            ObjectFormat::Sha1,
4995            "ce013625030ba8dba906f756967f9e9ca394464a",
4996        )
4997        .expect("test operation should succeed");
4998        let peeled_oid = ObjectId::from_hex(
4999            ObjectFormat::Sha1,
5000            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5001        )
5002        .expect("test operation should succeed");
5003        let mut tx = store.transaction();
5004        tx.update(RefUpdate {
5005            name: "refs/tags/v1.0".into(),
5006            expected: None,
5007            new: RefTarget::Direct(tag_oid),
5008            reflog: None,
5009        });
5010        tx.commit().expect("test operation should succeed");
5011
5012        let packed = store
5013            .pack_refs_with_peeler(true, |name, oid| {
5014                if name == "refs/tags/v1.0" && oid == &tag_oid {
5015                    Ok(Some(peeled_oid))
5016                } else {
5017                    Ok(None)
5018                }
5019            })
5020            .expect("test operation should succeed");
5021        assert_eq!(packed.len(), 1);
5022        assert_eq!(packed[0].peeled, Some(peeled_oid));
5023        let bytes =
5024            fs::read_to_string(git_dir.join("packed-refs")).expect("test operation should succeed");
5025        assert!(bytes.contains(&format!("^{peeled_oid}\n")));
5026        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
5027        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5028    }
5029
5030    fn reflog_entry(new_oid: &ObjectId, timestamp: i64, message: &str) -> ReflogEntry {
5031        ReflogEntry {
5032            old_oid: zero_oid(new_oid.format()).expect("test operation should succeed"),
5033            new_oid: *new_oid,
5034            committer: format!("Git Rs <sley@example.invalid> {timestamp} +0000").into_bytes(),
5035            message: message.as_bytes().to_vec(),
5036        }
5037    }
5038
5039    #[test]
5040    fn expire_reflog_drops_old_entries_and_keeps_latest() {
5041        let oid_a = ObjectId::from_hex(
5042            ObjectFormat::Sha1,
5043            "ce013625030ba8dba906f756967f9e9ca394464a",
5044        )
5045        .expect("test operation should succeed");
5046        let oid_b = ObjectId::from_hex(
5047            ObjectFormat::Sha1,
5048            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5049        )
5050        .expect("test operation should succeed");
5051        let oid_c = ObjectId::from_hex(
5052            ObjectFormat::Sha1,
5053            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5054        )
5055        .expect("test operation should succeed");
5056        let entries = vec![
5057            reflog_entry(&oid_a, 10, "oldest"),
5058            reflog_entry(&oid_b, 100, "middle"),
5059            reflog_entry(&oid_c, 20, "latest"),
5060        ];
5061
5062        // Cutoff drops the oldest entry; the most recent entry survives even
5063        // though its timestamp (20) is below the cutoff (50).
5064        let retained =
5065            expire_reflog(&entries, 50, None, |_| true).expect("test operation should succeed");
5066        assert_eq!(retained.len(), 2);
5067        assert_eq!(retained[0].message, b"middle");
5068        assert_eq!(retained[1].message, b"latest");
5069    }
5070
5071    #[test]
5072    fn expire_reflog_applies_stricter_unreachable_cutoff() {
5073        let reachable = ObjectId::from_hex(
5074            ObjectFormat::Sha1,
5075            "ce013625030ba8dba906f756967f9e9ca394464a",
5076        )
5077        .expect("test operation should succeed");
5078        let unreachable = ObjectId::from_hex(
5079            ObjectFormat::Sha1,
5080            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5081        )
5082        .expect("test operation should succeed");
5083        let tip = ObjectId::from_hex(
5084            ObjectFormat::Sha1,
5085            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5086        )
5087        .expect("test operation should succeed");
5088        // Both candidate entries sit above the lenient cutoff (50) but below the
5089        // stricter unreachable cutoff (150). Only the unreachable one is dropped.
5090        let entries = vec![
5091            reflog_entry(&reachable, 100, "reachable"),
5092            reflog_entry(&unreachable, 100, "unreachable"),
5093            reflog_entry(&tip, 200, "tip"),
5094        ];
5095        let retained = expire_reflog(&entries, 50, Some(150), |oid| {
5096            oid == &reachable || oid == &tip
5097        })
5098        .expect("test operation should succeed");
5099        assert_eq!(retained.len(), 2);
5100        assert_eq!(retained[0].message, b"reachable");
5101        assert_eq!(retained[1].message, b"tip");
5102    }
5103
5104    #[test]
5105    fn expire_reflog_keeps_single_entry_below_cutoff() {
5106        let oid = ObjectId::from_hex(
5107            ObjectFormat::Sha1,
5108            "ce013625030ba8dba906f756967f9e9ca394464a",
5109        )
5110        .expect("test operation should succeed");
5111        let entries = vec![reflog_entry(&oid, 1, "only")];
5112        let retained = expire_reflog(&entries, i64::MAX, Some(i64::MAX), |_| false)
5113            .expect("test operation should succeed");
5114        assert_eq!(retained.len(), 1);
5115        assert_eq!(retained[0].message, b"only");
5116    }
5117
5118    #[test]
5119    fn file_ref_store_expire_reflog_file_rewrites_and_dry_runs() {
5120        let git_dir = temp_git_dir();
5121        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5122        let first = ObjectId::from_hex(
5123            ObjectFormat::Sha1,
5124            "ce013625030ba8dba906f756967f9e9ca394464a",
5125        )
5126        .expect("test operation should succeed");
5127        let second = ObjectId::from_hex(
5128            ObjectFormat::Sha1,
5129            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5130        )
5131        .expect("test operation should succeed");
5132        store
5133            .write_reflog(
5134                "refs/heads/main",
5135                &[
5136                    reflog_entry(&first, 10, "old"),
5137                    reflog_entry(&second, 100, "new"),
5138                ],
5139            )
5140            .expect("test operation should succeed");
5141
5142        // Dry run reports the removal count without touching the file.
5143        let would_remove = store
5144            .expire_reflog_file("refs/heads/main", 50, None, false, |_| true)
5145            .expect("test operation should succeed");
5146        assert_eq!(would_remove, 1);
5147        assert_eq!(
5148            store
5149                .read_reflog("refs/heads/main")
5150                .expect("test operation should succeed")
5151                .len(),
5152            2
5153        );
5154
5155        // Opt-in rewrite drops the stale entry and leaves the latest.
5156        let removed = store
5157            .expire_reflog_file("refs/heads/main", 50, None, true, |_| true)
5158            .expect("test operation should succeed");
5159        assert_eq!(removed, 1);
5160        let log = store
5161            .read_reflog("refs/heads/main")
5162            .expect("test operation should succeed");
5163        assert_eq!(log.len(), 1);
5164        assert_eq!(log[0].new_oid, second);
5165        assert!(
5166            !git_dir
5167                .join("logs")
5168                .join("refs")
5169                .join("heads")
5170                .join("main.lock")
5171                .exists()
5172        );
5173        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5174    }
5175
5176    #[test]
5177    fn file_ref_transaction_commits_all_refs_atomically() {
5178        let git_dir = temp_git_dir();
5179        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5180        let main_oid = ObjectId::from_hex(
5181            ObjectFormat::Sha1,
5182            "ce013625030ba8dba906f756967f9e9ca394464a",
5183        )
5184        .expect("test operation should succeed");
5185        let topic_oid = ObjectId::from_hex(
5186            ObjectFormat::Sha1,
5187            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5188        )
5189        .expect("test operation should succeed");
5190        let tag_oid = ObjectId::from_hex(
5191            ObjectFormat::Sha1,
5192            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5193        )
5194        .expect("test operation should succeed");
5195        let mut tx = store.transaction();
5196        tx.update(RefUpdate {
5197            name: "refs/heads/main".into(),
5198            expected: None,
5199            new: RefTarget::Direct(main_oid),
5200            reflog: Some(reflog_entry(&main_oid, 0, "create main")),
5201        });
5202        tx.update(RefUpdate {
5203            name: "refs/heads/topic".into(),
5204            expected: None,
5205            new: RefTarget::Direct(topic_oid),
5206            reflog: None,
5207        });
5208        tx.update(RefUpdate {
5209            name: "refs/tags/v1.0".into(),
5210            expected: None,
5211            new: RefTarget::Direct(tag_oid),
5212            reflog: None,
5213        });
5214        tx.commit().expect("test operation should succeed");
5215
5216        assert_eq!(
5217            store
5218                .read_ref("refs/heads/main")
5219                .expect("test operation should succeed"),
5220            Some(RefTarget::Direct(main_oid))
5221        );
5222        assert_eq!(
5223            store
5224                .read_ref("refs/heads/topic")
5225                .expect("test operation should succeed"),
5226            Some(RefTarget::Direct(topic_oid))
5227        );
5228        assert_eq!(
5229            store
5230                .read_ref("refs/tags/v1.0")
5231                .expect("test operation should succeed"),
5232            Some(RefTarget::Direct(tag_oid))
5233        );
5234        let main_log = store
5235            .read_reflog("refs/heads/main")
5236            .expect("test operation should succeed");
5237        assert_eq!(main_log.len(), 1);
5238        assert_eq!(main_log[0].new_oid, main_oid);
5239        // No lock files survive a successful commit.
5240        assert!(
5241            !git_dir
5242                .join("refs")
5243                .join("heads")
5244                .join("main.lock")
5245                .exists()
5246        );
5247        assert!(
5248            !git_dir
5249                .join("refs")
5250                .join("heads")
5251                .join("topic.lock")
5252                .exists()
5253        );
5254        assert!(!git_dir.join("refs").join("tags").join("v1.0.lock").exists());
5255        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5256    }
5257
5258    #[test]
5259    fn file_ref_transaction_rolls_back_all_refs_on_expected_mismatch() {
5260        let git_dir = temp_git_dir();
5261        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5262        let old_topic = ObjectId::from_hex(
5263            ObjectFormat::Sha1,
5264            "ce013625030ba8dba906f756967f9e9ca394464a",
5265        )
5266        .expect("test operation should succeed");
5267        let new_main = ObjectId::from_hex(
5268            ObjectFormat::Sha1,
5269            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5270        )
5271        .expect("test operation should succeed");
5272        let new_tag = ObjectId::from_hex(
5273            ObjectFormat::Sha1,
5274            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5275        )
5276        .expect("test operation should succeed");
5277        let wrong_expected = ObjectId::from_hex(
5278            ObjectFormat::Sha1,
5279            "0000000000000000000000000000000000000001",
5280        )
5281        .expect("test operation should succeed");
5282
5283        // Seed an existing topic ref so the failing update has a real prior value
5284        // to be compared against (and left untouched).
5285        let mut seed = store.transaction();
5286        seed.update(RefUpdate {
5287            name: "refs/heads/topic".into(),
5288            expected: None,
5289            new: RefTarget::Direct(old_topic.clone()),
5290            reflog: None,
5291        });
5292        seed.commit().expect("test operation should succeed");
5293
5294        let mut tx = store.transaction();
5295        // 1st ref: brand new, would succeed in isolation.
5296        tx.update(RefUpdate {
5297            name: "refs/heads/main".into(),
5298            expected: None,
5299            new: RefTarget::Direct(new_main.clone()),
5300            reflog: Some(reflog_entry(&new_main, 0, "create main")),
5301        });
5302        // 2nd ref: expected value does not match on disk -> whole tx must abort.
5303        tx.update(RefUpdate {
5304            name: "refs/heads/topic".into(),
5305            expected: Some(RefTarget::Direct(wrong_expected)),
5306            new: RefTarget::Direct(new_main.clone()),
5307            reflog: None,
5308        });
5309        // 3rd ref: brand new, must not be written because the tx aborts.
5310        tx.update(RefUpdate {
5311            name: "refs/tags/v1.0".into(),
5312            expected: None,
5313            new: RefTarget::Direct(new_tag),
5314            reflog: None,
5315        });
5316        let result = tx.commit();
5317        assert!(result.is_err());
5318
5319        // Nothing changed: the new refs were never created and the existing one
5320        // keeps its original value.
5321        assert_eq!(
5322            store
5323                .read_ref("refs/heads/main")
5324                .expect("test operation should succeed"),
5325            None
5326        );
5327        assert_eq!(
5328            store
5329                .read_ref("refs/heads/topic")
5330                .expect("test operation should succeed"),
5331            Some(RefTarget::Direct(old_topic))
5332        );
5333        assert_eq!(
5334            store
5335                .read_ref("refs/tags/v1.0")
5336                .expect("test operation should succeed"),
5337            None
5338        );
5339        assert!(
5340            store
5341                .read_reflog("refs/heads/main")
5342                .expect("test operation should succeed")
5343                .is_empty()
5344        );
5345
5346        // All lock files were released.
5347        assert!(
5348            !git_dir
5349                .join("refs")
5350                .join("heads")
5351                .join("main.lock")
5352                .exists()
5353        );
5354        assert!(
5355            !git_dir
5356                .join("refs")
5357                .join("heads")
5358                .join("topic.lock")
5359                .exists()
5360        );
5361        assert!(!git_dir.join("refs").join("tags").join("v1.0.lock").exists());
5362        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5363    }
5364
5365    #[test]
5366    fn file_ref_transaction_mixes_update_and_delete() {
5367        let git_dir = temp_git_dir();
5368        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5369        let old_main = ObjectId::from_hex(
5370            ObjectFormat::Sha1,
5371            "ce013625030ba8dba906f756967f9e9ca394464a",
5372        )
5373        .expect("test operation should succeed");
5374        let new_topic = ObjectId::from_hex(
5375            ObjectFormat::Sha1,
5376            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5377        )
5378        .expect("test operation should succeed");
5379        let mut seed = store.transaction();
5380        seed.update(RefUpdate {
5381            name: "refs/heads/main".into(),
5382            expected: None,
5383            new: RefTarget::Direct(old_main),
5384            reflog: None,
5385        });
5386        seed.commit().expect("test operation should succeed");
5387
5388        let mut tx = store.transaction();
5389        tx.update(RefUpdate {
5390            name: "refs/heads/topic".into(),
5391            expected: None,
5392            new: RefTarget::Direct(new_topic),
5393            reflog: None,
5394        });
5395        tx.delete_with_precondition(
5396            "refs/heads/main",
5397            RefDeletePrecondition::Direct(Some(old_main)),
5398            None,
5399        );
5400        tx.commit().expect("test operation should succeed");
5401
5402        assert_eq!(
5403            store
5404                .read_ref("refs/heads/main")
5405                .expect("test operation should succeed"),
5406            None
5407        );
5408        assert_eq!(
5409            store
5410                .read_ref("refs/heads/topic")
5411                .expect("test operation should succeed"),
5412            Some(RefTarget::Direct(new_topic))
5413        );
5414        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5415    }
5416
5417    #[test]
5418    fn file_ref_transaction_stale_delete_rolls_back_update() {
5419        let git_dir = temp_git_dir();
5420        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5421        let old_oid = ObjectId::from_hex(
5422            ObjectFormat::Sha1,
5423            "ce013625030ba8dba906f756967f9e9ca394464a",
5424        )
5425        .expect("test operation should succeed");
5426        let new_oid = ObjectId::from_hex(
5427            ObjectFormat::Sha1,
5428            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5429        )
5430        .expect("test operation should succeed");
5431        let mut seed = store.transaction();
5432        for name in ["refs/heads/main", "refs/heads/topic"] {
5433            seed.update(RefUpdate {
5434                name: name.into(),
5435                expected: None,
5436                new: RefTarget::Direct(old_oid),
5437                reflog: None,
5438            });
5439        }
5440        seed.commit().expect("test operation should succeed");
5441
5442        let mut tx = store.transaction();
5443        tx.update(RefUpdate {
5444            name: "refs/heads/topic".into(),
5445            expected: None,
5446            new: RefTarget::Direct(new_oid),
5447            reflog: None,
5448        });
5449        tx.delete_with_precondition(
5450            "refs/heads/main",
5451            RefDeletePrecondition::Direct(Some(new_oid)),
5452            None,
5453        );
5454        let err = tx.commit().expect_err("stale delete must abort");
5455        assert!(err.to_string().contains("expected ref refs/heads/main"));
5456
5457        assert_eq!(
5458            store
5459                .read_ref("refs/heads/main")
5460                .expect("test operation should succeed"),
5461            Some(RefTarget::Direct(old_oid))
5462        );
5463        assert_eq!(
5464            store
5465                .read_ref("refs/heads/topic")
5466                .expect("test operation should succeed"),
5467            Some(RefTarget::Direct(old_oid))
5468        );
5469        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5470    }
5471
5472    #[test]
5473    fn file_ref_transaction_rejects_duplicate_mixed_ref() {
5474        let git_dir = temp_git_dir();
5475        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5476        let oid = ObjectId::from_hex(
5477            ObjectFormat::Sha1,
5478            "ce013625030ba8dba906f756967f9e9ca394464a",
5479        )
5480        .expect("test operation should succeed");
5481        let mut tx = store.transaction();
5482        tx.update(RefUpdate {
5483            name: "refs/heads/main".into(),
5484            expected: None,
5485            new: RefTarget::Direct(oid),
5486            reflog: None,
5487        });
5488        tx.delete_with_precondition("refs/heads/main", RefDeletePrecondition::Any, None);
5489
5490        let err = tx.commit().expect_err("duplicate ref must fail");
5491        assert!(err.to_string().contains("refs/heads/main"));
5492        assert_eq!(
5493            store
5494                .read_ref("refs/heads/main")
5495                .expect("test operation should succeed"),
5496            None
5497        );
5498        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5499    }
5500
5501    #[test]
5502    fn file_ref_transaction_deletes_symbolic_ref_with_immediate_expectation() {
5503        let git_dir = temp_git_dir();
5504        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5505        let oid = ObjectId::from_hex(
5506            ObjectFormat::Sha1,
5507            "ce013625030ba8dba906f756967f9e9ca394464a",
5508        )
5509        .expect("test operation should succeed");
5510        let mut seed = store.transaction();
5511        seed.update(RefUpdate {
5512            name: "refs/heads/main".into(),
5513            expected: None,
5514            new: RefTarget::Direct(oid),
5515            reflog: None,
5516        });
5517        seed.update(RefUpdate {
5518            name: "refs/aliases/main".into(),
5519            expected: None,
5520            new: RefTarget::Symbolic("refs/heads/main".into()),
5521            reflog: None,
5522        });
5523        seed.commit().expect("test operation should succeed");
5524
5525        let mut tx = store.transaction();
5526        tx.delete_with_precondition(
5527            "refs/aliases/main",
5528            RefDeletePrecondition::Immediate(RefTarget::Symbolic("refs/heads/main".into())),
5529            None,
5530        );
5531        tx.commit().expect("test operation should succeed");
5532
5533        assert_eq!(
5534            store
5535                .read_ref("refs/aliases/main")
5536                .expect("test operation should succeed"),
5537            None
5538        );
5539        assert_eq!(
5540            store
5541                .read_ref("refs/heads/main")
5542                .expect("test operation should succeed"),
5543            Some(RefTarget::Direct(oid))
5544        );
5545        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5546    }
5547
5548    #[test]
5549    fn file_ref_transaction_rolls_back_delete_after_late_write_failure() {
5550        let git_dir = temp_git_dir();
5551        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5552        let old_oid = ObjectId::from_hex(
5553            ObjectFormat::Sha1,
5554            "ce013625030ba8dba906f756967f9e9ca394464a",
5555        )
5556        .expect("test operation should succeed");
5557        let new_oid = ObjectId::from_hex(
5558            ObjectFormat::Sha1,
5559            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5560        )
5561        .expect("test operation should succeed");
5562        let mut seed = store.transaction();
5563        for name in ["refs/heads/main", "refs/heads/topic"] {
5564            seed.update(RefUpdate {
5565                name: name.into(),
5566                expected: None,
5567                new: RefTarget::Direct(old_oid),
5568                reflog: None,
5569            });
5570        }
5571        seed.commit().expect("test operation should succeed");
5572
5573        set_fail_loose_commit_action_for_test(Some(1));
5574        let mut tx = store.transaction();
5575        tx.delete_with_precondition(
5576            "refs/heads/main",
5577            RefDeletePrecondition::Direct(Some(old_oid)),
5578            None,
5579        );
5580        tx.update(RefUpdate {
5581            name: "refs/heads/topic".into(),
5582            expected: None,
5583            new: RefTarget::Direct(new_oid),
5584            reflog: None,
5585        });
5586        let err = tx.commit().expect_err("injected failure must abort");
5587        assert!(
5588            err.to_string()
5589                .contains("injected loose ref transaction failure")
5590        );
5591
5592        assert_eq!(
5593            store
5594                .read_ref("refs/heads/main")
5595                .expect("test operation should succeed"),
5596            Some(RefTarget::Direct(old_oid))
5597        );
5598        assert_eq!(
5599            store
5600                .read_ref("refs/heads/topic")
5601                .expect("test operation should succeed"),
5602            Some(RefTarget::Direct(old_oid))
5603        );
5604        assert!(
5605            !git_dir
5606                .join("refs")
5607                .join("heads")
5608                .join("main.lock")
5609                .exists()
5610        );
5611        assert!(
5612            !git_dir
5613                .join("refs")
5614                .join("heads")
5615                .join("topic.lock")
5616                .exists()
5617        );
5618        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5619    }
5620
5621    fn temp_git_dir() -> PathBuf {
5622        let path = std::env::temp_dir().join(format!(
5623            "sley-refs-{}-{}",
5624            std::process::id(),
5625            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
5626        ));
5627        fs::create_dir_all(&path).expect("test operation should succeed");
5628        path
5629    }
5630
5631    fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
5632        Ok(ObjectId::null(format))
5633    }
5634
5635    fn write_reftable_config(git_dir: &Path) {
5636        fs::write(
5637            git_dir.join("config"),
5638            b"[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefStorage = reftable\n",
5639        )
5640        .expect("test operation should succeed");
5641    }
5642
5643    fn write_reftable_stack(
5644        git_dir: &Path,
5645        tables: &[(&str, Vec<sley_formats::ReftableRefRecord>)],
5646    ) {
5647        let reftable_dir = git_dir.join("reftable");
5648        fs::create_dir_all(&reftable_dir).expect("test operation should succeed");
5649        let mut list = String::new();
5650        for (idx, (name, refs)) in tables.iter().enumerate() {
5651            let update_index = (idx + 1) as u64;
5652            let bytes = sley_formats::Reftable::write_ref_only(
5653                ObjectFormat::Sha1,
5654                update_index,
5655                update_index,
5656                refs,
5657            )
5658            .expect("test operation should succeed");
5659            fs::write(reftable_dir.join(name), bytes).expect("test operation should succeed");
5660            list.push_str(name);
5661            list.push('\n');
5662        }
5663        fs::write(reftable_dir.join("tables.list"), list).expect("test operation should succeed");
5664    }
5665}