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::{
8    Reftable, ReftableLogRecord, ReftableLogUpdate, ReftableLogValue, ReftableRefRecord,
9    ReftableRefValue,
10};
11use std::borrow::Borrow;
12use std::collections::{BTreeMap, BTreeSet, HashMap};
13use std::env;
14use std::fmt;
15use std::fs;
16use std::io::Write;
17use std::ops::Deref;
18use std::path::{Path, PathBuf};
19use std::thread;
20use std::time::Duration;
21use std::time::{SystemTime, UNIX_EPOCH};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum RefTarget {
25    Direct(ObjectId),
26    Symbolic(String),
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Ref {
31    pub name: String,
32    pub target: RefTarget,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RefDelete {
37    pub name: String,
38    pub oid: ObjectId,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DeleteRef {
43    pub name: String,
44    pub expected_old: Option<ObjectId>,
45    pub reflog: Option<DeleteRefReflog>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct DeleteRefReflog {
50    pub committer: Vec<u8>,
51    pub message: Vec<u8>,
52}
53
54#[derive(Debug)]
55pub enum RefDeleteError {
56    NotFound,
57    ExpectedMismatch {
58        expected: Option<ObjectId>,
59        actual: Option<ObjectId>,
60    },
61    Locked,
62    InvalidName,
63    Io(std::io::Error),
64}
65
66impl fmt::Display for RefDeleteError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Self::NotFound => f.write_str("ref not found"),
70            Self::ExpectedMismatch { expected, actual } => {
71                write!(
72                    f,
73                    "ref expected old oid mismatch: expected {:?}, actual {:?}",
74                    expected, actual
75                )
76            }
77            Self::Locked => f.write_str("ref is locked"),
78            Self::InvalidName => f.write_str("invalid ref name"),
79            Self::Io(err) => write!(f, "io error: {err}"),
80        }
81    }
82}
83
84impl std::error::Error for RefDeleteError {}
85
86impl From<std::io::Error> for RefDeleteError {
87    fn from(value: std::io::Error) -> Self {
88        Self::Io(value)
89    }
90}
91
92/// Parse the leading object id of a loose ref body, tolerating any trailing
93/// content that begins with whitespace (git's `parse_loose_ref_contents`).
94fn parse_leading_oid(format: ObjectFormat, value: &str) -> Option<ObjectId> {
95    let hexsz = format.hex_len();
96    let bytes = value.as_bytes();
97    if bytes.len() < hexsz {
98        return None;
99    }
100    if bytes.len() > hexsz && !bytes[hexsz].is_ascii_whitespace() {
101        return None;
102    }
103    ObjectId::from_hex(format, &value[..hexsz]).ok()
104}
105
106pub fn parse_loose_ref(format: ObjectFormat, name: impl Into<String>, bytes: &[u8]) -> Result<Ref> {
107    let name = name.into();
108    let value = std::str::from_utf8(bytes)
109        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
110        .trim_end_matches('\n');
111    if name == "FETCH_HEAD" {
112        let oid = value
113            .lines()
114            .find_map(|line| line.split_whitespace().next())
115            .ok_or_else(|| GitError::InvalidFormat("FETCH_HEAD is empty".into()))?;
116        return Ok(Ref {
117            name,
118            target: RefTarget::Direct(ObjectId::from_hex(format, oid)?),
119        });
120    }
121    let target = if let Some(symbolic) = value.strip_prefix("ref: ") {
122        RefTarget::Symbolic(symbolic.to_string())
123    } else {
124        // git's parse_loose_ref_contents reads the leading <hexsz> hex digits
125        // and tolerates trailing content as long as it begins with whitespace
126        // (a bare "<oid> garbage" still resolves to <oid>; `refs verify` flags
127        // the trailing separately as trailingRefContent).
128        let oid = parse_leading_oid(format, value).ok_or_else(|| {
129            GitError::InvalidFormat(format!(
130                "reference {name} has neither a valid OID nor a target"
131            ))
132        })?;
133        RefTarget::Direct(oid)
134    };
135    Ok(Ref { name, target })
136}
137
138pub fn write_loose_ref(reference: &Ref) -> Vec<u8> {
139    match &reference.target {
140        RefTarget::Direct(oid) => format!("{oid}\n").into_bytes(),
141        RefTarget::Symbolic(target) => format!("ref: {target}\n").into_bytes(),
142    }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct PackedRef {
147    pub reference: Ref,
148    pub peeled: Option<ObjectId>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum PackRefDecision {
153    Pack { peeled: Option<ObjectId> },
154    Skip,
155}
156
157pub fn parse_packed_refs(format: ObjectFormat, bytes: &[u8]) -> Result<Vec<PackedRef>> {
158    parse_packed_refs_filtered(format, bytes, |_| true)
159}
160
161fn parse_packed_refs_with_prefix(
162    format: ObjectFormat,
163    bytes: &[u8],
164    prefix: &str,
165) -> Result<Vec<PackedRef>> {
166    if !bytes.is_empty() && !bytes.ends_with(b"\n") {
167        let line_start = bytes
168            .iter()
169            .rposition(|byte| *byte == b'\n')
170            .map_or(0, |index| index + 1);
171        let line = String::from_utf8_lossy(&bytes[line_start..]);
172        return Err(GitError::InvalidFormat(format!(
173            "fatal: unterminated line in .git/packed-refs: {line}"
174        )));
175    }
176    let text =
177        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
178    let mut refs: Vec<PackedRef> = Vec::new();
179    let mut saw_ref = false;
180    let mut included_last_ref = false;
181    for raw_line in text.lines() {
182        let line = raw_line.trim_end();
183        if line.is_empty() || line.starts_with('#') {
184            continue;
185        }
186        if let Some(peeled) = line.strip_prefix('^') {
187            if !saw_ref {
188                return Err(GitError::InvalidFormat(
189                    "peeled packed ref without preceding ref".into(),
190                ));
191            }
192            if included_last_ref {
193                let oid = ObjectId::from_hex(format, peeled)?;
194                if let Some(last) = refs.last_mut() {
195                    last.peeled = Some(oid);
196                }
197            }
198            continue;
199        }
200        let (oid, name) = line
201            .split_once(' ')
202            .ok_or_else(|| packed_refs_unexpected_line(line))?;
203        // git validates EVERY packed-refs line as it reads the file, regardless
204        // of whether the ref matches the caller's prefix; a malformed line
205        // anywhere aborts with "unexpected line in .git/packed-refs" (t1463
206        // "reject packed-refs containing junk"). So validate before applying the
207        // prefix filter — skipping validation for non-matching lines would let
208        // junk pass silently.
209        if oid.len() != format.hex_len()
210            || !oid.bytes().all(|byte| byte.is_ascii_hexdigit())
211            || validate_ref_name_for_read(name).is_err()
212        {
213            return Err(packed_refs_unexpected_line(line));
214        }
215        saw_ref = true;
216        included_last_ref = name.starts_with(prefix);
217        if !included_last_ref {
218            continue;
219        }
220        let oid = ObjectId::from_hex(format, oid)?;
221        refs.push(PackedRef {
222            reference: Ref {
223                name: name.into(),
224                target: RefTarget::Direct(oid),
225            },
226            peeled: None,
227        });
228    }
229    Ok(refs)
230}
231
232fn parse_packed_refs_filtered(
233    format: ObjectFormat,
234    bytes: &[u8],
235    mut include: impl FnMut(&str) -> bool,
236) -> Result<Vec<PackedRef>> {
237    if !bytes.is_empty() && !bytes.ends_with(b"\n") {
238        let line_start = bytes
239            .iter()
240            .rposition(|byte| *byte == b'\n')
241            .map_or(0, |index| index + 1);
242        let line = String::from_utf8_lossy(&bytes[line_start..]);
243        return Err(GitError::InvalidFormat(format!(
244            "fatal: unterminated line in .git/packed-refs: {line}"
245        )));
246    }
247    let text =
248        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
249    let mut refs: Vec<PackedRef> = Vec::new();
250    let mut saw_ref = false;
251    let mut included_last_ref = false;
252    for raw_line in text.lines() {
253        let line = raw_line.trim_end();
254        if line.is_empty() || line.starts_with('#') {
255            continue;
256        }
257        if let Some(peeled) = line.strip_prefix('^') {
258            let oid = ObjectId::from_hex(format, peeled)?;
259            if !saw_ref {
260                return Err(GitError::InvalidFormat(
261                    "peeled packed ref without preceding ref".into(),
262                ));
263            }
264            if included_last_ref && let Some(last) = refs.last_mut() {
265                last.peeled = Some(oid);
266            }
267            continue;
268        }
269        let (oid, name) = line
270            .split_once(' ')
271            .ok_or_else(|| packed_refs_unexpected_line(line))?;
272        if oid.len() != format.hex_len()
273            || !oid.bytes().all(|byte| byte.is_ascii_hexdigit())
274            || validate_ref_name_for_read(name).is_err()
275        {
276            return Err(packed_refs_unexpected_line(line));
277        }
278        let oid = ObjectId::from_hex(format, oid)?;
279        saw_ref = true;
280        included_last_ref = include(name);
281        if included_last_ref {
282            refs.push(PackedRef {
283                reference: Ref {
284                    name: name.into(),
285                    target: RefTarget::Direct(oid),
286                },
287                peeled: None,
288            });
289        }
290    }
291    Ok(refs)
292}
293
294fn packed_ref_names_with_prefix(
295    format: ObjectFormat,
296    bytes: &[u8],
297    prefix: &str,
298    strip_prefix: bool,
299    names: &mut Vec<String>,
300) -> Result<()> {
301    if !bytes.is_empty() && !bytes.ends_with(b"\n") {
302        let line_start = bytes
303            .iter()
304            .rposition(|byte| *byte == b'\n')
305            .map_or(0, |index| index + 1);
306        let line = String::from_utf8_lossy(&bytes[line_start..]);
307        return Err(GitError::InvalidFormat(format!(
308            "fatal: unterminated line in .git/packed-refs: {line}"
309        )));
310    }
311    let text =
312        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
313    let mut saw_ref = false;
314    for raw_line in text.lines() {
315        let line = raw_line.trim_end();
316        if line.is_empty() || line.starts_with('#') {
317            continue;
318        }
319        if line.starts_with('^') {
320            if !saw_ref {
321                return Err(GitError::InvalidFormat(
322                    "peeled packed ref without preceding ref".into(),
323                ));
324            }
325            continue;
326        }
327        let (oid, name) = line
328            .split_once(' ')
329            .ok_or_else(|| packed_refs_unexpected_line(line))?;
330        // Validate every line before the prefix filter (see
331        // `parse_packed_refs_with_prefix`): git rejects a malformed packed-refs
332        // file even when the junk line is outside the requested prefix.
333        if oid.len() != format.hex_len()
334            || !oid.bytes().all(|byte| byte.is_ascii_hexdigit())
335            || validate_ref_name_for_read(name).is_err()
336        {
337            return Err(packed_refs_unexpected_line(line));
338        }
339        saw_ref = true;
340        if !name.starts_with(prefix) {
341            continue;
342        }
343        let name = if strip_prefix {
344            &name[prefix.len()..]
345        } else {
346            name
347        };
348        names.push(name.to_owned());
349    }
350    Ok(())
351}
352
353fn packed_refs_unexpected_line(line: &str) -> GitError {
354    GitError::InvalidFormat(format!(
355        "fatal: unexpected line in .git/packed-refs: {line}"
356    ))
357}
358
359fn packed_refs_have_prefix(format: ObjectFormat, bytes: &[u8], prefix: &str) -> Result<bool> {
360    if !bytes.is_empty() && !bytes.ends_with(b"\n") {
361        let line_start = bytes
362            .iter()
363            .rposition(|byte| *byte == b'\n')
364            .map_or(0, |index| index + 1);
365        let line = String::from_utf8_lossy(&bytes[line_start..]);
366        return Err(GitError::InvalidFormat(format!(
367            "fatal: unterminated line in .git/packed-refs: {line}"
368        )));
369    }
370    let text =
371        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
372    let mut found = false;
373    let mut saw_ref = false;
374    for raw_line in text.lines() {
375        let line = raw_line.trim_end();
376        if line.is_empty() || line.starts_with('#') {
377            continue;
378        }
379        if let Some(peeled) = line.strip_prefix('^') {
380            ObjectId::from_hex(format, peeled)?;
381            if !saw_ref {
382                return Err(GitError::InvalidFormat(
383                    "peeled packed ref without preceding ref".into(),
384                ));
385            }
386            continue;
387        }
388        let (oid, name) = line
389            .split_once(' ')
390            .ok_or_else(|| packed_refs_unexpected_line(line))?;
391        if oid.len() != format.hex_len()
392            || !oid.bytes().all(|byte| byte.is_ascii_hexdigit())
393            || validate_ref_name_for_read(name).is_err()
394        {
395            return Err(packed_refs_unexpected_line(line));
396        }
397        ObjectId::from_hex(format, oid)?;
398        saw_ref = true;
399        found |= name.starts_with(prefix);
400    }
401    Ok(found)
402}
403
404pub fn write_packed_refs(refs: &[PackedRef]) -> Result<Vec<u8>> {
405    let mut refs = refs.to_vec();
406    refs.sort_by(|left, right| left.reference.name.cmp(&right.reference.name));
407    let mut out = b"# pack-refs with: peeled fully-peeled sorted \n".to_vec();
408    for packed in refs {
409        validate_ref_name(&packed.reference.name)?;
410        let RefTarget::Direct(oid) = &packed.reference.target else {
411            return Err(GitError::InvalidFormat(format!(
412                "packed ref {} is symbolic",
413                packed.reference.name
414            )));
415        };
416        out.extend_from_slice(oid.to_hex().as_bytes());
417        out.push(b' ');
418        out.extend_from_slice(packed.reference.name.as_bytes());
419        out.push(b'\n');
420        if let Some(peeled) = packed.peeled {
421            out.push(b'^');
422            out.extend_from_slice(peeled.to_hex().as_bytes());
423            out.push(b'\n');
424        }
425    }
426    Ok(out)
427}
428
429#[derive(Debug, Clone, PartialEq, Eq)]
430pub struct ReflogEntry {
431    pub old_oid: ObjectId,
432    pub new_oid: ObjectId,
433    pub committer: Vec<u8>,
434    pub message: Vec<u8>,
435}
436
437impl ReflogEntry {
438    pub fn to_line(&self) -> Vec<u8> {
439        let mut out = Vec::new();
440        out.extend_from_slice(self.old_oid.to_hex().as_bytes());
441        out.push(b' ');
442        out.extend_from_slice(self.new_oid.to_hex().as_bytes());
443        out.push(b' ');
444        out.extend_from_slice(&self.committer);
445        if !self.message.is_empty() {
446            out.push(b'\t');
447            out.extend_from_slice(&self.message);
448        }
449        out.push(b'\n');
450        out
451    }
452
453    pub fn timestamp_seconds(&self) -> Result<i64> {
454        let committer = std::str::from_utf8(&self.committer)
455            .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
456        let Some((before_tz, _tz)) = committer.rsplit_once(' ') else {
457            return Err(GitError::InvalidFormat(
458                "reflog committer is missing timezone".into(),
459            ));
460        };
461        let Some((_identity, timestamp)) = before_tz.rsplit_once(' ') else {
462            return Err(GitError::InvalidFormat(
463                "reflog committer is missing timestamp".into(),
464            ));
465        };
466        timestamp
467            .parse::<i64>()
468            .map_err(|err| GitError::InvalidFormat(err.to_string()))
469    }
470}
471
472pub fn parse_reflog(format: ObjectFormat, bytes: &[u8]) -> Result<Vec<ReflogEntry>> {
473    let text =
474        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
475    let mut entries = Vec::new();
476    for line in text.lines() {
477        let mut parts = line.splitn(3, ' ');
478        let old = parts
479            .next()
480            .ok_or_else(|| GitError::InvalidFormat("missing reflog old oid".into()))?;
481        let new = parts
482            .next()
483            .ok_or_else(|| GitError::InvalidFormat("missing reflog new oid".into()))?;
484        let rest = parts
485            .next()
486            .ok_or_else(|| GitError::InvalidFormat("missing reflog committer".into()))?;
487        let (committer, message) = rest.split_once('\t').unwrap_or((rest, ""));
488        entries.push(ReflogEntry {
489            old_oid: ObjectId::from_hex(format, old)?,
490            new_oid: ObjectId::from_hex(format, new)?,
491            committer: committer.as_bytes().to_vec(),
492            message: message.as_bytes().to_vec(),
493        });
494    }
495    Ok(entries)
496}
497
498/// Expire reflog entries, mirroring `git reflog expire` semantics.
499///
500/// Entries are kept when their committer timestamp is at or after `cutoff_unix`.
501/// Entries whose `new_oid` is unreachable (per `is_reachable`) are held to the
502/// stricter `expire_unreachable_cutoff` when one is supplied: such an entry is
503/// dropped when its timestamp falls below either cutoff. When
504/// `expire_unreachable_cutoff` is `None`, reachability does not relax the single
505/// `cutoff_unix` bound.
506///
507/// The most recent entry (the one describing the ref's current value) is always
508/// preserved, exactly as git refuses to expire the tip of a reflog, even when it
509/// is older than the cutoff. Relative order of the surviving entries is kept.
510///
511/// This is a pure function over already-parsed entries so callers can read,
512/// filter, and rewrite reflogs however they like; see
513/// [`FileRefStore::expire_reflog_file`] for a filesystem convenience built on top
514/// of it.
515pub fn expire_reflog(
516    entries: &[ReflogEntry],
517    cutoff_unix: i64,
518    expire_unreachable_cutoff: Option<i64>,
519    is_reachable: impl Fn(&ObjectId) -> bool,
520) -> Result<Vec<ReflogEntry>> {
521    let last_index = entries.len().checked_sub(1);
522    let mut retained = Vec::with_capacity(entries.len());
523    for (index, entry) in entries.iter().enumerate() {
524        // Always keep the most recent entry: it records the current ref value
525        // and git never expires it.
526        if Some(index) == last_index {
527            retained.push(entry.clone());
528            continue;
529        }
530        let timestamp = entry.timestamp_seconds()?;
531        let mut expired = timestamp < cutoff_unix;
532        if let Some(unreachable_cutoff) = expire_unreachable_cutoff
533            && !is_reachable(&entry.new_oid)
534        {
535            expired = expired || timestamp < unreachable_cutoff;
536        }
537        if !expired {
538            retained.push(entry.clone());
539        }
540    }
541    Ok(retained)
542}
543
544#[derive(Debug, Default, Clone)]
545pub struct RefStore {
546    refs: HashMap<String, RefTarget>,
547    reflogs: BTreeMap<String, Vec<ReflogEntry>>,
548}
549
550impl RefStore {
551    pub fn new() -> Self {
552        Self::default()
553    }
554
555    pub fn get(&self, name: &str) -> Option<&RefTarget> {
556        self.refs.get(name)
557    }
558
559    pub fn transaction(&mut self) -> RefTransaction<'_> {
560        RefTransaction {
561            store: self,
562            updates: Vec::new(),
563        }
564    }
565
566    pub fn reflog(&self, name: &str) -> &[ReflogEntry] {
567        self.reflogs
568            .get(name)
569            .map(Vec::as_slice)
570            .unwrap_or_default()
571    }
572}
573
574#[derive(Debug)]
575pub struct RefUpdate {
576    pub name: String,
577    pub expected: Option<RefTarget>,
578    pub new: RefTarget,
579    pub reflog: Option<ReflogEntry>,
580}
581
582/// The compare-and-swap precondition a ref update is checked against (re-verified
583/// while the ref is locked, so it is a true CAS, not a check-then-write).
584///
585/// [`RefUpdate::expected`] can express [`Any`](RefPrecondition::Any) (`None`) and
586/// [`MustExistAndMatch`](RefPrecondition::MustExistAndMatch) (`Some`); the
587/// create-only and match-or-create modes are reachable via
588/// [`FileRefTransaction::update_to`].
589#[derive(Debug, Clone, PartialEq, Eq)]
590pub enum RefPrecondition {
591    /// No precondition: create or overwrite unconditionally.
592    Any,
593    /// The ref must currently exist (with any value).
594    MustExist,
595    /// The ref must currently not exist (create-only).
596    MustNotExist,
597    /// The ref must currently exist and point exactly at this target.
598    MustExistAndMatch(RefTarget),
599    /// If the ref exists it must point exactly at this target; if it is absent,
600    /// the update is still allowed (match-or-create).
601    ExistingMustMatch(RefTarget),
602}
603
604impl RefPrecondition {
605    /// The precondition implied by a [`RefUpdate::expected`] value.
606    fn from_expected(expected: Option<RefTarget>) -> Self {
607        match expected {
608            None => Self::Any,
609            Some(target) => Self::MustExistAndMatch(target),
610        }
611    }
612
613    /// Whether `current` — the ref's value right now, or `None` if absent —
614    /// satisfies this precondition.
615    fn is_satisfied_by(&self, current: Option<&RefTarget>) -> bool {
616        match self {
617            Self::Any => true,
618            Self::MustExist => current.is_some(),
619            Self::MustNotExist => current.is_none(),
620            Self::MustExistAndMatch(target) => current == Some(target),
621            Self::ExistingMustMatch(target) => match current {
622                None => true,
623                Some(current) => current == target,
624            },
625        }
626    }
627
628    /// A human-readable description of an unmet precondition, for errors.
629    fn describe(&self, name: &str) -> String {
630        match self {
631            Self::Any => format!("ref {name} precondition not met"),
632            Self::MustExist => format!("expected ref {name} to exist"),
633            Self::MustNotExist => format!("expected ref {name} to not already exist"),
634            Self::MustExistAndMatch(_) => format!("expected ref {name} to match"),
635            Self::ExistingMustMatch(_) => {
636                format!("expected ref {name} to match its current value")
637            }
638        }
639    }
640}
641
642pub struct RefTransaction<'a> {
643    store: &'a mut RefStore,
644    updates: Vec<RefUpdate>,
645}
646
647impl<'a> RefTransaction<'a> {
648    pub fn update(&mut self, update: RefUpdate) {
649        self.updates.push(update);
650    }
651
652    pub fn commit(self) -> Result<()> {
653        for update in &self.updates {
654            if let Some(expected) = &update.expected
655                && self.store.refs.get(&update.name) != Some(expected)
656            {
657                return Err(GitError::Transaction(format!(
658                    "expected ref {} to match",
659                    update.name
660                )));
661            }
662        }
663        for update in self.updates {
664            self.store.refs.insert(update.name.clone(), update.new);
665            if let Some(entry) = update.reflog {
666                self.store
667                    .reflogs
668                    .entry(update.name)
669                    .or_default()
670                    .push(entry);
671            }
672        }
673        Ok(())
674    }
675}
676
677#[derive(Debug, Clone)]
678pub struct FileRefStore {
679    git_dir: PathBuf,
680    common_dir: PathBuf,
681    storage_dir: PathBuf,
682    format: ObjectFormat,
683    reftable_lock_timeout_millis: Option<u64>,
684    combine_reftable_logs: bool,
685}
686
687#[derive(Debug, Clone, PartialEq, Eq)]
688pub struct BranchCreate {
689    pub name: String,
690    pub oid: ObjectId,
691}
692
693#[derive(Debug, Clone, PartialEq, Eq)]
694pub struct BranchDelete {
695    pub name: String,
696    pub oid: ObjectId,
697}
698
699#[derive(Debug, Clone, PartialEq, Eq)]
700pub struct TagCreate {
701    pub name: String,
702    pub oid: ObjectId,
703}
704
705#[derive(Debug, Clone, PartialEq, Eq)]
706pub struct TagDelete {
707    pub name: String,
708    pub oid: ObjectId,
709}
710
711#[derive(Debug, Clone, PartialEq, Eq)]
712pub struct BundleRefUpdate {
713    pub name: String,
714    pub oid: ObjectId,
715}
716
717#[derive(Debug, Clone, PartialEq, Eq)]
718pub struct BundleRefUpdateReflog {
719    pub committer: Vec<u8>,
720    pub message: Vec<u8>,
721}
722
723#[derive(Debug, Clone, PartialEq, Eq)]
724pub struct AppliedBundleRefUpdate {
725    pub name: String,
726    pub old_oid: Option<ObjectId>,
727    pub new_oid: ObjectId,
728}
729
730fn configured_ref_storage_backend(common_dir: &Path) -> Option<(RefBackendKind, Option<PathBuf>)> {
731    if let Ok(value) = env::var("GIT_REFERENCE_BACKEND")
732        && let Ok((kind, path)) = parse_ref_storage_backend_value(&value)
733    {
734        return Some((
735            kind,
736            path.map(|path| ref_storage_path_from_config(common_dir, path)),
737        ));
738    }
739    let config = GitConfig::read(common_dir.join("config")).ok()?;
740    let value = config.get("extensions", None, "refStorage")?;
741    parse_ref_storage_backend_value(value)
742        .ok()
743        .map(|(kind, path)| {
744            (
745                kind,
746                path.map(|path| ref_storage_path_from_config(common_dir, path)),
747            )
748        })
749}
750
751fn ref_storage_path_from_config(common_dir: &Path, path: PathBuf) -> PathBuf {
752    if path.is_absolute() {
753        path
754    } else {
755        common_dir.join(path)
756    }
757}
758
759fn parse_ref_storage_backend_value(value: &str) -> Result<(RefBackendKind, Option<PathBuf>)> {
760    let (backend, path) = if let Some((backend, path)) = value.split_once("://") {
761        if path.is_empty() {
762            return Err(GitError::InvalidFormat(format!(
763                "invalid value for 'extensions.refstorage': '{value}'"
764            )));
765        }
766        (backend, Some(PathBuf::from(path)))
767    } else {
768        (value, None)
769    };
770    let kind = match backend {
771        "files" => RefBackendKind::Files,
772        "reftable" => RefBackendKind::Reftable,
773        _ => {
774            return Err(GitError::InvalidFormat(format!(
775                "invalid value for 'extensions.refstorage': '{value}'"
776            )));
777        }
778    };
779    Ok((kind, path))
780}
781
782#[derive(Debug, Clone, Copy, PartialEq, Eq)]
783enum RefBackendKind {
784    Files,
785    Reftable,
786}
787
788impl FileRefStore {
789    pub fn new(git_dir: impl Into<PathBuf>, format: ObjectFormat) -> Self {
790        let git_dir = git_dir.into();
791        let common_dir = repository_common_dir(&git_dir);
792        let configured = configured_ref_storage_backend(&common_dir);
793        let storage_dir = match configured {
794            Some((_, Some(path))) => path,
795            Some((RefBackendKind::Reftable, None)) if git_dir != common_dir => git_dir.clone(),
796            _ => common_dir.clone(),
797        };
798        Self {
799            git_dir,
800            common_dir,
801            storage_dir,
802            format,
803            reftable_lock_timeout_millis: None,
804            combine_reftable_logs: false,
805        }
806    }
807
808    pub fn with_reftable_lock_timeout_millis(mut self, timeout_millis: Option<u64>) -> Self {
809        self.reftable_lock_timeout_millis = timeout_millis;
810        self
811    }
812
813    pub fn with_reftable_combined_logs(mut self, combine: bool) -> Self {
814        self.combine_reftable_logs = combine;
815        self
816    }
817
818    pub fn read_ref(&self, name: &str) -> Result<Option<RefTarget>> {
819        validate_ref_name_for_read(name)?;
820        self.read_ref_unchecked(name)
821    }
822
823    fn read_ref_unchecked(&self, name: &str) -> Result<Option<RefTarget>> {
824        if self.uses_reftable()? {
825            let (store, name) = self.reftable_store_for_ref(name)?;
826            if let Some(target) = store.read_reftable_ref(&name)? {
827                return Ok(Some(target));
828            }
829            if name != "HEAD" && is_root_ref_syntax(&name) {
830                return Ok(self
831                    .read_loose_ref(&name)?
832                    .map(|reference| reference.target));
833            }
834            return Ok(None);
835        }
836        if let Some(reference) = self.read_loose_ref(name)? {
837            return Ok(Some(reference.target));
838        }
839        if let Some(reference) = self.read_packed_ref(name)? {
840            return Ok(Some(reference.reference.target));
841        }
842        Ok(None)
843    }
844
845    /// Raw existence check matching git's `refs_read_raw_ref` (builtin/refs.c
846    /// cmd_refs_exists). A ref "exists" if its loose file is present (regardless
847    /// of contents — dangling symrefs, bad object ids, and refs written with a
848    /// bad name all count) or if it is recorded in packed-refs / the reftable.
849    /// Unlike [`read_ref`], no name validation is performed and the object the
850    /// ref points at is never read. Returns:
851    ///   * `Ok(true)`  — the raw ref exists.
852    ///   * `Ok(false)` — ENOENT or EISDIR (a bare directory where the ref would
853    ///     live and no packed entry); git maps both to exit code 2.
854    pub fn raw_ref_exists(&self, name: &str) -> Result<bool> {
855        if self.uses_reftable()? {
856            let (store, name) = self.reftable_store_for_ref(name)?;
857            if store.read_reftable_ref(&name)?.is_some() {
858                return Ok(true);
859            }
860            if name != "HEAD" && is_root_ref_syntax(&name) {
861                return Ok(self.read_loose_ref(&name)?.is_some());
862            }
863            return Ok(false);
864        }
865        // git routes root-ref-syntax names (HEAD, FETCH_HEAD, MERGE_HEAD, …) to
866        // the per-worktree gitdir and everything else to the common dir; mirror
867        // files_ref_path's REF_WORKTREE_CURRENT vs REF_WORKTREE_SHARED split.
868        let base = if is_root_ref_syntax(name) {
869            &self.git_dir
870        } else {
871            &self.common_dir
872        };
873        let path = base.join(name);
874        match fs::symlink_metadata(&path) {
875            Ok(meta) if meta.is_dir() => {
876                // A directory at the loose path is EISDIR unless packed-refs
877                // still carries the name.
878                Ok(self.read_packed_ref(name)?.is_some())
879            }
880            Ok(_) => Ok(true),
881            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
882                Ok(self.read_packed_ref(name)?.is_some())
883            }
884            Err(err) => Err(err.into()),
885        }
886    }
887
888    pub fn read_reflog(&self, name: &str) -> Result<Vec<ReflogEntry>> {
889        validate_ref_name_for_read(name)?;
890        if self.uses_reftable()? {
891            return self.read_reftable_logs(name);
892        }
893        let path = self.reflog_path(name);
894        if !path.exists() {
895            return Ok(Vec::new());
896        }
897        parse_reflog(self.format, &fs::read(path)?)
898    }
899
900    pub fn write_reflog(&self, name: &str, entries: &[ReflogEntry]) -> Result<()> {
901        validate_ref_name_for_read(name)?;
902        if self.uses_reftable()? {
903            return self.rewrite_reftable_logs(name, entries, true);
904        }
905        let path = self.reflog_path(name);
906        let parent = path
907            .parent()
908            .ok_or_else(|| GitError::InvalidPath("reflog path has no parent".into()))?;
909        fs::create_dir_all(parent)?;
910        let mut bytes = Vec::new();
911        for entry in entries {
912            bytes.extend_from_slice(&entry.to_line());
913        }
914        write_locked(&path, &bytes)
915    }
916
917    pub fn import_snapshot(
918        &self,
919        refs: &[Ref],
920        reflogs: &[(String, Vec<ReflogEntry>)],
921        pack_files_refs: bool,
922    ) -> Result<()> {
923        if self.uses_reftable()? {
924            let ref_records = refs
925                .iter()
926                .map(|reference| ReftableRefRecord {
927                    name: reference.name.clone(),
928                    update_index: 0,
929                    value: reftable_value_from_ref_target(&reference.target),
930                })
931                .collect::<Vec<_>>();
932            let mut log_records = Vec::new();
933            let mut update_index = 1u64;
934            for (name, entries) in reflogs {
935                for entry in entries {
936                    log_records.push(ReftableLogRecord {
937                        refname: name.clone(),
938                        update_index,
939                        value: ReftableLogValue::Update(reftable_update_from_reflog(entry)?),
940                    });
941                    update_index = update_index.checked_add(1).ok_or_else(|| {
942                        GitError::InvalidFormat("reftable update index overflow".into())
943                    })?;
944                }
945            }
946            if !ref_records.is_empty() || !log_records.is_empty() {
947                self.append_reftable_table_spanning(ref_records, log_records)?;
948            }
949            return Ok(());
950        }
951
952        let mut tx = self.transaction();
953        for reference in refs {
954            tx.update(RefUpdate {
955                name: reference.name.clone(),
956                expected: None,
957                new: reference.target.clone(),
958                reflog: None,
959            });
960        }
961        tx.commit()?;
962        for (name, entries) in reflogs {
963            self.write_reflog(name, entries)?;
964        }
965        if pack_files_refs {
966            let _ = self.pack_refs(true)?;
967        }
968        Ok(())
969    }
970
971    pub fn expire_reflog_older_than(&self, name: &str, cutoff_seconds: i64) -> Result<usize> {
972        validate_ref_name_for_read(name)?;
973        if self.uses_reftable()? {
974            let entries = self.read_reftable_logs(name)?;
975            let original_len = entries.len();
976            let mut retained = Vec::new();
977            for entry in entries {
978                if entry.timestamp_seconds()? >= cutoff_seconds {
979                    retained.push(entry);
980                }
981            }
982            let removed = original_len - retained.len();
983            if removed > 0 {
984                self.rewrite_reftable_logs(name, &retained, true)?;
985            }
986            return Ok(removed);
987        }
988        let path = self.reflog_path(name);
989        if !path.exists() {
990            return Ok(0);
991        }
992        let entries = parse_reflog(self.format, &fs::read(&path)?)?;
993        let original_len = entries.len();
994        let mut retained = Vec::new();
995        for entry in entries {
996            if entry.timestamp_seconds()? >= cutoff_seconds {
997                retained.push(entry);
998            }
999        }
1000        let mut bytes = Vec::new();
1001        for entry in &retained {
1002            bytes.extend_from_slice(&entry.to_line());
1003        }
1004        write_locked(&path, &bytes)?;
1005        Ok(original_len - retained.len())
1006    }
1007
1008    /// Read a ref's reflog, expire entries with [`expire_reflog`], and rewrite
1009    /// the file with the survivors.
1010    ///
1011    /// Reachability of each entry's `new_oid` is delegated to `is_reachable` so
1012    /// the caller can supply whatever object-graph knowledge it has. Rewriting is
1013    /// opt-in via `write`: when `false` nothing is written and the function only
1014    /// reports how many entries would be removed (a dry run). When `true` the
1015    /// reflog is rewritten atomically (lock file + rename) only if at least one
1016    /// entry was removed; an unchanged reflog is left untouched. Returns the
1017    /// number of entries removed.
1018    pub fn expire_reflog_file(
1019        &self,
1020        name: &str,
1021        cutoff_unix: i64,
1022        expire_unreachable_cutoff: Option<i64>,
1023        write: bool,
1024        is_reachable: impl Fn(&ObjectId) -> bool,
1025    ) -> Result<usize> {
1026        validate_ref_name(name)?;
1027        if self.uses_reftable()? {
1028            let entries = self.read_reftable_logs(name)?;
1029            let original_len = entries.len();
1030            let retained = expire_reflog(
1031                &entries,
1032                cutoff_unix,
1033                expire_unreachable_cutoff,
1034                is_reachable,
1035            )?;
1036            let removed = original_len - retained.len();
1037            if write && removed > 0 {
1038                self.rewrite_reftable_logs(name, &retained, true)?;
1039            }
1040            return Ok(removed);
1041        }
1042        let path = self.reflog_path(name);
1043        if !path.exists() {
1044            return Ok(0);
1045        }
1046        let entries = parse_reflog(self.format, &fs::read(&path)?)?;
1047        let original_len = entries.len();
1048        let retained = expire_reflog(
1049            &entries,
1050            cutoff_unix,
1051            expire_unreachable_cutoff,
1052            is_reachable,
1053        )?;
1054        let removed = original_len - retained.len();
1055        if write && removed > 0 {
1056            let mut bytes = Vec::new();
1057            for entry in &retained {
1058                bytes.extend_from_slice(&entry.to_line());
1059            }
1060            write_locked(&path, &bytes)?;
1061        }
1062        Ok(removed)
1063    }
1064
1065    pub fn list_refs(&self) -> Result<Vec<Ref>> {
1066        self.list_refs_with_prefix("refs/")
1067    }
1068
1069    pub fn list_refs_with_prefix(&self, prefix: &str) -> Result<Vec<Ref>> {
1070        if self.uses_reftable()? {
1071            let mut refs = BTreeMap::<String, Ref>::new();
1072            for reference in self
1073                .reftable_store_with_storage(self.shared_reftable_storage_dir())
1074                .list_reftable_refs_with_prefix(prefix)?
1075            {
1076                refs.insert(reference.name.clone(), reference);
1077            }
1078            if self.git_dir != self.common_dir {
1079                for reference in self.list_reftable_refs_with_prefix(prefix)? {
1080                    if reftable_current_worktree_ref(&reference.name) {
1081                        refs.insert(reference.name.clone(), reference);
1082                    }
1083                }
1084            }
1085            return Ok(refs.into_values().collect());
1086        }
1087        let mut refs = Vec::new();
1088        let packed_path = self.storage_dir.join("packed-refs");
1089        if packed_path.exists() {
1090            for packed in
1091                parse_packed_refs_with_prefix(self.format, &fs::read(packed_path)?, prefix)?
1092            {
1093                refs.push(packed.reference);
1094            }
1095        }
1096        let mut loose_refs = BTreeMap::new();
1097        self.collect_loose_refs_with_prefix(prefix, &mut loose_refs)?;
1098        if !loose_refs.is_empty() {
1099            refs.retain(|reference| !loose_refs.contains_key(&reference.name));
1100            refs.extend(loose_refs.into_values());
1101        }
1102        refs.retain(|reference| reference.name.starts_with(prefix));
1103        refs.retain(|reference| {
1104            if validate_ref_name(&reference.name).is_ok() {
1105                true
1106            } else {
1107                warn_broken_ref_name(&reference.name);
1108                false
1109            }
1110        });
1111        refs.sort_by(|left, right| left.name.cmp(&right.name));
1112        Ok(refs)
1113    }
1114
1115    pub fn list_ref_names_with_prefix(&self, prefix: &str) -> Result<Vec<String>> {
1116        if self.uses_reftable()? {
1117            return Ok(self
1118                .list_refs_with_prefix(prefix)?
1119                .into_iter()
1120                .map(|reference| reference.name)
1121                .collect());
1122        }
1123        let mut names = Vec::new();
1124        let packed_path = self.storage_dir.join("packed-refs");
1125        if packed_path.exists() {
1126            packed_ref_names_with_prefix(
1127                self.format,
1128                &fs::read(packed_path)?,
1129                prefix,
1130                false,
1131                &mut names,
1132            )?;
1133        }
1134        let mut loose_names = BTreeSet::new();
1135        self.collect_loose_ref_names_with_prefix(prefix, &mut loose_names)?;
1136        if !loose_names.is_empty() {
1137            names.retain(|name| !loose_names.contains(name));
1138            names.extend(loose_names);
1139        }
1140        names.retain(|name| name.starts_with(prefix));
1141        names.retain(|name| {
1142            if validate_ref_name(name).is_ok() {
1143                true
1144            } else {
1145                warn_broken_ref_name(name);
1146                false
1147            }
1148        });
1149        names.sort();
1150        names.dedup();
1151        Ok(names)
1152    }
1153
1154    pub fn list_short_ref_names_with_prefix(&self, prefix: &str) -> Result<Vec<String>> {
1155        if self.uses_reftable()? {
1156            let mut names = self
1157                .list_reftable_refs_with_prefix(prefix)?
1158                .into_iter()
1159                .filter_map(|reference| reference.name.strip_prefix(prefix).map(str::to_owned))
1160                .collect::<Vec<_>>();
1161            names.sort();
1162            return Ok(names);
1163        }
1164        let mut names = Vec::new();
1165        let packed_path = self.storage_dir.join("packed-refs");
1166        if packed_path.exists() {
1167            packed_ref_names_with_prefix(
1168                self.format,
1169                &fs::read(packed_path)?,
1170                prefix,
1171                true,
1172                &mut names,
1173            )?;
1174        }
1175        let mut loose_full_names = BTreeSet::new();
1176        self.collect_loose_ref_names_with_prefix(prefix, &mut loose_full_names)?;
1177        let loose_names = loose_full_names
1178            .into_iter()
1179            .filter_map(|name| {
1180                if validate_ref_name(&name).is_ok() {
1181                    name.strip_prefix(prefix).map(str::to_owned)
1182                } else {
1183                    warn_broken_ref_name(&name);
1184                    None
1185                }
1186            })
1187            .collect::<BTreeSet<_>>();
1188        if !loose_names.is_empty() {
1189            names.retain(|name| !loose_names.contains(name));
1190            names.extend(loose_names);
1191        }
1192        names.sort();
1193        names.dedup();
1194        Ok(names)
1195    }
1196
1197    pub fn list_all_refs(&self) -> Result<Vec<Ref>> {
1198        let mut refs = self.list_refs()?;
1199        let mut seen = refs
1200            .iter()
1201            .map(|reference| reference.name.clone())
1202            .collect::<BTreeSet<_>>();
1203        for reference in self.list_root_refs()? {
1204            if seen.insert(reference.name.clone()) {
1205                refs.push(reference);
1206            }
1207        }
1208        refs.sort_by(|left, right| left.name.cmp(&right.name));
1209        Ok(refs)
1210    }
1211
1212    pub fn list_reflog_names(&self) -> Result<Vec<String>> {
1213        let mut names = BTreeSet::new();
1214        if self.uses_reftable()? {
1215            for table in self.reftables()? {
1216                for record in table.logs {
1217                    names.insert(record.refname);
1218                }
1219            }
1220            let mut live = Vec::new();
1221            for name in names {
1222                if !self.read_reftable_logs(&name)?.is_empty() {
1223                    live.push(name);
1224                }
1225            }
1226            return Ok(live);
1227        }
1228        self.collect_reflog_names(&self.storage_dir.join("logs"), "logs", &mut names)?;
1229        let worktree_logs = self.git_dir.join("logs");
1230        if worktree_logs != self.storage_dir.join("logs") {
1231            self.collect_reflog_names(&worktree_logs, "logs", &mut names)?;
1232        }
1233        Ok(names.into_iter().collect())
1234    }
1235
1236    pub fn has_refs_with_prefix(&self, prefix: &str) -> Result<bool> {
1237        if self.uses_reftable()? {
1238            return Ok(self
1239                .list_reftable_refs()?
1240                .iter()
1241                .any(|reference| reference.name.starts_with(prefix)));
1242        }
1243        let packed_path = self.storage_dir.join("packed-refs");
1244        if packed_path.exists()
1245            && packed_refs_have_prefix(self.format, &fs::read(&packed_path)?, prefix)?
1246        {
1247            return Ok(true);
1248        }
1249        self.loose_refs_have_prefix(prefix)
1250    }
1251
1252    pub fn write_packed_refs(&self, refs: &[PackedRef]) -> Result<()> {
1253        self.write_packed_refs_with_timeout(refs, 0)
1254    }
1255
1256    pub fn write_packed_refs_with_timeout(
1257        &self,
1258        refs: &[PackedRef],
1259        timeout_millis: u64,
1260    ) -> Result<()> {
1261        let path = self.packed_refs_write_path()?;
1262        write_locked_with_timeout(&path, &write_packed_refs(refs)?, timeout_millis)
1263    }
1264
1265    pub fn pack_refs(&self, prune_loose: bool) -> Result<Vec<PackedRef>> {
1266        self.pack_refs_with_peeler(prune_loose, |_, _| Ok(None))
1267    }
1268
1269    pub fn pack_refs_with_peeler<F>(&self, prune_loose: bool, mut peel: F) -> Result<Vec<PackedRef>>
1270    where
1271        F: FnMut(&str, &ObjectId) -> Result<Option<ObjectId>>,
1272    {
1273        self.pack_refs_selected_with_timeout(
1274            prune_loose,
1275            false,
1276            0,
1277            |_| true,
1278            |name, oid| peel(name, oid).map(|peeled| PackRefDecision::Pack { peeled }),
1279        )
1280    }
1281
1282    pub fn pack_refs_selected_with_timeout<F, S>(
1283        &self,
1284        prune_loose: bool,
1285        auto: bool,
1286        timeout_millis: u64,
1287        mut should_pack: S,
1288        mut decide: F,
1289    ) -> Result<Vec<PackedRef>>
1290    where
1291        F: FnMut(&str, &ObjectId) -> Result<PackRefDecision>,
1292        S: FnMut(&str) -> bool,
1293    {
1294        if self.uses_reftable()? {
1295            self.compact_reftable_stack()?;
1296            return Ok(Vec::new());
1297        }
1298        let mut packed_refs = BTreeMap::new();
1299        let packed_path = self.storage_dir.join("packed-refs");
1300        if packed_path.exists() {
1301            for packed in parse_packed_refs(self.format, &fs::read(&packed_path)?)? {
1302                packed_refs.insert(packed.reference.name.clone(), packed);
1303            }
1304        }
1305
1306        let mut loose_refs = BTreeMap::new();
1307        let refs_dir = self.storage_dir.join("refs");
1308        if refs_dir.exists() {
1309            self.collect_loose_refs(&refs_dir, "refs", &mut loose_refs)?;
1310        }
1311        let loose_refs = loose_refs
1312            .into_values()
1313            .filter(|reference| packable_loose_ref_name(&reference.name))
1314            .filter(|reference| should_pack(&reference.name))
1315            .collect::<Vec<_>>();
1316        if auto && !pack_refs_auto_required_for(&packed_path, loose_refs.len())? {
1317            return Ok(packed_refs.into_values().collect());
1318        }
1319        let mut packed_loose_names = Vec::new();
1320        for reference in loose_refs {
1321            let RefTarget::Direct(oid) = reference.target else {
1322                continue;
1323            };
1324            let peeled = match decide(&reference.name, &oid)? {
1325                PackRefDecision::Pack { peeled } => peeled,
1326                PackRefDecision::Skip => continue,
1327            };
1328            packed_loose_names.push(reference.name.clone());
1329            packed_refs.insert(
1330                reference.name.clone(),
1331                PackedRef {
1332                    reference: Ref {
1333                        name: reference.name,
1334                        target: RefTarget::Direct(oid),
1335                    },
1336                    peeled,
1337                },
1338            );
1339        }
1340
1341        let refs = packed_refs.into_values().collect::<Vec<_>>();
1342        self.write_packed_refs_with_timeout(&refs, timeout_millis)?;
1343        if prune_loose {
1344            for name in packed_loose_names {
1345                self.delete_loose_ref(&name)?;
1346            }
1347        }
1348        Ok(refs)
1349    }
1350
1351    pub fn pack_refs_auto_required<S>(&self, mut should_pack: S) -> Result<bool>
1352    where
1353        S: FnMut(&str) -> bool,
1354    {
1355        if self.uses_reftable()? {
1356            return Ok(true);
1357        }
1358        let mut loose_refs = BTreeMap::new();
1359        let refs_dir = self.storage_dir.join("refs");
1360        if refs_dir.exists() {
1361            self.collect_loose_refs(&refs_dir, "refs", &mut loose_refs)?;
1362        }
1363        let count = loose_refs
1364            .values()
1365            .filter(|reference| packable_loose_ref_name(&reference.name))
1366            .filter(|reference| matches!(reference.target, RefTarget::Direct(_)))
1367            .filter(|reference| should_pack(&reference.name))
1368            .count();
1369        pack_refs_auto_required_for(&self.storage_dir.join("packed-refs"), count)
1370    }
1371
1372    fn packed_refs_write_path(&self) -> Result<PathBuf> {
1373        let path = self.storage_dir.join("packed-refs");
1374        match fs::symlink_metadata(&path) {
1375            Ok(meta) if meta.file_type().is_symlink() => {
1376                let target = fs::read_link(&path)?;
1377                if target.is_absolute() {
1378                    Ok(target)
1379                } else {
1380                    let parent = path.parent().ok_or_else(|| {
1381                        GitError::InvalidPath("packed-refs path has no parent".into())
1382                    })?;
1383                    Ok(parent.join(target))
1384                }
1385            }
1386            Ok(_) => Ok(path),
1387            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(path),
1388            Err(err) => Err(err.into()),
1389        }
1390    }
1391
1392    pub fn current_branch_ref(&self) -> Result<Option<String>> {
1393        match self.read_ref("HEAD")? {
1394            Some(RefTarget::Symbolic(name)) if name.starts_with("refs/heads/") => Ok(Some(name)),
1395            _ => Ok(None),
1396        }
1397    }
1398
1399    pub fn current_branch(&self) -> Result<Option<String>> {
1400        Ok(self
1401            .current_branch_ref()?
1402            .and_then(|name| name.strip_prefix("refs/heads/").map(str::to_string)))
1403    }
1404
1405    pub fn transaction(&self) -> FileRefTransaction<'_> {
1406        FileRefTransaction {
1407            store: self,
1408            changes: Vec::new(),
1409            hook: None,
1410        }
1411    }
1412
1413    pub fn create_branch(
1414        &self,
1415        branch: &str,
1416        start: ObjectId,
1417        committer: Vec<u8>,
1418        message: Vec<u8>,
1419    ) -> Result<BranchCreate> {
1420        let name = branch_ref_name(branch)?;
1421        if self.read_ref(&name)?.is_some() {
1422            return Err(GitError::Transaction(format!(
1423                "branch {branch} already exists"
1424            )));
1425        }
1426        let zero = ObjectId::null(self.format);
1427        let mut tx = self.transaction();
1428        tx.update(RefUpdate {
1429            name: name.clone(),
1430            expected: None,
1431            new: RefTarget::Direct(start),
1432            reflog: Some(ReflogEntry {
1433                old_oid: zero,
1434                new_oid: start,
1435                committer,
1436                message,
1437            }),
1438        });
1439        tx.commit()?;
1440        Ok(BranchCreate { name, oid: start })
1441    }
1442
1443    pub fn delete_branch(&self, branch: &str) -> Result<BranchDelete> {
1444        let name = branch_ref_name_for_read(branch)?;
1445        if matches!(self.read_ref("HEAD")?, Some(RefTarget::Symbolic(head)) if head == name) {
1446            return Err(GitError::Transaction(format!(
1447                "cannot delete branch {branch} checked out at HEAD"
1448            )));
1449        }
1450        let oid = self.delete_direct_ref(&name, "branch", branch)?;
1451        self.remove_reflog_file(&name);
1452        Ok(BranchDelete { name, oid })
1453    }
1454
1455    pub fn move_branch(
1456        &self,
1457        old_branch: &str,
1458        new_branch: &str,
1459        force: bool,
1460        committer: Vec<u8>,
1461    ) -> Result<()> {
1462        self.copy_or_move_branch(old_branch, new_branch, force, false, committer)
1463    }
1464
1465    pub fn copy_branch(
1466        &self,
1467        old_branch: &str,
1468        new_branch: &str,
1469        force: bool,
1470        committer: Vec<u8>,
1471    ) -> Result<()> {
1472        self.copy_or_move_branch(old_branch, new_branch, force, true, committer)
1473    }
1474
1475    /// Find an existing ref (other than `exclude`) that would have a
1476    /// directory/file conflict with creating `new_name`: either an existing ref
1477    /// is a path-prefix of `new_name` (it occupies a directory component
1478    /// `new_name` needs), or `new_name` is a path-prefix of an existing ref
1479    /// (`new_name` would occupy a directory another ref needs). Returns the
1480    /// conflicting ref name.
1481    fn conflicting_ref_for_path(&self, new_name: &str, exclude: &str) -> Result<Option<String>> {
1482        for reference in self.list_refs()? {
1483            let name = &reference.name;
1484            if name == new_name || name == exclude {
1485                continue;
1486            }
1487            // `name` sits above `new_name`: name = refs/heads/r, new = refs/heads/r/q
1488            if new_name.starts_with(&format!("{name}/")) {
1489                return Ok(Some(name.clone()));
1490            }
1491            // `name` sits below `new_name`: new = refs/heads/r, name = refs/heads/r/q
1492            if name.starts_with(&format!("{new_name}/")) {
1493                return Ok(Some(name.clone()));
1494            }
1495        }
1496        Ok(None)
1497    }
1498
1499    fn copy_or_move_branch(
1500        &self,
1501        old_branch: &str,
1502        new_branch: &str,
1503        force: bool,
1504        copy: bool,
1505        committer: Vec<u8>,
1506    ) -> Result<()> {
1507        let old_name = branch_ref_name_for_source(old_branch)?;
1508        let new_name = branch_ref_name(new_branch)?;
1509        if old_name == new_name {
1510            return Ok(());
1511        }
1512        let Some(target) = self.read_ref(&old_name)? else {
1513            return Err(GitError::reference_not_found(format!(
1514                "branch {old_branch}"
1515            )));
1516        };
1517        let RefTarget::Direct(oid) = target else {
1518            return Err(GitError::InvalidFormat(format!(
1519                "branch {old_branch} is symbolic"
1520            )));
1521        };
1522        // Detect a directory/file conflict against some *other* ref before
1523        // mutating anything (git's rename_ref fails up front, leaving the old
1524        // branch intact): e.g. renaming `q` -> `r/q` while `r` exists, or `q` ->
1525        // `r` while `r/x` exists. For renames, the old ref itself is excluded
1526        // because a self-nesting rename (`m` -> `m/m`) is handled by removing it
1527        // first. Copies leave the source ref in place, so the source can
1528        // conflict with the destination.
1529        let conflict_exclude = if copy { "" } else { &old_name };
1530        if let Some(conflict) = self.conflicting_ref_for_path(&new_name, conflict_exclude)? {
1531            return Err(GitError::Transaction(format!(
1532                "'{conflict}' exists; cannot create '{new_name}'"
1533            )));
1534        }
1535        // git's validate_branchname uses refs_ref_exists (RESOLVE_REF_READING):
1536        // a *dangling* symref destination does not "exist", so a rename onto it
1537        // proceeds without --force and overwrites the symref file (t3200 #16).
1538        let dest_entry = self.read_ref(&new_name)?;
1539        let dest_resolves = resolve_ref_peeled(self, &new_name)?.is_some();
1540        if dest_resolves && !force {
1541            return Err(GitError::Transaction(format!(
1542                "branch {new_branch} already exists"
1543            )));
1544        }
1545        // Remove any existing destination ref (direct or symbolic) before
1546        // writing. A dangling symref must be removed as a symref; a real branch
1547        // as a direct ref.
1548        match dest_entry {
1549            Some(RefTarget::Symbolic(_)) => {
1550                self.delete_symbolic_ref(&new_name)?;
1551                self.remove_reflog_file(&new_name);
1552            }
1553            Some(RefTarget::Direct(_)) => {
1554                let _ = self.delete_direct_ref(&new_name, "branch", new_branch)?;
1555                self.remove_reflog_file(&new_name);
1556            }
1557            None => {}
1558        }
1559
1560        // Capture the old reflog before removing anything; it is carried over
1561        // to the new ref.
1562        let mut reflog = self.read_reflog(&old_name)?;
1563        reflog.push(ReflogEntry {
1564            old_oid: oid,
1565            new_oid: oid,
1566            committer,
1567            message: if copy {
1568                format!("Branch: copied {old_name} to {new_name}").into_bytes()
1569            } else {
1570                format!("Branch: renamed {old_name} to {new_name}").into_bytes()
1571            },
1572        });
1573
1574        // A directory/file conflict can occur when the new ref's path nests
1575        // under the old ref (`m` -> `m/m`) or vice-versa; remove the old loose
1576        // ref AND its reflog first so neither file blocks creating the new
1577        // directory under refs/ or logs/refs/ (t3200 #17, #18).
1578        if !copy {
1579            let _ = self.delete_direct_ref(&old_name, "branch", old_branch)?;
1580            self.remove_reflog_file(&old_name);
1581        }
1582
1583        self.write_loose_ref(&Ref {
1584            name: new_name.clone(),
1585            target: RefTarget::Direct(oid),
1586        })?;
1587        self.write_reflog(&new_name, &reflog)?;
1588
1589        if !copy
1590            && matches!(self.read_ref("HEAD")?, Some(RefTarget::Symbolic(head)) if head == old_name)
1591        {
1592            self.write_loose_ref(&Ref {
1593                name: "HEAD".into(),
1594                target: RefTarget::Symbolic(new_name),
1595            })?;
1596        }
1597        Ok(())
1598    }
1599
1600    pub fn create_tag(&self, tag: &str, target: ObjectId) -> Result<TagCreate> {
1601        let name = tag_ref_name(tag)?;
1602        if self.read_ref(&name)?.is_some() {
1603            return Err(GitError::Transaction(format!("tag {tag} already exists")));
1604        }
1605        let mut tx = self.transaction();
1606        tx.update(RefUpdate {
1607            name: name.clone(),
1608            expected: None,
1609            new: RefTarget::Direct(target),
1610            reflog: None,
1611        });
1612        tx.commit()?;
1613        Ok(TagCreate { name, oid: target })
1614    }
1615
1616    pub fn apply_bundle_ref_updates(
1617        &self,
1618        refs: &[BundleRefUpdate],
1619        reflog: Option<BundleRefUpdateReflog>,
1620    ) -> Result<Vec<AppliedBundleRefUpdate>> {
1621        let (updates, applied) = prepare_bundle_ref_updates(refs, reflog.as_ref(), |name, oid| {
1622            if oid.format() != self.format {
1623                return Err(GitError::InvalidObjectId(format!(
1624                    "bundle ref {name} has {} object id for {} repository",
1625                    oid.format().name(),
1626                    self.format.name()
1627                )));
1628            }
1629            self.read_ref(name)
1630        })?;
1631        let mut tx = self.transaction();
1632        for update in updates {
1633            tx.update(update);
1634        }
1635        tx.commit()?;
1636        Ok(applied)
1637    }
1638
1639    pub fn delete_tag(&self, tag: &str) -> Result<TagDelete> {
1640        let name = TagRefNameBuf::from_tag_name_unrestricted(tag)?.into_string();
1641        let oid = self.delete_direct_ref(&name, "tag", tag)?;
1642        self.remove_reflog_file(&name);
1643        Ok(TagDelete { name, oid })
1644    }
1645
1646    pub fn delete_ref(&self, name: &str) -> Result<RefDelete> {
1647        validate_ref_name_for_read(name)?;
1648        let oid = self.delete_direct_ref(name, "ref", name)?;
1649        self.remove_reflog_file(name);
1650        Ok(RefDelete {
1651            name: name.into(),
1652            oid,
1653        })
1654    }
1655
1656    pub fn delete_ref_checked(
1657        &self,
1658        delete: DeleteRef,
1659    ) -> std::result::Result<RefDelete, RefDeleteError> {
1660        validate_ref_name_for_read(&delete.name).map_err(|_| RefDeleteError::InvalidName)?;
1661        if self.uses_reftable().map_err(ref_delete_error_from_git)? {
1662            return self.delete_reftable_ref_checked(delete);
1663        }
1664        self.delete_files_ref_checked(delete)
1665    }
1666
1667    pub fn delete_symbolic_ref(&self, name: &str) -> Result<bool> {
1668        validate_ref_name_for_read(name)?;
1669        if self.uses_reftable()? {
1670            let Some(target) = self.read_ref(name)? else {
1671                return Ok(false);
1672            };
1673            if !matches!(target, RefTarget::Symbolic(_)) {
1674                return Ok(false);
1675            }
1676            self.append_reftable_records(vec![ReftableRefRecord {
1677                name: name.to_string(),
1678                update_index: 0,
1679                value: ReftableRefValue::Deletion,
1680            }])?;
1681            self.remove_reflog_file(name);
1682            return Ok(true);
1683        }
1684        let Some(reference) = self.read_loose_ref(name)? else {
1685            return Ok(false);
1686        };
1687        if !matches!(reference.target, RefTarget::Symbolic(_)) {
1688            return Ok(false);
1689        }
1690        self.delete_loose_ref(name)?;
1691        self.remove_reflog_file(name);
1692        Ok(true)
1693    }
1694
1695    fn delete_direct_ref(&self, name: &str, kind: &str, short_name: &str) -> Result<ObjectId> {
1696        if self.uses_reftable()? {
1697            let Some(target) = self.read_ref(name)? else {
1698                return Err(GitError::reference_not_found(format!(
1699                    "{kind} {short_name}"
1700                )));
1701            };
1702            let RefTarget::Direct(oid) = target else {
1703                return Err(GitError::InvalidFormat(format!(
1704                    "{kind} {short_name} is symbolic"
1705                )));
1706            };
1707            self.append_reftable_records(vec![ReftableRefRecord {
1708                name: name.to_string(),
1709                update_index: 0,
1710                value: ReftableRefValue::Deletion,
1711            }])?;
1712            // git drops the reflog when the ref goes away; tombstone the log
1713            // records so `git reflog` / `git stash list` stop seeing them.
1714            self.remove_reflog_file(name);
1715            return Ok(oid);
1716        }
1717        let Some(reference) = self.read_loose_ref(name)? else {
1718            return self.delete_packed_ref(name, kind, short_name);
1719        };
1720        let oid = match reference.target {
1721            RefTarget::Direct(oid) => oid,
1722            RefTarget::Symbolic(target) => {
1723                return Err(GitError::InvalidFormat(format!(
1724                    "{kind} {short_name} is symbolic to {target}"
1725                )));
1726            }
1727        };
1728        self.delete_loose_ref(name)?;
1729        // A loose ref can shadow a packed entry of the same name (e.g. an update
1730        // after `pack-refs --all` writes a loose file over the packed copy).
1731        // git removes the ref from packed-refs too, so unlinking only the loose
1732        // file would leave the stale packed entry resurfacing. Drop any packed
1733        // copy as well; its absence is not an error.
1734        match self.delete_packed_ref(name, kind, short_name) {
1735            Ok(_) | Err(GitError::NotFound(_)) => {}
1736            Err(err) => return Err(err),
1737        }
1738        Ok(oid)
1739    }
1740
1741    fn delete_packed_ref(&self, name: &str, kind: &str, short_name: &str) -> Result<ObjectId> {
1742        let path = self.storage_dir.join("packed-refs");
1743        if !path.exists() {
1744            return Err(GitError::reference_not_found(format!(
1745                "{kind} {short_name}"
1746            )));
1747        }
1748        let mut refs = parse_packed_refs(self.format, &fs::read(&path)?)?;
1749        let Some(index) = refs
1750            .iter()
1751            .position(|reference| reference.reference.name == name)
1752        else {
1753            return Err(GitError::reference_not_found(format!(
1754                "{kind} {short_name}"
1755            )));
1756        };
1757        let removed = refs.remove(index);
1758        let RefTarget::Direct(oid) = removed.reference.target else {
1759            return Err(GitError::InvalidFormat(format!(
1760                "{kind} {short_name} is symbolic"
1761            )));
1762        };
1763        self.write_packed_refs(&refs)?;
1764        Ok(oid)
1765    }
1766
1767    fn delete_reftable_ref_checked(
1768        &self,
1769        delete: DeleteRef,
1770    ) -> std::result::Result<RefDelete, RefDeleteError> {
1771        let target = self
1772            .read_ref(&delete.name)
1773            .map_err(ref_delete_error_from_git)?;
1774        let oid = checked_delete_oid(delete.expected_old, target)?;
1775        self.append_reftable_records(vec![ReftableRefRecord {
1776            name: delete.name.clone(),
1777            update_index: 0,
1778            value: ReftableRefValue::Deletion,
1779        }])
1780        .map_err(ref_delete_error_from_git)?;
1781        // Git unlinks logs/refs/<name> on delete (pruning now-empty dirs); it
1782        // does not keep a deletion entry. Mirror delete_ref / delete_branch.
1783        self.remove_reflog_file(&delete.name);
1784        Ok(RefDelete {
1785            name: delete.name,
1786            oid,
1787        })
1788    }
1789
1790    fn delete_files_ref_checked(
1791        &self,
1792        delete: DeleteRef,
1793    ) -> std::result::Result<RefDelete, RefDeleteError> {
1794        let name = delete.name;
1795        let path = self.ref_path(&name);
1796        let parent = path.parent().ok_or(RefDeleteError::InvalidName)?;
1797        fs::create_dir_all(parent).map_err(RefDeleteError::from)?;
1798
1799        let loose_lock_path = lock_path_for(&path).map_err(|_| RefDeleteError::InvalidName)?;
1800        let _prune_guard = RefDirPruneGuard {
1801            store: self,
1802            name: name.clone(),
1803        };
1804        let loose_lock = DeleteLock::acquire(loose_lock_path)?;
1805
1806        let packed_path = self.storage_dir.join("packed-refs");
1807        let packed_lock_path =
1808            lock_path_for(&packed_path).map_err(|_| RefDeleteError::InvalidName)?;
1809        let mut packed_lock = DeleteLock::acquire(packed_lock_path)?;
1810
1811        let loose_ref = self
1812            .read_loose_ref(&name)
1813            .map_err(ref_delete_error_from_git)?;
1814        let packed_original = match fs::read(&packed_path) {
1815            Ok(bytes) => Some(bytes),
1816            Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
1817            Err(err) => return Err(RefDeleteError::Io(err)),
1818        };
1819        let mut packed_refs = match &packed_original {
1820            Some(bytes) => {
1821                parse_packed_refs(self.format, bytes).map_err(ref_delete_error_from_git)?
1822            }
1823            None => Vec::new(),
1824        };
1825        let packed_index = packed_refs
1826            .iter()
1827            .position(|reference| reference.reference.name == name);
1828
1829        let current = if let Some(reference) = loose_ref.as_ref() {
1830            Some(reference.target.clone())
1831        } else {
1832            packed_index.map(|index| packed_refs[index].reference.target.clone())
1833        };
1834        let oid = checked_delete_oid(delete.expected_old, current)?;
1835
1836        let packed_changed = if let Some(index) = packed_index {
1837            packed_refs.remove(index);
1838            true
1839        } else {
1840            false
1841        };
1842
1843        if packed_changed {
1844            let packed_bytes =
1845                write_packed_refs(&packed_refs).map_err(ref_delete_error_from_git)?;
1846            packed_lock.write_all(&packed_bytes)?;
1847            let lock_path = packed_lock.close();
1848            if let Err(err) = fs::rename(&lock_path, &packed_path) {
1849                let _ = fs::remove_file(&lock_path);
1850                return Err(RefDeleteError::Io(err));
1851            }
1852        } else {
1853            packed_lock.remove();
1854        }
1855
1856        if loose_ref.is_some()
1857            && let Err(err) = fs::remove_file(&path)
1858        {
1859            if packed_changed && let Some(bytes) = packed_original.as_ref() {
1860                let _ = restore_file_atomically(&packed_path, bytes);
1861            }
1862            return Err(RefDeleteError::Io(err));
1863        }
1864        loose_lock.remove();
1865
1866        // Git unlinks logs/refs/<name> on delete (pruning now-empty dirs); it
1867        // does not keep a deletion entry. Mirror delete_ref / delete_branch.
1868        self.remove_reflog_file(&name);
1869        Ok(RefDelete { name, oid })
1870    }
1871
1872    fn read_loose_ref(&self, name: &str) -> Result<Option<Ref>> {
1873        let path = self.ref_path(name);
1874        if !path.exists() {
1875            return Ok(None);
1876        }
1877        if path.is_dir() {
1878            return Ok(None);
1879        }
1880        Ok(Some(parse_loose_ref(self.format, name, &fs::read(path)?)?))
1881    }
1882
1883    fn read_packed_ref(&self, name: &str) -> Result<Option<PackedRef>> {
1884        let path = self.storage_dir.join("packed-refs");
1885        if !path.exists() {
1886            return Ok(None);
1887        }
1888        Ok(parse_packed_refs(self.format, &fs::read(path)?)?
1889            .into_iter()
1890            .find(|reference| reference.reference.name == name))
1891    }
1892
1893    fn read_reftable_ref(&self, name: &str) -> Result<Option<RefTarget>> {
1894        for table in self.reftables()?.into_iter().rev() {
1895            if let Some(record) = table.refs.into_iter().find(|record| record.name == name) {
1896                return reftable_ref_target(record.value);
1897            }
1898        }
1899        Ok(None)
1900    }
1901
1902    fn list_reftable_refs(&self) -> Result<Vec<Ref>> {
1903        self.list_reftable_refs_with_prefix("refs/")
1904    }
1905
1906    fn list_reftable_refs_with_prefix(&self, prefix: &str) -> Result<Vec<Ref>> {
1907        let mut refs = BTreeMap::<String, Ref>::new();
1908        for table in self.reftables()? {
1909            for record in table.refs {
1910                if !record.name.starts_with("refs/") || !record.name.starts_with(prefix) {
1911                    continue;
1912                }
1913                match reftable_ref_target(record.value)? {
1914                    Some(target) => {
1915                        refs.insert(
1916                            record.name.clone(),
1917                            Ref {
1918                                name: record.name,
1919                                target,
1920                            },
1921                        );
1922                    }
1923                    None => {
1924                        refs.remove(&record.name);
1925                    }
1926                }
1927            }
1928        }
1929        Ok(refs.into_values().collect())
1930    }
1931
1932    fn list_root_refs(&self) -> Result<Vec<Ref>> {
1933        if self.uses_reftable()? {
1934            let mut refs = BTreeMap::<String, Ref>::new();
1935            for table in self.reftables()? {
1936                for record in table.refs {
1937                    if record.name.starts_with("refs/") || !is_root_ref_syntax(&record.name) {
1938                        continue;
1939                    }
1940                    match reftable_ref_target(record.value)? {
1941                        Some(target) => {
1942                            refs.insert(
1943                                record.name.clone(),
1944                                Ref {
1945                                    name: record.name,
1946                                    target,
1947                                },
1948                            );
1949                        }
1950                        None => {
1951                            refs.remove(&record.name);
1952                        }
1953                    }
1954                }
1955            }
1956            return Ok(refs.into_values().collect());
1957        }
1958
1959        let mut refs = Vec::new();
1960        for entry in fs::read_dir(&self.git_dir)? {
1961            let entry = entry?;
1962            if !entry.file_type()?.is_file() {
1963                continue;
1964            }
1965            let name = entry.file_name().to_string_lossy().into_owned();
1966            if !is_root_ref_syntax(&name)
1967                || (name != "HEAD" && !name.ends_with("_HEAD"))
1968                || name == "FETCH_HEAD"
1969                || name == "MERGE_HEAD"
1970            {
1971                continue;
1972            }
1973            if let Ok(reference) = parse_loose_ref(self.format, name, &fs::read(entry.path())?) {
1974                refs.push(reference);
1975            }
1976        }
1977        refs.sort_by(|left, right| left.name.cmp(&right.name));
1978        Ok(refs)
1979    }
1980
1981    fn reftables(&self) -> Result<Vec<Reftable>> {
1982        let reftable_dir = self.storage_dir.join("reftable");
1983        let tables_list = reftable_dir.join("tables.list");
1984        for _ in 0..10 {
1985            let text = match fs::read_to_string(&tables_list) {
1986                Ok(text) => text,
1987                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1988                    if !tables_list.exists() {
1989                        return Ok(Vec::new());
1990                    }
1991                    thread::sleep(Duration::from_millis(10));
1992                    continue;
1993                }
1994                Err(err) => return Err(err.into()),
1995            };
1996            let mut tables = Vec::new();
1997            let mut reload = false;
1998            for raw_line in text.lines() {
1999                let line = raw_line.trim();
2000                if line.is_empty() {
2001                    continue;
2002                }
2003                if line.contains('/')
2004                    || line.contains('\\')
2005                    || Path::new(line).components().count() != 1
2006                {
2007                    return Err(GitError::InvalidPath(format!(
2008                        "invalid reftable table name {line}"
2009                    )));
2010                }
2011                let bytes = match fs::read(reftable_dir.join(line)) {
2012                    Ok(bytes) => bytes,
2013                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
2014                        reload = true;
2015                        break;
2016                    }
2017                    Err(err) => return Err(err.into()),
2018                };
2019                let table = Reftable::parse(&bytes)?;
2020                if table.header.object_format != self.format {
2021                    return Err(GitError::InvalidFormat(format!(
2022                        "reftable {line} has {} object ids in {} repository",
2023                        table.header.object_format.name(),
2024                        self.format.name()
2025                    )));
2026                }
2027                tables.push(table);
2028            }
2029            if reload {
2030                thread::sleep(Duration::from_millis(10));
2031                continue;
2032            }
2033            return Ok(tables);
2034        }
2035        Err(GitError::Io(format!(
2036            "cannot read stable reftable stack {}",
2037            tables_list.display()
2038        )))
2039    }
2040
2041    fn reftable_store_with_storage(&self, storage_dir: PathBuf) -> FileRefStore {
2042        FileRefStore {
2043            git_dir: self.git_dir.clone(),
2044            common_dir: self.common_dir.clone(),
2045            storage_dir,
2046            format: self.format,
2047            reftable_lock_timeout_millis: self.reftable_lock_timeout_millis,
2048            combine_reftable_logs: self.combine_reftable_logs,
2049        }
2050    }
2051
2052    /// Directory holding the *shared* (non-per-worktree) reftable stack.
2053    ///
2054    /// Normally this is the common dir, but an `extensions.refStorage` /
2055    /// `GIT_REFERENCE_BACKEND` URI with a `://path` payload relocates the
2056    /// stack to that path (e.g. `reftable:///abs/path`). `storage_dir` cannot
2057    /// stand in for this: for a *linked worktree* with no alternate path it is
2058    /// the per-worktree gitdir, not the shared stack. So resolve the shared
2059    /// location explicitly from the configured backend, mirroring the path
2060    /// resolution in `FileRefStore::new`. (Without this, shared refs under an
2061    /// alternate-path backend are looked up in the empty default `reftable/`.)
2062    fn shared_reftable_storage_dir(&self) -> PathBuf {
2063        match configured_ref_storage_backend(&self.common_dir) {
2064            Some((_, Some(path))) => path,
2065            _ => self.common_dir.clone(),
2066        }
2067    }
2068
2069    fn reftable_store_for_ref(&self, name: &str) -> Result<(FileRefStore, String)> {
2070        if let Some((worktree, rewritten)) = reftable_other_worktree_ref(name) {
2071            let storage_dir = self.common_dir.join("worktrees").join(worktree);
2072            return Ok((
2073                self.reftable_store_with_storage(storage_dir),
2074                rewritten.to_string(),
2075            ));
2076        }
2077        if reftable_current_worktree_ref(name) {
2078            return Ok((
2079                self.reftable_store_with_storage(self.git_dir.clone()),
2080                name.to_string(),
2081            ));
2082        }
2083        Ok((
2084            self.reftable_store_with_storage(self.shared_reftable_storage_dir()),
2085            name.to_string(),
2086        ))
2087    }
2088
2089    pub fn uses_reftable(&self) -> Result<bool> {
2090        if let Ok(value) = env::var("GIT_REFERENCE_BACKEND") {
2091            return Ok(parse_ref_storage_backend_value(&value)?.0 == RefBackendKind::Reftable);
2092        }
2093        let config_path = self.common_dir.join("config");
2094        if !config_path.exists() {
2095            return Ok(false);
2096        }
2097        let config = GitConfig::parse(&fs::read(config_path)?)?;
2098        let Some(value) = config.get("extensions", None, "refStorage") else {
2099            return Ok(false);
2100        };
2101        Ok(parse_ref_storage_backend_value(value)?.0 == RefBackendKind::Reftable)
2102    }
2103
2104    pub fn reftable_table_count(&self) -> Result<usize> {
2105        Ok(self.reftable_table_names()?.len())
2106    }
2107
2108    fn append_reftable_records(&self, records: Vec<ReftableRefRecord>) -> Result<()> {
2109        if records.is_empty() {
2110            return Ok(());
2111        }
2112        self.append_reftable_table(records, Vec::new())?;
2113        Ok(())
2114    }
2115
2116    fn next_reftable_update_index(&self, table_names: &[String]) -> Result<u64> {
2117        let reftable_dir = self.storage_dir.join("reftable");
2118        let mut max_update_index = 0;
2119        for name in table_names {
2120            let table = Reftable::parse(&fs::read(reftable_dir.join(name))?)?;
2121            max_update_index = max_update_index.max(table.header.max_update_index);
2122        }
2123        max_update_index
2124            .checked_add(1)
2125            .ok_or_else(|| GitError::InvalidFormat("reftable update index overflow".into()))
2126    }
2127
2128    /// Read the table list (file names) backing the reftable stack, oldest first.
2129    fn reftable_table_names(&self) -> Result<Vec<String>> {
2130        self.reftable_table_names_from(&self.storage_dir.join("reftable").join("tables.list"))
2131    }
2132
2133    fn reftable_table_names_from(&self, tables_list: &Path) -> Result<Vec<String>> {
2134        if !tables_list.exists() {
2135            return Ok(Vec::new());
2136        }
2137        Ok(fs::read_to_string(tables_list)?
2138            .lines()
2139            .map(str::trim)
2140            .filter(|line| !line.is_empty())
2141            .map(str::to_string)
2142            .collect())
2143    }
2144
2145    fn reftable_lock_timeout_millis(&self) -> Result<u64> {
2146        if let Some(timeout_millis) = self.reftable_lock_timeout_millis {
2147            return Ok(timeout_millis);
2148        }
2149        let config_path = self.common_dir.join("config");
2150        let Ok(config) = GitConfig::read(config_path) else {
2151            return Ok(0);
2152        };
2153        Ok(config
2154            .get("reftable", None, "lockTimeout")
2155            .and_then(|value| value.parse::<u64>().ok())
2156            .unwrap_or(0))
2157    }
2158
2159    fn acquire_reftable_list_lock(&self, list_path: PathBuf) -> Result<ReftableListLock> {
2160        let lock_path = lock_path_for(&list_path)?;
2161        let timeout_millis = self.reftable_lock_timeout_millis()?;
2162        ReftableListLock::acquire(list_path, lock_path, timeout_millis)
2163    }
2164
2165    /// Append a single combined ref+log table to the stack, allocating the next
2166    /// update index. Either slice may be empty (a log-only table for an
2167    /// `append_reflog` / `delete-reflog`, or a ref-only table for a plain ref
2168    /// write). Returns the allocated update index.
2169    fn append_reftable_table(
2170        &self,
2171        mut refs: Vec<ReftableRefRecord>,
2172        mut logs: Vec<ReftableLogRecord>,
2173    ) -> Result<u64> {
2174        let reftable_dir = self.storage_dir.join("reftable");
2175        fs::create_dir_all(&reftable_dir)?;
2176        let list_path = reftable_dir.join("tables.list");
2177        let list_lock = self.acquire_reftable_list_lock(list_path.clone())?;
2178        let mut table_names = self.reftable_table_names_from(&list_path)?;
2179        let update_index = self.next_reftable_update_index(&table_names)?;
2180        for record in &mut refs {
2181            record.update_index = update_index;
2182        }
2183        for record in &mut logs {
2184            record.update_index = update_index;
2185        }
2186        let table_name = reftable_table_name(update_index, update_index);
2187        let bytes = Reftable::write(self.format, update_index, update_index, &refs, &logs)?;
2188        let table_path = reftable_dir.join(&table_name);
2189        write_locked(&table_path, &bytes)?;
2190        self.apply_reftable_shared_file_mode(&table_path)?;
2191        table_names.push(table_name);
2192        let mut list = Vec::new();
2193        for name in &table_names {
2194            list.extend_from_slice(name.as_bytes());
2195            list.push(b'\n');
2196        }
2197        list_lock.commit(&list)?;
2198        self.apply_reftable_shared_file_mode(&list_path)?;
2199        if logs.is_empty() && table_names.len() > 6 {
2200            self.auto_compact_reftable_stack()?;
2201        }
2202        Ok(update_index)
2203    }
2204
2205    pub fn compact_reftable_stack(&self) -> Result<()> {
2206        let old_names = self.reftable_table_names()?;
2207        self.compact_reftable_stack_range(0, old_names.len(), true)
2208    }
2209
2210    fn compact_reftable_stack_range(
2211        &self,
2212        start: usize,
2213        end: usize,
2214        fail_on_locked_table: bool,
2215    ) -> Result<()> {
2216        let reftable_dir = self.storage_dir.join("reftable");
2217        let list_path = reftable_dir.join("tables.list");
2218        let list_lock = self.acquire_reftable_list_lock(list_path.clone())?;
2219        let old_names = self.reftable_table_names_from(&list_path)?;
2220        if start >= end || end > old_names.len() {
2221            return Ok(());
2222        }
2223        let compact_names = old_names[start..end].to_vec();
2224        if compact_names.is_empty() {
2225            return Ok(());
2226        }
2227        if fail_on_locked_table {
2228            for name in &compact_names {
2229                if reftable_dir.join(format!("{name}.lock")).exists() {
2230                    return Err(GitError::Io(format!(
2231                        "cannot lock references: {}: File exists",
2232                        reftable_dir.join(format!("{name}.lock")).display()
2233                    )));
2234                }
2235            }
2236        }
2237
2238        let mut refs: BTreeMap<String, ReftableRefRecord> = BTreeMap::new();
2239        let mut logs: BTreeMap<(String, u64), ReftableLogRecord> = BTreeMap::new();
2240        let mut min_index = u64::MAX;
2241        let mut max_index = 0u64;
2242        let drop_tombstones = start == 0;
2243        for name in &compact_names {
2244            let table = Reftable::parse(&fs::read(reftable_dir.join(name))?)?;
2245            for record in table.refs {
2246                match record.value {
2247                    ReftableRefValue::Deletion if drop_tombstones => {
2248                        refs.remove(&record.name);
2249                    }
2250                    _ => {
2251                        min_index = min_index.min(record.update_index);
2252                        max_index = max_index.max(record.update_index);
2253                        refs.insert(record.name.clone(), record);
2254                    }
2255                }
2256            }
2257            for record in table.logs {
2258                let key = (record.refname.clone(), record.update_index);
2259                match record.value {
2260                    ReftableLogValue::Deletion if drop_tombstones => {
2261                        logs.remove(&key);
2262                    }
2263                    _ => {
2264                        min_index = min_index.min(record.update_index);
2265                        max_index = max_index.max(record.update_index);
2266                        logs.insert(key, record);
2267                    }
2268                }
2269            }
2270        }
2271
2272        if refs.is_empty() && logs.is_empty() {
2273            min_index = compact_names
2274                .iter()
2275                .filter_map(|name| Reftable::parse(&fs::read(reftable_dir.join(name)).ok()?).ok())
2276                .map(|table| table.header.min_update_index)
2277                .min()
2278                .unwrap_or(1);
2279            max_index = min_index;
2280        }
2281
2282        let table_name = reftable_table_name(min_index, max_index);
2283        let refs = refs.into_values().collect::<Vec<_>>();
2284        let logs = logs.into_values().collect::<Vec<_>>();
2285        let bytes = Reftable::write(self.format, min_index, max_index, &refs, &logs)?;
2286        let table_path = reftable_dir.join(&table_name);
2287        write_locked(&table_path, &bytes)?;
2288        self.apply_reftable_shared_file_mode(&table_path)?;
2289        let mut list = Vec::new();
2290        for name in &old_names[..start] {
2291            list.extend_from_slice(name.as_bytes());
2292            list.push(b'\n');
2293        }
2294        list.extend_from_slice(table_name.as_bytes());
2295        list.push(b'\n');
2296        for name in &old_names[end..] {
2297            list.extend_from_slice(name.as_bytes());
2298            list.push(b'\n');
2299        }
2300        list_lock.commit(&list)?;
2301        self.apply_reftable_shared_file_mode(&list_path)?;
2302        for name in compact_names {
2303            if name != table_name {
2304                let _ = fs::remove_file(reftable_dir.join(name));
2305            }
2306        }
2307        Ok(())
2308    }
2309
2310    fn apply_reftable_shared_file_mode(&self, path: &Path) -> Result<()> {
2311        #[cfg(unix)]
2312        {
2313            use std::os::unix::fs::PermissionsExt;
2314
2315            let config_path = self.common_dir.join("config");
2316            let Ok(config) = GitConfig::read(config_path) else {
2317                return Ok(());
2318            };
2319            let Some(value) = config.get("core", None, "sharedRepository") else {
2320                return Ok(());
2321            };
2322            let mode_or = match value {
2323                "1" | "group" | "true" => 0o660,
2324                "2" | "all" | "world" | "everybody" => 0o664,
2325                _ => return Ok(()),
2326            };
2327            let metadata = fs::metadata(path)?;
2328            let old_mode = metadata.permissions().mode();
2329            let mut permissions = metadata.permissions();
2330            permissions.set_mode((old_mode | mode_or) & 0o7777);
2331            fs::set_permissions(path, permissions)?;
2332        }
2333        #[cfg(not(unix))]
2334        {
2335            let _ = path;
2336        }
2337        Ok(())
2338    }
2339
2340    fn auto_compact_reftable_stack(&self) -> Result<()> {
2341        if reftable_autocompaction_disabled() {
2342            return Ok(());
2343        }
2344        let names = self.reftable_table_names()?;
2345        if names.len() <= 1 {
2346            return Ok(());
2347        }
2348        let reftable_dir = self.storage_dir.join("reftable");
2349        let start = names
2350            .iter()
2351            .rposition(|name| reftable_dir.join(format!("{name}.lock")).exists())
2352            .map_or(0, |index| index + 1);
2353        if names.len().saturating_sub(start) > 1 {
2354            self.compact_reftable_stack_range(start, names.len(), false)?;
2355        }
2356        Ok(())
2357    }
2358
2359    /// Merge the log records for `name` across the whole stack into the reflog
2360    /// entries `git reflog` expects, in *oldest-first* order (the loose-file
2361    /// order callers reverse for display). Later tables in the stack override
2362    /// earlier ones for the same `(refname, update_index)`, deletions mask
2363    /// entries, and the old==new==null existence marker is dropped (it records
2364    /// reflog existence, not a real entry).
2365    fn read_reftable_logs(&self, name: &str) -> Result<Vec<ReflogEntry>> {
2366        // Collect the newest value for each update_index, honoring deletions.
2367        // update_index ascending == chronological order.
2368        let mut by_index: BTreeMap<u64, Option<ReftableLogUpdate>> = BTreeMap::new();
2369        for table in self.reftables()? {
2370            for record in table.logs {
2371                if record.refname != name {
2372                    continue;
2373                }
2374                match record.value {
2375                    ReftableLogValue::Deletion => {
2376                        by_index.insert(record.update_index, None);
2377                    }
2378                    ReftableLogValue::Update(update) => {
2379                        by_index.insert(record.update_index, Some(update));
2380                    }
2381                }
2382            }
2383        }
2384        let null = ObjectId::null(self.format);
2385        let mut entries = Vec::new();
2386        for update in by_index.into_values().flatten() {
2387            // Drop the existence marker (old==new==null): it is not a real entry.
2388            if update.old_oid == null && update.new_oid == null {
2389                continue;
2390            }
2391            entries.push(reflog_entry_from_reftable(update));
2392        }
2393        Ok(entries)
2394    }
2395
2396    fn collect_loose_refs(
2397        &self,
2398        dir: &Path,
2399        prefix: &str,
2400        refs: &mut BTreeMap<String, Ref>,
2401    ) -> Result<()> {
2402        for entry in fs::read_dir(dir)? {
2403            let entry = entry?;
2404            let path = entry.path();
2405            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
2406            if path.is_dir() {
2407                self.collect_loose_refs(&path, &name, refs)?;
2408            } else if !name.ends_with(".lock") {
2409                // git marks a loose ref whose content is unparseable, or that
2410                // resolves to the null OID, as REF_ISBROKEN and skips it from
2411                // iteration with a warning instead of aborting the whole walk.
2412                match parse_loose_ref(self.format, name.clone(), &fs::read(path)?) {
2413                    Ok(reference) if ref_target_is_broken(&reference.target) => {
2414                        warn_broken_ref(&name);
2415                    }
2416                    Ok(reference) => {
2417                        refs.insert(name, reference);
2418                    }
2419                    Err(_) => warn_broken_ref(&name),
2420                }
2421            }
2422        }
2423        Ok(())
2424    }
2425
2426    fn collect_loose_refs_with_prefix(
2427        &self,
2428        prefix: &str,
2429        refs: &mut BTreeMap<String, Ref>,
2430    ) -> Result<()> {
2431        if !safe_ref_prefix_for_directory_scan(prefix) {
2432            return self.collect_all_loose_refs(refs);
2433        }
2434
2435        let trimmed = prefix.trim_end_matches('/');
2436        if prefix.ends_with('/') {
2437            let candidate = self.storage_dir.join(trimmed);
2438            match fs::metadata(&candidate) {
2439                Ok(meta) if meta.is_dir() => {
2440                    self.collect_loose_refs(&candidate, trimmed, refs)?;
2441                    return Ok(());
2442                }
2443                Ok(_) => return Ok(()),
2444                Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2445                Err(err) => return Err(err.into()),
2446            }
2447        }
2448
2449        let Some((parent_prefix, _)) = trimmed.rsplit_once('/') else {
2450            return self.collect_all_loose_refs(refs);
2451        };
2452        let parent = self.storage_dir.join(parent_prefix);
2453        match fs::metadata(&parent) {
2454            Ok(meta) if meta.is_dir() => self.collect_loose_refs(&parent, parent_prefix, refs),
2455            Ok(_) => Ok(()),
2456            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2457            Err(err) => Err(err.into()),
2458        }
2459    }
2460
2461    fn collect_loose_ref_names(
2462        &self,
2463        dir: &Path,
2464        prefix: &str,
2465        names: &mut BTreeSet<String>,
2466    ) -> Result<()> {
2467        for entry in fs::read_dir(dir)? {
2468            let entry = entry?;
2469            let path = entry.path();
2470            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
2471            if path.is_dir() {
2472                self.collect_loose_ref_names(&path, &name, names)?;
2473            } else if !name.ends_with(".lock") {
2474                names.insert(name);
2475            }
2476        }
2477        Ok(())
2478    }
2479
2480    fn collect_loose_ref_names_with_prefix(
2481        &self,
2482        prefix: &str,
2483        names: &mut BTreeSet<String>,
2484    ) -> Result<()> {
2485        if !safe_ref_prefix_for_directory_scan(prefix) {
2486            return self.collect_all_loose_ref_names(names);
2487        }
2488
2489        let trimmed = prefix.trim_end_matches('/');
2490        if prefix.ends_with('/') {
2491            let candidate = self.storage_dir.join(trimmed);
2492            match fs::metadata(&candidate) {
2493                Ok(meta) if meta.is_dir() => {
2494                    self.collect_loose_ref_names(&candidate, trimmed, names)?;
2495                    return Ok(());
2496                }
2497                Ok(_) => return Ok(()),
2498                Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2499                Err(err) => return Err(err.into()),
2500            }
2501        }
2502
2503        let Some((parent_prefix, _)) = trimmed.rsplit_once('/') else {
2504            return self.collect_all_loose_ref_names(names);
2505        };
2506        let parent = self.storage_dir.join(parent_prefix);
2507        match fs::metadata(&parent) {
2508            Ok(meta) if meta.is_dir() => {
2509                self.collect_loose_ref_names(&parent, parent_prefix, names)
2510            }
2511            Ok(_) => Ok(()),
2512            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
2513            Err(err) => Err(err.into()),
2514        }
2515    }
2516
2517    fn collect_all_loose_ref_names(&self, names: &mut BTreeSet<String>) -> Result<()> {
2518        let refs_dir = self.storage_dir.join("refs");
2519        if refs_dir.exists() {
2520            self.collect_loose_ref_names(&refs_dir, "refs", names)?;
2521        }
2522        Ok(())
2523    }
2524
2525    fn collect_all_loose_refs(&self, refs: &mut BTreeMap<String, Ref>) -> Result<()> {
2526        let refs_dir = self.storage_dir.join("refs");
2527        if refs_dir.exists() {
2528            self.collect_loose_refs(&refs_dir, "refs", refs)?;
2529        }
2530        Ok(())
2531    }
2532
2533    fn collect_reflog_names(
2534        &self,
2535        dir: &Path,
2536        prefix: &str,
2537        names: &mut BTreeSet<String>,
2538    ) -> Result<()> {
2539        let Ok(entries) = fs::read_dir(dir) else {
2540            return Ok(());
2541        };
2542        for entry in entries {
2543            let entry = entry?;
2544            let path = entry.path();
2545            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
2546            if path.is_dir() {
2547                self.collect_reflog_names(&path, &name, names)?;
2548            } else if let Some(name) = name.strip_prefix("logs/") {
2549                names.insert(name.to_string());
2550            }
2551        }
2552        Ok(())
2553    }
2554
2555    fn loose_refs_have_prefix(&self, prefix: &str) -> Result<bool> {
2556        if !prefix.starts_with("refs/") || !prefix.ends_with('/') {
2557            return Ok(self
2558                .list_refs()?
2559                .iter()
2560                .any(|reference| reference.name.starts_with(prefix)));
2561        }
2562        let loose_prefix = prefix.trim_end_matches('/');
2563        let dir = self.common_dir.join(loose_prefix);
2564        match fs::metadata(&dir) {
2565            Ok(meta) if meta.is_dir() => {
2566                let mut refs = BTreeMap::new();
2567                self.collect_loose_refs(&dir, loose_prefix, &mut refs)?;
2568                Ok(!refs.is_empty())
2569            }
2570            Ok(_) => Ok(false),
2571            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
2572            Err(err) => Err(err.into()),
2573        }
2574    }
2575
2576    fn write_loose_ref(&self, reference: &Ref) -> Result<()> {
2577        if self.uses_reftable()? {
2578            let (store, name) = self.reftable_store_for_ref(&reference.name)?;
2579            store.append_reftable_records(vec![ReftableRefRecord {
2580                name,
2581                update_index: 0,
2582                value: reftable_value_from_ref_target(&reference.target),
2583            }])?;
2584            return Ok(());
2585        }
2586        let path = self.ref_path(&reference.name);
2587        let parent = path
2588            .parent()
2589            .ok_or_else(|| GitError::InvalidPath("ref path has no parent".into()))?;
2590        fs::create_dir_all(parent)?;
2591        write_locked(&path, &write_loose_ref(reference))
2592    }
2593
2594    fn delete_loose_ref(&self, name: &str) -> Result<()> {
2595        let path = self.ref_path(name);
2596        let lock_path = lock_path_for(&path)?;
2597        {
2598            let mut file = fs::OpenOptions::new()
2599                .write(true)
2600                .create_new(true)
2601                .open(&lock_path)?;
2602            file.write_all(b"delete\n")?;
2603            file.sync_all()?;
2604        }
2605        match fs::remove_file(&path) {
2606            Ok(()) => {
2607                fs::remove_file(lock_path)?;
2608                self.prune_empty_ref_dirs(name);
2609                Ok(())
2610            }
2611            Err(err) => {
2612                let _ = fs::remove_file(lock_path);
2613                Err(GitError::Io(err.to_string()))
2614            }
2615        }
2616    }
2617
2618    /// Remove now-empty parent directories left after deleting a loose ref,
2619    /// stopping at the `refs/` boundary. git does this so that, e.g., deleting
2620    /// `refs/heads/l/m` lets `refs/heads/l` be created as a file afterwards
2621    /// (t3200 #14). Pruning stops at the first non-empty directory and never
2622    /// removes the `refs` directory itself.
2623    fn prune_empty_ref_dirs(&self, name: &str) {
2624        let base = self.ref_base_dir(name).to_path_buf();
2625        let refs_root = base.join("refs");
2626        if let Some(parent) = self.ref_path(name).parent() {
2627            prune_empty_dirs_up_to(parent, &refs_root);
2628        }
2629    }
2630
2631    /// Remove a ref's reflog file and prune any empty parent directories it
2632    /// leaves behind under `logs/refs/`, stopping at the `logs/refs` boundary.
2633    /// Without this, deleting `refs/heads/l/m` leaves `logs/refs/heads/l/` and a
2634    /// later `refs/heads/l` cannot create its own `logs/refs/heads/l` reflog
2635    /// file (t3200 #14, #18).
2636    fn remove_reflog_file(&self, name: &str) {
2637        // Reftable repos keep the reflog inside the table stack, not as a loose
2638        // file: deleting a ref must tombstone its log records or `git stash
2639        // list` / `git reflog` keep surfacing them (t0610 'basic: stash').
2640        if matches!(self.uses_reftable(), Ok(true)) {
2641            let _ = self.tombstone_reftable_logs(name);
2642            return;
2643        }
2644        let path = self.reflog_path(name);
2645        let _ = fs::remove_file(&path);
2646        let base = self.ref_base_dir(name).to_path_buf();
2647        let logs_refs_root = base.join("logs").join("refs");
2648        if let Some(parent) = path.parent() {
2649            prune_empty_dirs_up_to(parent, &logs_refs_root);
2650        }
2651    }
2652
2653    /// Mask every live log record for `name` with deletion tombstones, so the
2654    /// reflog reads as absent. Mirrors git unlinking `logs/<name>` on the loose
2655    /// backend; the reftable analogue is an all-tombstone table.
2656    fn tombstone_reftable_logs(&self, name: &str) -> Result<()> {
2657        self.rewrite_reftable_logs(name, &[], false)
2658    }
2659
2660    /// Mirror git's `files_log_ref_write`: when a transaction updates a branch
2661    /// that `HEAD` symbolically points at, the same reflog entry is also written
2662    /// to `logs/HEAD`. Without this, `git reflog` (which reads the HEAD reflog)
2663    /// misses commits/merges/resets done on the checked-out branch.
2664    ///
2665    /// `head_branch` is HEAD's symref target captured **before** the transaction
2666    /// mutated any refs — using the post-apply value would mis-mirror a
2667    /// transaction that re-points HEAD onto the branch it just updated (e.g.
2668    /// rebase's finish step, which manages `logs/HEAD` itself). When the
2669    /// transaction explicitly updates `HEAD` it owns the HEAD reflog and nothing
2670    /// is mirrored.
2671    fn head_reflog_mirror(
2672        head_branch: Option<&str>,
2673        reflogs: &[(String, ReflogEntry)],
2674    ) -> Vec<(String, ReflogEntry)> {
2675        let Some(head_branch) = head_branch else {
2676            return Vec::new();
2677        };
2678        // A transaction that touches HEAD directly is managing the HEAD reflog
2679        // on its own terms (detach, rebase finish, checkout); don't double-write.
2680        if reflogs.iter().any(|(name, _)| name == "HEAD") {
2681            return Vec::new();
2682        }
2683        reflogs
2684            .iter()
2685            .filter(|(name, _)| name == head_branch)
2686            .map(|(_, entry)| ("HEAD".to_string(), entry.clone()))
2687            .collect()
2688    }
2689
2690    /// HEAD's symref target (`refs/heads/<branch>`) if HEAD is symbolic, else
2691    /// `None`. Read once at the start of a transaction commit so the HEAD-reflog
2692    /// mirror reflects the pre-transaction state.
2693    fn head_symref_target(&self) -> Option<String> {
2694        match self.read_ref("HEAD") {
2695            Ok(Some(RefTarget::Symbolic(branch))) => Some(branch),
2696            _ => None,
2697        }
2698    }
2699
2700    pub fn append_reflog(&self, name: &str, entry: &ReflogEntry) -> Result<()> {
2701        validate_ref_name_for_read(name)?;
2702        if self.uses_reftable()? {
2703            let update = reftable_update_from_reflog(entry)?;
2704            self.append_reftable_table(
2705                Vec::new(),
2706                vec![ReftableLogRecord {
2707                    refname: name.to_string(),
2708                    update_index: 0,
2709                    value: ReftableLogValue::Update(update),
2710                }],
2711            )?;
2712            self.auto_compact_reftable_stack()?;
2713            return Ok(());
2714        }
2715        let path = self.reflog_path(name);
2716        let parent = path
2717            .parent()
2718            .ok_or_else(|| GitError::InvalidPath("reflog path has no parent".into()))?;
2719        fs::create_dir_all(parent)?;
2720        let mut file = fs::OpenOptions::new()
2721            .create(true)
2722            .append(true)
2723            .open(path)?;
2724        file.write_all(&entry.to_line())?;
2725        Ok(())
2726    }
2727
2728    /// Replace the entire reflog for `name` in the reftable stack with `entries`
2729    /// (chronological, oldest first). Writes a single new table that tombstones
2730    /// every currently-live log update index for `name` and re-adds the desired
2731    /// entries at fresh update indexes that preserve their order. This is the
2732    /// stack-friendly analogue of rewriting a loose `logs/<name>` file — used by
2733    /// `write_reflog` / reflog expiry. An empty `entries` slice clears the
2734    /// reflog (an empty `git reflog`).
2735    fn rewrite_reftable_logs(
2736        &self,
2737        name: &str,
2738        entries: &[ReflogEntry],
2739        preserve_empty: bool,
2740    ) -> Result<()> {
2741        // Gather every update index that currently carries a live log record for
2742        // `name`, so we can mask them with deletion tombstones.
2743        let mut live_indexes: BTreeSet<u64> = BTreeSet::new();
2744        let mut deleted_indexes: BTreeSet<u64> = BTreeSet::new();
2745        for table in self.reftables()? {
2746            for record in table.logs {
2747                if record.refname != name {
2748                    continue;
2749                }
2750                match record.value {
2751                    ReftableLogValue::Deletion => {
2752                        deleted_indexes.insert(record.update_index);
2753                        live_indexes.remove(&record.update_index);
2754                    }
2755                    ReftableLogValue::Update(_) => {
2756                        live_indexes.insert(record.update_index);
2757                        deleted_indexes.remove(&record.update_index);
2758                    }
2759                }
2760            }
2761        }
2762
2763        let table_names = self.reftable_table_names()?;
2764        let base = self.next_reftable_update_index(&table_names)?;
2765        let mut logs: Vec<ReftableLogRecord> = Vec::new();
2766        // Tombstone the old entries at their original update indexes.
2767        for index in &live_indexes {
2768            logs.push(ReftableLogRecord {
2769                refname: name.to_string(),
2770                update_index: *index,
2771                value: ReftableLogValue::Deletion,
2772            });
2773        }
2774        // Re-add the survivors at fresh, monotonically increasing indexes so
2775        // their chronological order is preserved on the next read.
2776        for (offset, entry) in entries.iter().enumerate() {
2777            let update_index = base
2778                .checked_add(offset as u64)
2779                .ok_or_else(|| GitError::InvalidFormat("reftable update index overflow".into()))?;
2780            logs.push(ReftableLogRecord {
2781                refname: name.to_string(),
2782                update_index,
2783                value: ReftableLogValue::Update(reftable_update_from_reflog(entry)?),
2784            });
2785        }
2786        if entries.is_empty() && preserve_empty {
2787            let null = ObjectId::null(self.format);
2788            logs.push(ReftableLogRecord {
2789                refname: name.to_string(),
2790                update_index: base,
2791                value: ReftableLogValue::Update(ReftableLogUpdate {
2792                    old_oid: null,
2793                    new_oid: null,
2794                    name: String::new(),
2795                    email: String::new(),
2796                    time: 0,
2797                    tz_offset: 0,
2798                    message: String::new(),
2799                }),
2800            });
2801        }
2802        if logs.is_empty() {
2803            return Ok(());
2804        }
2805        let leave_empty_rewrite_tombstones_separate = entries.is_empty();
2806        if leave_empty_rewrite_tombstones_separate
2807            && !reftable_autocompaction_disabled()
2808            && self.reftable_table_names()?.len() > 1
2809        {
2810            self.compact_reftable_stack()?;
2811        }
2812        self.append_reftable_table_spanning(Vec::new(), logs)?;
2813        if !leave_empty_rewrite_tombstones_separate {
2814            self.auto_compact_reftable_stack()?;
2815        }
2816        Ok(())
2817    }
2818
2819    /// Like [`Self::append_reftable_table`] but the caller has already assigned
2820    /// each record's `update_index`; the new table's header `[min, max]` is set
2821    /// to span them (plus the freshly allocated index for any ref records). Used
2822    /// by reflog rewrites that mix old-index tombstones with new-index entries.
2823    fn append_reftable_table_spanning(
2824        &self,
2825        mut refs: Vec<ReftableRefRecord>,
2826        logs: Vec<ReftableLogRecord>,
2827    ) -> Result<u64> {
2828        let reftable_dir = self.storage_dir.join("reftable");
2829        fs::create_dir_all(&reftable_dir)?;
2830        let list_path = reftable_dir.join("tables.list");
2831        let list_lock = self.acquire_reftable_list_lock(list_path.clone())?;
2832        let mut table_names = self.reftable_table_names_from(&list_path)?;
2833        let alloc_index = self.next_reftable_update_index(&table_names)?;
2834        for record in &mut refs {
2835            record.update_index = alloc_index;
2836        }
2837        let mut min_index = alloc_index;
2838        let mut max_index = alloc_index;
2839        for record in &logs {
2840            min_index = min_index.min(record.update_index);
2841            max_index = max_index.max(record.update_index);
2842        }
2843        for record in &refs {
2844            min_index = min_index.min(record.update_index);
2845            max_index = max_index.max(record.update_index);
2846        }
2847        let table_name = reftable_table_name(min_index, max_index);
2848        let bytes = Reftable::write(self.format, min_index, max_index, &refs, &logs)?;
2849        let table_path = reftable_dir.join(&table_name);
2850        write_locked(&table_path, &bytes)?;
2851        self.apply_reftable_shared_file_mode(&table_path)?;
2852        table_names.push(table_name);
2853        let mut list = Vec::new();
2854        for name in &table_names {
2855            list.extend_from_slice(name.as_bytes());
2856            list.push(b'\n');
2857        }
2858        list_lock.commit(&list)?;
2859        self.apply_reftable_shared_file_mode(&list_path)?;
2860        Ok(max_index)
2861    }
2862
2863    fn ref_path(&self, name: &str) -> PathBuf {
2864        self.ref_base_dir(name).join(name)
2865    }
2866
2867    fn reflog_path(&self, name: &str) -> PathBuf {
2868        self.ref_base_dir(name).join("logs").join(name)
2869    }
2870
2871    fn ref_base_dir(&self, name: &str) -> &Path {
2872        if self.storage_dir != self.common_dir {
2873            return &self.storage_dir;
2874        }
2875        if is_root_ref_syntax(name)
2876            || name.starts_with("refs/worktree/")
2877            || name.starts_with("refs/rewritten/")
2878        {
2879            &self.git_dir
2880        } else {
2881            &self.common_dir
2882        }
2883    }
2884
2885    fn check_ref_directory_conflict_targeted(&self, name: &str) -> Result<()> {
2886        match self.refname_directory_conflict(name)? {
2887            Some(conflict) => Err(ref_directory_conflict_error(name, &conflict)),
2888            None => Ok(()),
2889        }
2890    }
2891
2892    /// Return the existing ref that would directory/file-conflict with creating
2893    /// `name` (an ancestor occupying a needed file, or a descendant occupying a
2894    /// needed directory), or `None` when `name` is creatable. Checks loose and
2895    /// packed refs. Exposed so `update-ref --stdin --batch-updates` can reject a
2896    /// single conflicting update without aborting the rest of the batch.
2897    pub fn refname_directory_conflict(&self, name: &str) -> Result<Option<String>> {
2898        let components = name.split('/').collect::<Vec<_>>();
2899        let mut ancestors = Vec::new();
2900        for index in 1..components.len() {
2901            let ancestor = components[..index].join("/");
2902            if self.loose_ref_file_exists_for_conflict(&ancestor)? {
2903                return Ok(Some(ancestor));
2904            }
2905            ancestors.push(ancestor);
2906        }
2907        let child_prefix = format!("{name}/");
2908        if let Some(existing) = self.first_loose_ref_name_with_prefix(&child_prefix)? {
2909            return Ok(Some(existing));
2910        }
2911        if let Some(existing) =
2912            self.first_packed_ref_directory_conflict(&ancestors, &child_prefix)?
2913        {
2914            return Ok(Some(existing));
2915        }
2916        Ok(None)
2917    }
2918
2919    fn loose_ref_file_exists_for_conflict(&self, name: &str) -> Result<bool> {
2920        let path = self.ref_path(name);
2921        match fs::symlink_metadata(path) {
2922            Ok(meta) => Ok(!meta.is_dir()),
2923            Err(err)
2924                if err.kind() == std::io::ErrorKind::NotFound
2925                    || err.kind() == std::io::ErrorKind::NotADirectory =>
2926            {
2927                Ok(false)
2928            }
2929            Err(err) => Err(err.into()),
2930        }
2931    }
2932
2933    fn first_packed_ref_directory_conflict(
2934        &self,
2935        ancestors: &[String],
2936        child_prefix: &str,
2937    ) -> Result<Option<String>> {
2938        let packed_path = self.storage_dir.join("packed-refs");
2939        let text = match fs::read_to_string(packed_path) {
2940            Ok(text) => text,
2941            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2942            Err(err) => return Err(err.into()),
2943        };
2944        for raw_line in text.lines() {
2945            let line = raw_line.trim_end();
2946            if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
2947                continue;
2948            }
2949            let (_, name) = line
2950                .split_once(' ')
2951                .ok_or_else(|| packed_refs_unexpected_line(line))?;
2952            if ancestors.iter().any(|ancestor| ancestor == name) || name.starts_with(child_prefix) {
2953                return Ok(Some(name.to_owned()));
2954            }
2955        }
2956        Ok(None)
2957    }
2958
2959    fn first_loose_ref_name_with_prefix(&self, prefix: &str) -> Result<Option<String>> {
2960        if !prefix.starts_with("refs/") || !prefix.ends_with('/') {
2961            return Ok(None);
2962        }
2963        let trimmed = prefix.trim_end_matches('/');
2964        let dir = self.ref_base_dir(trimmed).join(trimmed);
2965        self.first_loose_ref_name_in_dir(&dir, trimmed)
2966    }
2967
2968    fn first_loose_ref_name_in_dir(&self, dir: &Path, prefix: &str) -> Result<Option<String>> {
2969        let entries = match fs::read_dir(dir) {
2970            Ok(entries) => entries,
2971            Err(err)
2972                if err.kind() == std::io::ErrorKind::NotFound
2973                    || err.kind() == std::io::ErrorKind::NotADirectory =>
2974            {
2975                return Ok(None);
2976            }
2977            Err(err) => return Err(err.into()),
2978        };
2979        for entry in entries {
2980            let entry = entry?;
2981            let path = entry.path();
2982            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
2983            if path.is_dir() {
2984                if let Some(found) = self.first_loose_ref_name_in_dir(&path, &name)? {
2985                    return Ok(Some(found));
2986                }
2987            } else if !name.ends_with(".lock") {
2988                return Ok(Some(name));
2989            }
2990        }
2991        Ok(None)
2992    }
2993}
2994
2995fn reftable_ref_target(value: ReftableRefValue) -> Result<Option<RefTarget>> {
2996    match value {
2997        ReftableRefValue::Deletion => Ok(None),
2998        ReftableRefValue::Direct(oid) | ReftableRefValue::Peeled { target: oid, .. } => {
2999            Ok(Some(RefTarget::Direct(oid)))
3000        }
3001        ReftableRefValue::Symbolic(target) => Ok(Some(RefTarget::Symbolic(target))),
3002    }
3003}
3004
3005fn reftable_value_from_ref_target(target: &RefTarget) -> ReftableRefValue {
3006    match target {
3007        RefTarget::Direct(oid) => ReftableRefValue::Direct(*oid),
3008        RefTarget::Symbolic(target) => ReftableRefValue::Symbolic(target.clone()),
3009    }
3010}
3011
3012/// Reconstruct a `ReflogEntry`'s flat committer line from a reftable log update.
3013///
3014/// git's reftable backend stores the identity split into `name`/`email`/`time`/
3015/// `tz_offset` (refs/reftable-backend.c::fill_reftable_log_record) and rebuilds
3016/// `Name <email> time tz` on read. The loose reflog committer field is exactly
3017/// that string, so the entry round-trips byte-for-byte with the loose backend.
3018fn reflog_entry_from_reftable(update: ReftableLogUpdate) -> ReflogEntry {
3019    let committer = format!(
3020        "{} <{}> {} {}",
3021        update.name,
3022        update.email,
3023        update.time,
3024        format_reflog_tz(update.tz_offset),
3025    );
3026    // git stores reflog messages with a trailing newline in the reftable record
3027    // (refs/reftable-backend.c passes `u->msg`, which carries the `\n`). A loose
3028    // `ReflogEntry.message` is the newline-free form, so strip the single
3029    // trailing `\n` we add on write.
3030    let mut message = update.message.into_bytes();
3031    if message.last() == Some(&b'\n') {
3032        message.pop();
3033    }
3034    ReflogEntry {
3035        old_oid: update.old_oid,
3036        new_oid: update.new_oid,
3037        committer: committer.into_bytes(),
3038        message,
3039    }
3040}
3041
3042/// Split a flat reflog committer line (`Name <email> <seconds> <±HHMM>`) plus the
3043/// entry's oids/message into the reftable log update fields.
3044fn reftable_update_from_reflog(entry: &ReflogEntry) -> Result<ReftableLogUpdate> {
3045    let committer = std::str::from_utf8(&entry.committer)
3046        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
3047    let (name, email, time, tz_offset) = split_committer_ident(committer)?;
3048    let mut message = std::str::from_utf8(&entry.message)
3049        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
3050        .to_string();
3051    // git stores reflog messages with a trailing newline in reftable records;
3052    // `%gs` and the loose backend strip it. Add it back so git renders the
3053    // message identically across backends.
3054    if !message.ends_with('\n') {
3055        message.push('\n');
3056    }
3057    Ok(ReftableLogUpdate {
3058        old_oid: entry.old_oid,
3059        new_oid: entry.new_oid,
3060        name,
3061        email,
3062        time,
3063        tz_offset,
3064        message,
3065    })
3066}
3067
3068/// Parse `Name <email> <seconds> <±HHMM>` into the reftable log fields. Mirrors
3069/// git's `split_ident_line` semantics for the committer line: the display name
3070/// is everything before ` <`, the email is between the angle brackets, then the
3071/// unix timestamp and the signed `HHMM` timezone follow.
3072fn split_committer_ident(committer: &str) -> Result<(String, String, u64, i16)> {
3073    let open = committer.find(" <").ok_or_else(|| {
3074        GitError::InvalidFormat("reflog committer is missing email opener".into())
3075    })?;
3076    let name = committer[..open].to_string();
3077    let after_open = open + 2;
3078    let close = committer[after_open..].find('>').ok_or_else(|| {
3079        GitError::InvalidFormat("reflog committer is missing email closer".into())
3080    })?;
3081    let email = committer[after_open..after_open + close].to_string();
3082    let rest = committer[after_open + close + 1..].trim();
3083    let (time_str, tz_str) = rest.split_once(' ').ok_or_else(|| {
3084        GitError::InvalidFormat("reflog committer is missing timestamp/timezone".into())
3085    })?;
3086    let time = time_str
3087        .trim()
3088        .parse::<u64>()
3089        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
3090    let tz_offset = parse_reflog_tz(tz_str.trim())?;
3091    Ok((name, email, time, tz_offset))
3092}
3093
3094/// Format a reftable `tz_offset` (a signed `HHMM` value) as git renders it in a
3095/// committer line, e.g. `120 -> "+0200"`, `-300 -> "-0500"`.
3096fn format_reflog_tz(tz_offset: i16) -> String {
3097    let sign = if tz_offset < 0 { '-' } else { '+' };
3098    let magnitude = tz_offset.unsigned_abs();
3099    format!("{sign}{magnitude:04}")
3100}
3101
3102/// Parse a `±HHMM` timezone token into the raw signed `HHMM` value git stores in
3103/// a reftable log record (refs/reftable-backend.c parses it the same way).
3104fn parse_reflog_tz(tz: &str) -> Result<i16> {
3105    let (sign, digits) = match tz.strip_prefix('-') {
3106        Some(rest) => (-1i16, rest),
3107        None => (1i16, tz.strip_prefix('+').unwrap_or(tz)),
3108    };
3109    let magnitude = digits
3110        .parse::<i16>()
3111        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
3112    Ok(sign * magnitude)
3113}
3114
3115/// Build a reftable file name in git's exact `0x%012x-0x%012x-%08x.ref` shape
3116/// (reftable/stack.c::format_name). git's `table_has_valid_name` (reftable/fsck.c)
3117/// parses all three dash-separated components as hex, so the suffix MUST be a
3118/// pure 8-hex-digit token — a non-hex disambiguator like `-sley-<nanos>` makes
3119/// `git fsck` reject the table with `badReftableTableName`.
3120fn reftable_table_name(min_update_index: u64, max_update_index: u64) -> String {
3121    let nanos = SystemTime::now()
3122        .duration_since(UNIX_EPOCH)
3123        .map(|duration| duration.as_nanos())
3124        .unwrap_or(0);
3125    // Mix the process id in so concurrent writers in the same nanosecond still
3126    // pick distinct names; truncate to 32 bits to match git's `%08x`.
3127    let salt = (nanos as u64) ^ (u64::from(std::process::id()) << 16);
3128    format!(
3129        "0x{min_update_index:012x}-0x{max_update_index:012x}-{:08x}.ref",
3130        salt as u32
3131    )
3132}
3133
3134fn reftable_autocompaction_disabled() -> bool {
3135    std::env::var("GIT_TEST_REFTABLE_AUTOCOMPACTION")
3136        .is_ok_and(|value| value.eq_ignore_ascii_case("false"))
3137}
3138
3139/// Whether `name` parses as a valid reftable file name the way git's
3140/// `table_has_valid_name` (reftable/fsck.c) does: three hex tokens separated by
3141/// `-`, ending in `.ref` (or `.log`). Used to keep sley's generated names from
3142/// regressing into a shape `git fsck` would reject.
3143#[cfg(test)]
3144fn reftable_table_name_is_valid(name: &str) -> bool {
3145    fn hex_prefix(s: &str) -> Option<&str> {
3146        // strtoull(base 16) skips an optional leading 0x and consumes hex digits.
3147        let body = s
3148            .strip_prefix("0x")
3149            .or_else(|| s.strip_prefix("0X"))
3150            .unwrap_or(s);
3151        let consumed = body
3152            .find(|c: char| !c.is_ascii_hexdigit())
3153            .unwrap_or(body.len());
3154        if consumed == 0 {
3155            return None;
3156        }
3157        Some(&body[consumed..])
3158    }
3159    let Some(rest) = hex_prefix(name) else {
3160        return false;
3161    };
3162    let Some(rest) = rest.strip_prefix('-') else {
3163        return false;
3164    };
3165    let Some(rest) = hex_prefix(rest) else {
3166        return false;
3167    };
3168    let Some(rest) = rest.strip_prefix('-') else {
3169        return false;
3170    };
3171    let Some(rest) = hex_prefix(rest) else {
3172        return false;
3173    };
3174    rest == ".ref" || rest == ".log"
3175}
3176
3177fn repository_common_dir(git_dir: &Path) -> PathBuf {
3178    if let Some(common_dir) = std::env::var_os("GIT_COMMON_DIR") {
3179        return PathBuf::from(common_dir);
3180    }
3181    let commondir = git_dir.join("commondir");
3182    if let Ok(value) = fs::read_to_string(&commondir) {
3183        let path = PathBuf::from(value.trim());
3184        let common = if path.is_absolute() {
3185            path
3186        } else {
3187            git_dir.join(path)
3188        };
3189        return fs::canonicalize(&common).unwrap_or(common);
3190    }
3191    git_dir.to_path_buf()
3192}
3193
3194/// The phase a [`ReferenceTransactionHook`] is invoked for, mirroring the
3195/// `state` argument git passes to the `reference-transaction` hook
3196/// (`refs.c:run_transaction_hook`).
3197#[derive(Clone, Copy, PartialEq, Eq, Debug)]
3198pub enum RefTransactionPhase {
3199    /// Before references are locked. A nonzero hook exit aborts the
3200    /// transaction with `in 'preparing' phase, update aborted ...`.
3201    Preparing,
3202    /// After references are locked but before they are written. A nonzero
3203    /// hook exit aborts with `in 'prepared' phase, update aborted ...`.
3204    Prepared,
3205    /// After every ref change has landed. The hook's exit status is ignored.
3206    Committed,
3207    /// When a prepared transaction is rolled back. The exit status is ignored.
3208    Aborted,
3209}
3210
3211impl RefTransactionPhase {
3212    /// The literal `state` string git feeds as `argv[1]` to the hook.
3213    pub fn as_str(self) -> &'static str {
3214        match self {
3215            RefTransactionPhase::Preparing => "preparing",
3216            RefTransactionPhase::Prepared => "prepared",
3217            RefTransactionPhase::Committed => "committed",
3218            RefTransactionPhase::Aborted => "aborted",
3219        }
3220    }
3221}
3222
3223/// One queued ref change as the `reference-transaction` hook sees it: the
3224/// `<old-value> SP <new-value> SP <refname>` triple git writes to the hook's
3225/// stdin (`refs.c:transaction_hook_feed_stdin`). `old_value`/`new_value` are
3226/// already rendered the way git renders them — a 40/64-hex OID, the string
3227/// `ref:<target>` for a symref, or the all-zeros OID when the side is absent.
3228#[derive(Clone, Debug)]
3229pub struct RefTransactionHookUpdate {
3230    pub old_value: String,
3231    pub new_value: String,
3232    pub refname: String,
3233}
3234
3235/// A handler the file backend invokes at each phase of a ref transaction so the
3236/// CLI layer can run the project's `reference-transaction` hook. Implemented in
3237/// `sley-cli`; the backend stays oblivious to how (or whether) a hook script is
3238/// found and executed.
3239///
3240/// `run` returns `Ok(true)` to mean "the hook ran and requested an abort"
3241/// (nonzero exit in a `preparing`/`prepared` phase), `Ok(false)` to mean
3242/// "proceed" (hook absent, succeeded, or a non-abortable phase), and `Err` only
3243/// for an I/O failure spawning the hook. The backend turns an abort request in
3244/// the prepare phases into the git-shaped `in '<phase>' phase, update aborted by
3245/// the reference-transaction hook` failure.
3246pub trait ReferenceTransactionHook {
3247    fn run(&self, phase: RefTransactionPhase, updates: &[RefTransactionHookUpdate])
3248    -> Result<bool>;
3249}
3250
3251pub struct FileRefTransaction<'a> {
3252    store: &'a FileRefStore,
3253    changes: Vec<QueuedRefChange>,
3254    hook: Option<&'a dyn ReferenceTransactionHook>,
3255}
3256
3257/// One queued update inside a [`FileRefTransaction`], carrying the
3258/// compare-and-swap precondition to enforce under lock.
3259struct QueuedUpdate {
3260    name: String,
3261    precondition: RefPrecondition,
3262    new: RefTarget,
3263    reflog: Option<ReflogEntry>,
3264}
3265
3266struct QueuedDelete {
3267    name: String,
3268    precondition: RefDeletePrecondition,
3269}
3270
3271enum QueuedRefChange {
3272    Update(QueuedUpdate),
3273    Delete(QueuedDelete),
3274}
3275
3276/// The compare-and-delete precondition checked for a queued ref delete.
3277#[derive(Debug, Clone, PartialEq, Eq)]
3278pub enum RefDeletePrecondition {
3279    /// Any existing direct or symbolic ref may be deleted.
3280    Any,
3281    /// The ref's immediate target must match exactly.
3282    Immediate(RefTarget),
3283    /// The ref must be direct. When an object id is supplied, it must match.
3284    Direct(Option<ObjectId>),
3285    /// The ref may be symbolic, but its peeled direct target must match.
3286    Peeled(ObjectId),
3287}
3288
3289impl<'a> FileRefTransaction<'a> {
3290    /// Attach the `reference-transaction` hook handler this transaction fires at
3291    /// each phase. Without one the transaction behaves exactly as before (no
3292    /// hook is run). This is the single point through which every ref-write path
3293    /// — `update-ref`, `symbolic-ref`, `update-ref --stdin`, push — gets hook
3294    /// coverage, so a new write site cannot silently skip the hook.
3295    pub fn with_hook(mut self, hook: &'a dyn ReferenceTransactionHook) -> Self {
3296        self.hook = Some(hook);
3297        self
3298    }
3299
3300    /// Queue a ref update whose precondition comes from [`RefUpdate::expected`]
3301    /// (`None` = no check; `Some(target)` = the ref must currently match
3302    /// `target`). For create-only or match-or-create semantics use
3303    /// [`update_to`](FileRefTransaction::update_to).
3304    pub fn update(&mut self, update: RefUpdate) {
3305        self.changes.push(QueuedRefChange::Update(QueuedUpdate {
3306            name: update.name,
3307            precondition: RefPrecondition::from_expected(update.expected),
3308            new: update.new,
3309            reflog: update.reflog,
3310        }));
3311    }
3312
3313    /// Queue a ref update with an explicit compare-and-swap [`RefPrecondition`]
3314    /// (e.g. [`MustNotExist`](RefPrecondition::MustNotExist) for create-only, or
3315    /// [`ExistingMustMatch`](RefPrecondition::ExistingMustMatch) for
3316    /// match-or-create). The precondition is re-verified while the ref is
3317    /// locked.
3318    pub fn update_to(
3319        &mut self,
3320        name: impl Into<String>,
3321        new: RefTarget,
3322        precondition: RefPrecondition,
3323        reflog: Option<ReflogEntry>,
3324    ) {
3325        self.changes.push(QueuedRefChange::Update(QueuedUpdate {
3326            name: name.into(),
3327            precondition,
3328            new,
3329            reflog,
3330        }));
3331    }
3332
3333    /// Queue a direct ref delete using the historical checked-delete shape.
3334    ///
3335    /// `expected_old = None` means "delete any direct ref"; `Some(oid)` means
3336    /// the direct ref must currently point at that object id.
3337    pub fn delete(&mut self, delete: DeleteRef) {
3338        self.delete_with_precondition(
3339            delete.name,
3340            RefDeletePrecondition::Direct(delete.expected_old),
3341            delete.reflog,
3342        );
3343    }
3344
3345    /// Queue a ref delete with an explicit direct/symbolic precondition.
3346    ///
3347    /// `_reflog` is accepted for API compatibility but ignored: git unlinks the
3348    /// reflog on delete rather than writing a deletion entry, so a
3349    /// caller-supplied deletion message has no on-disk effect.
3350    pub fn delete_with_precondition(
3351        &mut self,
3352        name: impl Into<String>,
3353        precondition: RefDeletePrecondition,
3354        _reflog: Option<DeleteRefReflog>,
3355    ) {
3356        self.changes.push(QueuedRefChange::Delete(QueuedDelete {
3357            name: name.into(),
3358            precondition,
3359        }));
3360    }
3361
3362    /// Commit all queued updates and deletes atomically and durably.
3363    ///
3364    /// All ref changes succeed together or none take effect. For the loose-ref
3365    /// backend the sequence is:
3366    ///
3367    /// 1. Preserve the historical update-only coalescing behavior. Mixed
3368    ///    transactions reject duplicate ref names so a delete and write cannot
3369    ///    target the same ref ambiguously.
3370    /// 2. Take an exclusive `<ref>.lock` file for every ref up front, and lock
3371    ///    `packed-refs` before checked deletes can inspect or rewrite it.
3372    /// 3. Re-verify every precondition *while holding the locks*, closing
3373    ///    the check-then-write race that a pre-lock verification would leave open.
3374    /// 4. Stage every write, delete marker, and packed-refs rewrite.
3375    /// 5. Rename/remove staged paths, rolling back already-applied paths if a
3376    ///    later step fails.
3377    ///
3378    /// If any step fails, every path already changed in this commit is restored
3379    /// to the exact bytes it held beforehand (or removed if it did not exist),
3380    /// and all outstanding lock files are deleted. Reflog entries are appended
3381    /// only after every ref change has landed.
3382    pub fn commit(self) -> Result<()> {
3383        let FileRefTransaction {
3384            store,
3385            changes,
3386            hook,
3387        } = self;
3388        let changes = coalesce_ref_changes(changes)?;
3389        // Derive the `<old> <new> <refname>` lines the reference-transaction
3390        // hook sees, in the same coalesced order the writes apply. This is the
3391        // single place the hook is fed, so loose, packed, and symref updates all
3392        // flow through one firing path (git's run_transaction_hook).
3393        let hook_updates = hook.map(|_| hook_updates_for_changes(store.format, &changes));
3394        if let (Some(hook), Some(updates)) = (hook, hook_updates.as_ref())
3395            && hook.run(RefTransactionPhase::Preparing, updates)?
3396        {
3397            return Err(ref_transaction_hook_abort(RefTransactionPhase::Preparing));
3398        }
3399        if store.uses_reftable()? {
3400            store.commit_reftable(changes)
3401        } else {
3402            store.commit_loose_hooked(changes, hook, hook_updates.as_deref())
3403        }
3404    }
3405}
3406
3407/// The git-shaped fatal raised when the `reference-transaction` hook requests an
3408/// abort in the `preparing`/`prepared` phase (`refs.c:abort_by_ref_transaction_hook`).
3409fn ref_transaction_hook_abort(phase: RefTransactionPhase) -> GitError {
3410    GitError::Transaction(format!(
3411        "in '{}' phase, update aborted by the reference-transaction hook",
3412        phase.as_str()
3413    ))
3414}
3415
3416/// Render the per-update hook lines for a coalesced change set, matching git's
3417/// `transaction_hook_feed_stdin`: the old side is `null_oid` when no old value
3418/// was required, `ref:<target>` for a symref precondition, else the expected
3419/// OID; the new side is `null_oid` for a delete, `ref:<target>` for a new
3420/// symref, else the new OID.
3421fn hook_updates_for_changes(
3422    format: ObjectFormat,
3423    changes: &[CoalescedRefChange],
3424) -> Vec<RefTransactionHookUpdate> {
3425    let zero = ObjectId::null(format).to_string();
3426    changes
3427        .iter()
3428        .map(|change| match change {
3429            CoalescedRefChange::Update(update) => RefTransactionHookUpdate {
3430                old_value: hook_old_value(&zero, &update.precondition),
3431                new_value: hook_target_value(&zero, Some(&update.new)),
3432                refname: update.name.clone(),
3433            },
3434            CoalescedRefChange::Delete(delete) => RefTransactionHookUpdate {
3435                old_value: hook_delete_old_value(&zero, &delete.precondition),
3436                new_value: zero.clone(),
3437                refname: delete.name.clone(),
3438            },
3439        })
3440        .collect()
3441}
3442
3443/// The hook's `<old-value>` for an update: git prints `null_oid` unless the
3444/// caller supplied an old value (`REF_HAVE_OLD`), in which case it is the
3445/// expected target (a `ref:` for a symref expectation, else the OID).
3446fn hook_old_value(zero: &str, precondition: &RefPrecondition) -> String {
3447    match precondition {
3448        RefPrecondition::Any | RefPrecondition::MustExist => zero.to_string(),
3449        RefPrecondition::MustNotExist => zero.to_string(),
3450        RefPrecondition::MustExistAndMatch(target) | RefPrecondition::ExistingMustMatch(target) => {
3451            hook_target_value(zero, Some(target))
3452        }
3453    }
3454}
3455
3456/// The hook's `<old-value>` for a delete: git renders the supplied old OID, or
3457/// `null_oid` when none was required.
3458fn hook_delete_old_value(zero: &str, precondition: &RefDeletePrecondition) -> String {
3459    match precondition {
3460        RefDeletePrecondition::Any => zero.to_string(),
3461        RefDeletePrecondition::Immediate(target) => hook_target_value(zero, Some(target)),
3462        RefDeletePrecondition::Direct(Some(oid)) | RefDeletePrecondition::Peeled(oid) => {
3463            oid.to_string()
3464        }
3465        RefDeletePrecondition::Direct(None) => zero.to_string(),
3466    }
3467}
3468
3469/// Render a [`RefTarget`] the way git renders a hook value: `ref:<target>` for a
3470/// symref, the bare OID for a direct ref, or `null_oid` when absent.
3471fn hook_target_value(zero: &str, target: Option<&RefTarget>) -> String {
3472    match target {
3473        None => zero.to_string(),
3474        Some(RefTarget::Direct(oid)) => oid.to_string(),
3475        Some(RefTarget::Symbolic(name)) => format!("ref:{name}"),
3476    }
3477}
3478
3479impl FileRefStore {
3480    fn commit_reftable(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
3481        let routed = self.route_reftable_changes(changes)?;
3482        if routed.len() != 1 || routed[0].0.storage_dir != self.storage_dir {
3483            for (store, changes) in routed {
3484                store.commit_reftable_local(changes)?;
3485            }
3486            return Ok(());
3487        }
3488        let mut routed = routed;
3489        if let Some((_, changes)) = routed.pop() {
3490            self.commit_reftable_local(changes)
3491        } else {
3492            Ok(())
3493        }
3494    }
3495
3496    fn commit_reftable_local(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
3497        // Capture HEAD's symref target only when there is a reflog to mirror.
3498        let has_reflogs = changes.iter().any(|change| {
3499            matches!(change, CoalescedRefChange::Update(update) if !update.reflog.is_empty())
3500        });
3501        let head_branch = if has_reflogs {
3502            self.head_symref_target()
3503        } else {
3504            None
3505        };
3506        if changes
3507            .iter()
3508            .any(|change| matches!(change, CoalescedRefChange::Update(_)))
3509        {
3510            let mut names = self
3511                .list_refs()?
3512                .into_iter()
3513                .map(|reference| reference.name)
3514                .collect::<BTreeSet<_>>();
3515            for change in &changes {
3516                if let CoalescedRefChange::Update(update) = change {
3517                    names.insert(update.name.clone());
3518                }
3519            }
3520            for change in &changes {
3521                if let CoalescedRefChange::Update(update) = change {
3522                    check_ref_directory_conflict_in_names(&update.name, &names)?;
3523                }
3524            }
3525        }
3526        let mut records = Vec::with_capacity(changes.len());
3527        let mut reflogs = Vec::new();
3528        let mut delete_names = Vec::new();
3529        for change in changes {
3530            match change {
3531                CoalescedRefChange::Update(update) => {
3532                    if !matches!(update.precondition, RefPrecondition::Any) {
3533                        let current = self.read_ref(&update.name)?;
3534                        if !update.precondition.is_satisfied_by(current.as_ref()) {
3535                            return Err(GitError::Transaction(
3536                                update.precondition.describe(&update.name),
3537                            ));
3538                        }
3539                    }
3540                    records.push(ReftableRefRecord {
3541                        name: update.name.clone(),
3542                        update_index: 0,
3543                        value: reftable_value_from_ref_target(&update.new),
3544                    });
3545                    for entry in update.reflog {
3546                        reflogs.push((update.name.clone(), entry));
3547                    }
3548                }
3549                CoalescedRefChange::Delete(delete) => {
3550                    let current = self.read_ref(&delete.name)?;
3551                    // Enforce the precondition; git unlinks logs/refs/<name> on
3552                    // delete rather than appending a deletion reflog entry, so the
3553                    // returned OID is unused.
3554                    verify_delete_precondition(
3555                        self,
3556                        &delete.name,
3557                        current.as_ref(),
3558                        &delete.precondition,
3559                    )?;
3560                    records.push(ReftableRefRecord {
3561                        name: delete.name.clone(),
3562                        update_index: 0,
3563                        value: ReftableRefValue::Deletion,
3564                    });
3565                    delete_names.push(delete.name.clone());
3566                }
3567            }
3568        }
3569        if self.combine_reftable_logs || self.git_dir != self.common_dir {
3570            let head_mirror = Self::head_reflog_mirror(head_branch.as_deref(), &reflogs);
3571            reflogs.extend(head_mirror);
3572            let log_records = reflogs
3573                .into_iter()
3574                .map(|(name, entry)| {
3575                    Ok(ReftableLogRecord {
3576                        refname: name,
3577                        update_index: 0,
3578                        value: ReftableLogValue::Update(reftable_update_from_reflog(&entry)?),
3579                    })
3580                })
3581                .collect::<Result<Vec<_>>>()?;
3582            self.append_reftable_table(records, log_records)?;
3583            for name in &delete_names {
3584                self.remove_reflog_file(name);
3585            }
3586            return Ok(());
3587        }
3588        self.append_reftable_records(records)?;
3589        // Git unlinks logs/refs/<name> (pruning now-empty dirs) on delete; do
3590        // this before appending update reflogs so a delete+recreate does not race
3591        // the new ref's reflog file.
3592        for name in &delete_names {
3593            self.remove_reflog_file(name);
3594        }
3595        let head_mirror = Self::head_reflog_mirror(head_branch.as_deref(), &reflogs);
3596        reflogs.extend(head_mirror);
3597        for (name, entry) in reflogs {
3598            self.append_reflog(&name, &entry)?;
3599        }
3600        Ok(())
3601    }
3602
3603    fn route_reftable_changes(
3604        &self,
3605        changes: Vec<CoalescedRefChange>,
3606    ) -> Result<Vec<(FileRefStore, Vec<CoalescedRefChange>)>> {
3607        let mut grouped = BTreeMap::<PathBuf, (FileRefStore, Vec<CoalescedRefChange>)>::new();
3608        for change in changes {
3609            let name = match &change {
3610                CoalescedRefChange::Update(update) => update.name.as_str(),
3611                CoalescedRefChange::Delete(delete) => delete.name.as_str(),
3612            };
3613            let (store, rewritten) = self.reftable_store_for_ref(name)?;
3614            let rewritten_change = match change {
3615                CoalescedRefChange::Update(mut update) => {
3616                    update.name = rewritten;
3617                    CoalescedRefChange::Update(update)
3618                }
3619                CoalescedRefChange::Delete(mut delete) => {
3620                    delete.name = rewritten;
3621                    CoalescedRefChange::Delete(delete)
3622                }
3623            };
3624            grouped
3625                .entry(store.storage_dir.clone())
3626                .or_insert_with(|| (store, Vec::new()))
3627                .1
3628                .push(rewritten_change);
3629        }
3630        Ok(grouped.into_values().collect())
3631    }
3632
3633    /// Atomic, all-or-nothing commit for the loose-ref backend. See
3634    /// [`FileRefTransaction::commit`] for the full ordering and rollback rules.
3635    #[allow(dead_code)]
3636    fn commit_loose(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
3637        self.commit_loose_hooked(changes, None, None)
3638    }
3639
3640    /// As [`commit_loose`](Self::commit_loose) but firing the
3641    /// `reference-transaction` hook at `prepared` (after every ref is locked and
3642    /// staged, before any rename) and `committed` (after every change lands).
3643    /// A nonzero hook exit in the `prepared` phase rolls the staged changes back
3644    /// and surfaces the git-shaped abort error.
3645    fn commit_loose_hooked(
3646        &self,
3647        changes: Vec<CoalescedRefChange>,
3648        hook: Option<&dyn ReferenceTransactionHook>,
3649        hook_updates: Option<&[RefTransactionHookUpdate]>,
3650    ) -> Result<()> {
3651        // Capture HEAD's symref target only when there is a reflog to mirror.
3652        let has_reflogs = changes.iter().any(|change| {
3653            matches!(change, CoalescedRefChange::Update(update) if !update.reflog.is_empty())
3654        });
3655        let head_branch = if has_reflogs {
3656            self.head_symref_target()
3657        } else {
3658            None
3659        };
3660        let has_delete = changes
3661            .iter()
3662            .any(|change| matches!(change, CoalescedRefChange::Delete(_)));
3663        let update_count = changes
3664            .iter()
3665            .filter(|change| matches!(change, CoalescedRefChange::Update(_)))
3666            .count();
3667        let targeted_conflict_check = update_count == 1 && !has_delete;
3668        let conflict_names = if update_count > 0 && !targeted_conflict_check {
3669            let mut names = self
3670                .list_refs()?
3671                .into_iter()
3672                .map(|reference| reference.name)
3673                .collect::<BTreeSet<_>>();
3674            for change in &changes {
3675                if let CoalescedRefChange::Update(update) = change {
3676                    names.insert(update.name.clone());
3677                }
3678            }
3679            Some(names)
3680        } else {
3681            None
3682        };
3683        let mut pending = Vec::with_capacity(changes.len() + usize::from(has_delete));
3684        // Acquire every lock first; bail (releasing what we hold) on any failure.
3685        for change in &changes {
3686            let name = change.name();
3687            if matches!(change, CoalescedRefChange::Update(_)) {
3688                let conflict_result = if targeted_conflict_check {
3689                    self.check_ref_directory_conflict_targeted(name)
3690                } else if let Some(conflict_names) = conflict_names.as_ref() {
3691                    check_ref_directory_conflict_in_names(name, conflict_names)
3692                } else {
3693                    Ok(())
3694                };
3695                if let Err(err) = conflict_result {
3696                    release_pending_locks(&pending);
3697                    return Err(err);
3698                }
3699            }
3700            let path = self.ref_path(name);
3701            let parent = path
3702                .parent()
3703                .ok_or_else(|| GitError::InvalidPath("ref path has no parent".into()))?;
3704            if let Err(err) = fs::create_dir_all(parent) {
3705                release_pending_locks(&pending);
3706                if err.kind() == std::io::ErrorKind::NotADirectory {
3707                    return Err(ref_directory_conflict_error(
3708                        name,
3709                        &parent_to_ref_name(self.ref_base_dir(name), parent),
3710                    ));
3711                }
3712                return Err(GitError::Io(err.to_string()));
3713            }
3714            let lock_path = match lock_path_for(&path) {
3715                Ok(lock_path) => lock_path,
3716                Err(err) => {
3717                    release_pending_locks(&pending);
3718                    return Err(err);
3719                }
3720            };
3721            if let Err(err) = fs::OpenOptions::new()
3722                .write(true)
3723                .create_new(true)
3724                .open(&lock_path)
3725            {
3726                release_pending_locks(&pending);
3727                return Err(GitError::Io(format!("could not lock ref {name}: {err}")));
3728            }
3729            let action = match change {
3730                CoalescedRefChange::Update(update) => PendingPathAction::Write {
3731                    contents: write_loose_ref(&Ref {
3732                        name: update.name.clone(),
3733                        target: update.new.clone(),
3734                    }),
3735                },
3736                CoalescedRefChange::Delete(_) => PendingPathAction::Delete,
3737            };
3738            pending.push(PendingPathChange {
3739                name: name.to_string(),
3740                path,
3741                lock_path,
3742                original: None,
3743                action,
3744            });
3745        }
3746
3747        let packed_path = self.storage_dir.join("packed-refs");
3748        let mut packed_refs = Vec::new();
3749        let mut use_packed_snapshot = false;
3750        if has_delete {
3751            let packed_lock_path = match lock_path_for(&packed_path) {
3752                Ok(lock_path) => lock_path,
3753                Err(err) => {
3754                    release_pending_locks(&pending);
3755                    return Err(err);
3756                }
3757            };
3758            if let Err(err) = fs::OpenOptions::new()
3759                .write(true)
3760                .create_new(true)
3761                .open(&packed_lock_path)
3762            {
3763                release_pending_locks(&pending);
3764                return Err(GitError::Io(format!("could not lock packed-refs: {err}")));
3765            }
3766            let packed_original = match fs::read(&packed_path) {
3767                Ok(bytes) => Some(bytes),
3768                Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
3769                Err(err) => {
3770                    release_pending_locks(&pending);
3771                    let _ = fs::remove_file(&packed_lock_path);
3772                    return Err(GitError::Io(err.to_string()));
3773                }
3774            };
3775            packed_refs = match &packed_original {
3776                Some(bytes) => match parse_packed_refs(self.format, bytes) {
3777                    Ok(refs) => refs,
3778                    Err(err) => {
3779                        release_pending_locks(&pending);
3780                        let _ = fs::remove_file(&packed_lock_path);
3781                        return Err(err);
3782                    }
3783                },
3784                None => Vec::new(),
3785            };
3786            use_packed_snapshot = true;
3787            pending.push(PendingPathChange {
3788                name: "packed-refs".into(),
3789                path: packed_path.clone(),
3790                lock_path: packed_lock_path,
3791                original: packed_original,
3792                action: PendingPathAction::ReleaseLock,
3793            });
3794        } else if packed_path.exists() {
3795            packed_refs = parse_packed_refs(self.format, &fs::read(&packed_path)?)?;
3796            use_packed_snapshot = true;
3797        }
3798        let packed_ref_targets = packed_refs
3799            .iter()
3800            .map(|reference| {
3801                (
3802                    reference.reference.name.clone(),
3803                    reference.reference.target.clone(),
3804                )
3805            })
3806            .collect::<HashMap<_, _>>();
3807
3808        // Verify expectations under lock, then capture prior on-disk state for
3809        // rollback. Mixed transactions read packed refs from the snapshot held
3810        // behind packed-refs.lock so deletes cannot race a packed rewrite.
3811        let mut reflogs = Vec::new();
3812        let mut delete_names = BTreeSet::new();
3813        for index in 0..changes.len() {
3814            match &changes[index] {
3815                CoalescedRefChange::Update(update) => {
3816                    let current = if use_packed_snapshot {
3817                        match self.read_ref_from_packed_snapshot(&update.name, &packed_ref_targets)
3818                        {
3819                            Ok(current) => current,
3820                            Err(err) => {
3821                                release_pending_locks(&pending);
3822                                return Err(err);
3823                            }
3824                        }
3825                    } else {
3826                        match self.read_ref(&update.name) {
3827                            Ok(current) => current,
3828                            Err(err) => {
3829                                release_pending_locks(&pending);
3830                                return Err(err);
3831                            }
3832                        }
3833                    };
3834                    if !matches!(update.precondition, RefPrecondition::Any)
3835                        && !update.precondition.is_satisfied_by(current.as_ref())
3836                    {
3837                        release_pending_locks(&pending);
3838                        return Err(GitError::Transaction(
3839                            update.precondition.describe(&update.name),
3840                        ));
3841                    }
3842                    pending[index].original = match read_optional_file(&pending[index].path) {
3843                        Ok(original) => original,
3844                        Err(err) => {
3845                            release_pending_locks(&pending);
3846                            return Err(err);
3847                        }
3848                    };
3849                    if pending[index].original.is_none() && current.as_ref() == Some(&update.new) {
3850                        pending[index].action = PendingPathAction::ReleaseLock;
3851                    }
3852                    for entry in &update.reflog {
3853                        reflogs.push((update.name.clone(), entry.clone()));
3854                    }
3855                }
3856                CoalescedRefChange::Delete(delete) => {
3857                    let state = match self.read_locked_ref_state(&delete.name, &packed_ref_targets)
3858                    {
3859                        Ok(state) => state,
3860                        Err(err) => {
3861                            release_pending_locks(&pending);
3862                            return Err(err);
3863                        }
3864                    };
3865                    // Enforce the delete precondition under lock; the returned
3866                    // OID is unused because git unlinks logs/refs/<name> on
3867                    // delete rather than appending a deletion reflog entry.
3868                    if let Err(err) = verify_delete_precondition(
3869                        self,
3870                        &delete.name,
3871                        state.current.as_ref(),
3872                        &delete.precondition,
3873                    ) {
3874                        release_pending_locks(&pending);
3875                        return Err(err);
3876                    }
3877                    pending[index].original = if state.has_loose {
3878                        match read_optional_file(&pending[index].path) {
3879                            Ok(original) => original,
3880                            Err(err) => {
3881                                release_pending_locks(&pending);
3882                                return Err(err);
3883                            }
3884                        }
3885                    } else {
3886                        None
3887                    };
3888                    delete_names.insert(delete.name.clone());
3889                }
3890            }
3891        }
3892
3893        if has_delete {
3894            let old_len = packed_refs.len();
3895            packed_refs.retain(|reference| !delete_names.contains(&reference.reference.name));
3896            if packed_refs.len() != old_len {
3897                let packed_bytes = match write_packed_refs(&packed_refs) {
3898                    Ok(bytes) => bytes,
3899                    Err(err) => {
3900                        release_pending_locks(&pending);
3901                        return Err(err);
3902                    }
3903                };
3904                if let Some(packed) = pending.last_mut() {
3905                    packed.action = PendingPathAction::Write {
3906                        contents: packed_bytes,
3907                    };
3908                }
3909            }
3910        }
3911
3912        // Stage every new value or delete marker into its lock file. Nothing has
3913        // been renamed or removed yet, so on failure we only drop lock files.
3914        for change in &pending {
3915            if let Err(err) = stage_pending_change(change) {
3916                release_pending_locks(&pending);
3917                return Err(err);
3918            }
3919        }
3920
3921        // git fires the `prepared` hook once every ref is locked, before any
3922        // value is renamed into place. A nonzero exit drops the staged lock
3923        // files (no on-disk change happened yet) and aborts.
3924        if let (Some(hook), Some(updates)) = (hook, hook_updates)
3925            && hook.run(RefTransactionPhase::Prepared, updates)?
3926        {
3927            release_pending_locks(&pending);
3928            return Err(ref_transaction_hook_abort(RefTransactionPhase::Prepared));
3929        }
3930
3931        // Apply each staged path change; on failure restore paths already
3932        // changed and drop the remaining lock files.
3933        for index in 0..pending.len() {
3934            if let Err(err) = maybe_fail_loose_commit_action(index) {
3935                rollback_after_apply(&pending, index);
3936                return Err(err);
3937            }
3938            if let Err(err) = apply_pending_change(&pending[index]) {
3939                rollback_after_apply(&pending, index + 1);
3940                return Err(err);
3941            }
3942            if matches!(pending[index].action, PendingPathAction::Delete) {
3943                self.prune_empty_ref_dirs(&pending[index].name);
3944            }
3945        }
3946
3947        // Git unlinks logs/refs/<name> (and prunes now-empty log dirs) on delete;
3948        // do this before appending update reflogs so a delete+recreate in the
3949        // same direction does not race the new ref's reflog file.
3950        for name in &delete_names {
3951            self.remove_reflog_file(name);
3952        }
3953        // git's `files_log_ref_write` mirrors a checked-out branch's reflog
3954        // entry into logs/HEAD; `head_branch` was captured before any mutation.
3955        let head_mirror = Self::head_reflog_mirror(head_branch.as_deref(), &reflogs);
3956        reflogs.extend(head_mirror);
3957        // All refs are durable; append reflogs last, matching git's ordering.
3958        for (name, entry) in reflogs {
3959            self.append_reflog(&name, &entry)?;
3960        }
3961        // git fires the `committed` hook once every ref change has landed. Its
3962        // exit status is ignored — the transaction has already succeeded.
3963        if let (Some(hook), Some(updates)) = (hook, hook_updates) {
3964            hook.run(RefTransactionPhase::Committed, updates)?;
3965        }
3966        Ok(())
3967    }
3968
3969    fn read_ref_from_packed_snapshot(
3970        &self,
3971        name: &str,
3972        packed_refs: &HashMap<String, RefTarget>,
3973    ) -> Result<Option<RefTarget>> {
3974        let state = self.read_locked_ref_state(name, packed_refs)?;
3975        Ok(state.current)
3976    }
3977
3978    fn read_locked_ref_state(
3979        &self,
3980        name: &str,
3981        packed_refs: &HashMap<String, RefTarget>,
3982    ) -> Result<LockedRefState> {
3983        let loose = self.read_loose_ref(name)?;
3984        let current = if let Some(reference) = loose.as_ref() {
3985            Some(reference.target.clone())
3986        } else {
3987            packed_refs.get(name).cloned()
3988        };
3989        Ok(LockedRefState {
3990            current,
3991            has_loose: loose.is_some(),
3992        })
3993    }
3994}
3995
3996struct LockedRefState {
3997    current: Option<RefTarget>,
3998    has_loose: bool,
3999}
4000
4001enum CoalescedRefChange {
4002    Update(CoalescedRefUpdate),
4003    Delete(CoalescedRefDelete),
4004}
4005
4006impl CoalescedRefChange {
4007    fn name(&self) -> &str {
4008        match self {
4009            Self::Update(update) => &update.name,
4010            Self::Delete(delete) => &delete.name,
4011        }
4012    }
4013}
4014
4015/// A ref update with all writes that targeted the same name folded together.
4016struct CoalescedRefUpdate {
4017    name: String,
4018    precondition: RefPrecondition,
4019    new: RefTarget,
4020    reflog: Vec<ReflogEntry>,
4021}
4022
4023struct CoalescedRefDelete {
4024    name: String,
4025    precondition: RefDeletePrecondition,
4026}
4027
4028fn coalesce_ref_changes(changes: Vec<QueuedRefChange>) -> Result<Vec<CoalescedRefChange>> {
4029    let has_delete = changes
4030        .iter()
4031        .any(|change| matches!(change, QueuedRefChange::Delete(_)));
4032    if !has_delete {
4033        let updates = changes
4034            .into_iter()
4035            .map(|change| match change {
4036                QueuedRefChange::Update(update) => update,
4037                QueuedRefChange::Delete(_) => unreachable!("has_delete was false"),
4038            })
4039            .collect::<Vec<_>>();
4040        return coalesce_ref_updates(updates).map(|updates| {
4041            updates
4042                .into_iter()
4043                .map(CoalescedRefChange::Update)
4044                .collect()
4045        });
4046    }
4047
4048    let mut seen = BTreeSet::new();
4049    let mut coalesced = Vec::with_capacity(changes.len());
4050    for change in changes {
4051        let name = match &change {
4052            QueuedRefChange::Update(update) => &update.name,
4053            QueuedRefChange::Delete(delete) => &delete.name,
4054        };
4055        match &change {
4056            QueuedRefChange::Update(update) => validate_ref_name_for_update(&update.name)?,
4057            QueuedRefChange::Delete(delete) => validate_ref_name_for_read(&delete.name)?,
4058        }
4059        if !seen.insert(name.clone()) {
4060            return Err(GitError::Transaction(format!(
4061                "ref {name} appears more than once in transaction"
4062            )));
4063        }
4064        coalesced.push(match change {
4065            QueuedRefChange::Update(update) => CoalescedRefChange::Update(CoalescedRefUpdate {
4066                name: update.name,
4067                precondition: update.precondition,
4068                new: update.new,
4069                reflog: update.reflog.into_iter().collect(),
4070            }),
4071            QueuedRefChange::Delete(delete) => CoalescedRefChange::Delete(CoalescedRefDelete {
4072                name: delete.name,
4073                precondition: delete.precondition,
4074            }),
4075        });
4076    }
4077    Ok(coalesced)
4078}
4079
4080/// Fold repeated updates to the same ref into one, preserving first-seen order.
4081/// The last queued value wins, reflog entries accumulate in order, and the
4082/// precondition is taken from the first update (the state the caller
4083/// asserted before any change in this transaction).
4084fn coalesce_ref_updates(updates: Vec<QueuedUpdate>) -> Result<Vec<CoalescedRefUpdate>> {
4085    let mut order: Vec<String> = Vec::new();
4086    let mut by_name: HashMap<String, CoalescedRefUpdate> = HashMap::new();
4087    for update in updates {
4088        validate_ref_name_for_update(&update.name)?;
4089        match by_name.get_mut(&update.name) {
4090            Some(existing) => {
4091                existing.new = update.new;
4092                if let Some(entry) = update.reflog {
4093                    existing.reflog.push(entry);
4094                }
4095            }
4096            None => {
4097                order.push(update.name.clone());
4098                by_name.insert(
4099                    update.name.clone(),
4100                    CoalescedRefUpdate {
4101                        name: update.name,
4102                        precondition: update.precondition,
4103                        new: update.new,
4104                        reflog: update.reflog.into_iter().collect(),
4105                    },
4106                );
4107            }
4108        }
4109    }
4110    let mut coalesced = Vec::with_capacity(order.len());
4111    for name in order {
4112        if let Some(update) = by_name.remove(&name) {
4113            coalesced.push(update);
4114        }
4115    }
4116    Ok(coalesced)
4117}
4118
4119/// A staged path change: the target path, its lock file, and original bytes for
4120/// rollback.
4121struct PendingPathChange {
4122    name: String,
4123    path: PathBuf,
4124    lock_path: PathBuf,
4125    original: Option<Vec<u8>>,
4126    action: PendingPathAction,
4127}
4128
4129enum PendingPathAction {
4130    Write { contents: Vec<u8> },
4131    Delete,
4132    ReleaseLock,
4133}
4134
4135struct RefDirPruneGuard<'a> {
4136    store: &'a FileRefStore,
4137    name: String,
4138}
4139
4140impl Drop for RefDirPruneGuard<'_> {
4141    fn drop(&mut self) {
4142        self.store.prune_empty_ref_dirs(&self.name);
4143    }
4144}
4145
4146struct DeleteLock {
4147    path: PathBuf,
4148    file: Option<fs::File>,
4149    active: bool,
4150}
4151
4152impl DeleteLock {
4153    fn acquire(path: PathBuf) -> std::result::Result<Self, RefDeleteError> {
4154        match fs::OpenOptions::new()
4155            .write(true)
4156            .create_new(true)
4157            .open(&path)
4158        {
4159            Ok(file) => Ok(Self {
4160                path,
4161                file: Some(file),
4162                active: true,
4163            }),
4164            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
4165                Err(RefDeleteError::Locked)
4166            }
4167            Err(err) => Err(RefDeleteError::Io(err)),
4168        }
4169    }
4170
4171    fn write_all(&mut self, bytes: &[u8]) -> std::result::Result<(), RefDeleteError> {
4172        let Some(file) = self.file.as_mut() else {
4173            return Err(RefDeleteError::Io(std::io::Error::other(
4174                "lock file is already closed",
4175            )));
4176        };
4177        file.set_len(0)?;
4178        file.write_all(bytes)?;
4179        file.sync_all()?;
4180        Ok(())
4181    }
4182
4183    fn close(mut self) -> PathBuf {
4184        self.active = false;
4185        let _ = self.file.take();
4186        self.path.clone()
4187    }
4188
4189    fn remove(mut self) {
4190        self.active = false;
4191        let _ = self.file.take();
4192        let _ = fs::remove_file(&self.path);
4193    }
4194}
4195
4196impl Drop for DeleteLock {
4197    fn drop(&mut self) {
4198        if self.active {
4199            let _ = self.file.take();
4200            let _ = fs::remove_file(&self.path);
4201        }
4202    }
4203}
4204
4205struct ReftableListLock {
4206    list_path: PathBuf,
4207    lock_path: PathBuf,
4208    file: Option<fs::File>,
4209    active: bool,
4210}
4211
4212impl ReftableListLock {
4213    fn acquire(list_path: PathBuf, lock_path: PathBuf, timeout_millis: u64) -> Result<Self> {
4214        let start = SystemTime::now();
4215        loop {
4216            match fs::OpenOptions::new()
4217                .write(true)
4218                .create_new(true)
4219                .open(&lock_path)
4220            {
4221                Ok(file) => {
4222                    return Ok(Self {
4223                        list_path,
4224                        lock_path,
4225                        file: Some(file),
4226                        active: true,
4227                    });
4228                }
4229                Err(err)
4230                    if err.kind() == std::io::ErrorKind::AlreadyExists && timeout_millis > 0 =>
4231                {
4232                    let elapsed = start
4233                        .elapsed()
4234                        .unwrap_or_else(|_| Duration::from_millis(timeout_millis + 1));
4235                    if elapsed.as_millis() as u64 >= timeout_millis {
4236                        return Err(GitError::Io(format!(
4237                            "cannot lock references: {}: File exists",
4238                            lock_path.display()
4239                        )));
4240                    }
4241                    thread::sleep(Duration::from_millis(50));
4242                }
4243                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
4244                    return Err(GitError::Io(format!(
4245                        "cannot lock references: {}: File exists",
4246                        lock_path.display()
4247                    )));
4248                }
4249                Err(err) => return Err(err.into()),
4250            }
4251        }
4252    }
4253
4254    fn commit(mut self, bytes: &[u8]) -> Result<()> {
4255        let Some(mut file) = self.file.take() else {
4256            return Err(GitError::Io("reftable list lock is already closed".into()));
4257        };
4258        file.set_len(0)?;
4259        file.write_all(bytes)?;
4260        file.sync_all()?;
4261        drop(file);
4262        fs::rename(&self.lock_path, &self.list_path)
4263            .map_err(|err| GitError::Io(err.to_string()))?;
4264        self.active = false;
4265        Ok(())
4266    }
4267}
4268
4269impl Drop for ReftableListLock {
4270    fn drop(&mut self) {
4271        if self.active {
4272            let _ = self.file.take();
4273            let _ = fs::remove_file(&self.lock_path);
4274        }
4275    }
4276}
4277
4278fn checked_delete_oid(
4279    expected: Option<ObjectId>,
4280    current: Option<RefTarget>,
4281) -> std::result::Result<ObjectId, RefDeleteError> {
4282    let Some(current) = current else {
4283        return Err(RefDeleteError::NotFound);
4284    };
4285    let RefTarget::Direct(actual) = current else {
4286        return Err(RefDeleteError::ExpectedMismatch {
4287            expected,
4288            actual: None,
4289        });
4290    };
4291    if let Some(expected_oid) = expected
4292        && expected_oid != actual
4293    {
4294        return Err(RefDeleteError::ExpectedMismatch {
4295            expected: Some(expected_oid),
4296            actual: Some(actual),
4297        });
4298    }
4299    Ok(actual)
4300}
4301
4302/// Verify a queued/checked delete may proceed, dying on a precondition
4303/// mismatch. Git unlinks the reflog on delete (it never writes a deletion
4304/// entry), so this validates only — the peeled OID is no longer plumbed out.
4305/// `peeled_oid_for_delete` is still invoked where the precondition requires the
4306/// peeled value, so a broken/unpeelable ref is still reported.
4307fn verify_delete_precondition(
4308    store: &FileRefStore,
4309    name: &str,
4310    current: Option<&RefTarget>,
4311    precondition: &RefDeletePrecondition,
4312) -> Result<()> {
4313    let Some(current) = current else {
4314        return Err(GitError::Transaction(format!("ref {name} not found")));
4315    };
4316    match precondition {
4317        RefDeletePrecondition::Any => {
4318            peeled_oid_for_delete(store, current)?;
4319            Ok(())
4320        }
4321        RefDeletePrecondition::Immediate(expected) if current == expected => {
4322            peeled_oid_for_delete(store, current)?;
4323            Ok(())
4324        }
4325        RefDeletePrecondition::Immediate(_) => Err(delete_precondition_mismatch(name)),
4326        RefDeletePrecondition::Direct(expected) => {
4327            let RefTarget::Direct(actual) = current else {
4328                return Err(delete_precondition_mismatch(name));
4329            };
4330            if let Some(expected) = expected
4331                && expected != actual
4332            {
4333                return Err(delete_precondition_mismatch(name));
4334            }
4335            Ok(())
4336        }
4337        RefDeletePrecondition::Peeled(expected) => {
4338            let actual = peeled_oid_for_delete(store, current)?;
4339            if actual == Some(*expected) {
4340                Ok(())
4341            } else {
4342                Err(delete_precondition_mismatch(name))
4343            }
4344        }
4345    }
4346}
4347
4348fn peeled_oid_for_delete(store: &FileRefStore, target: &RefTarget) -> Result<Option<ObjectId>> {
4349    match target {
4350        RefTarget::Direct(oid) => Ok(Some(*oid)),
4351        RefTarget::Symbolic(name) => resolve_ref_peeled(store, name),
4352    }
4353}
4354
4355fn delete_precondition_mismatch(name: &str) -> GitError {
4356    GitError::Transaction(format!("expected ref {name} to match"))
4357}
4358
4359fn ref_delete_error_from_git(err: GitError) -> RefDeleteError {
4360    match err {
4361        GitError::InvalidPath(_) => RefDeleteError::InvalidName,
4362        GitError::NotFound(_) => RefDeleteError::NotFound,
4363        GitError::Io(message) if message.contains("File exists") => RefDeleteError::Locked,
4364        GitError::Io(message) if message.contains("could not lock") => RefDeleteError::Locked,
4365        GitError::Transaction(message) if message.contains("could not lock") => {
4366            RefDeleteError::Locked
4367        }
4368        other => RefDeleteError::Io(std::io::Error::other(other.to_string())),
4369    }
4370}
4371
4372fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
4373    match fs::read(path) {
4374        Ok(bytes) => Ok(Some(bytes)),
4375        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
4376        Err(err) if err.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
4377        Err(err) => Err(GitError::Io(err.to_string())),
4378    }
4379}
4380
4381/// Recursively remove an empty directory tree at `path` (git's
4382/// `remove_empty_directories`). A no-op when `path` is absent or is a file; an
4383/// error if any directory in the tree contains a non-directory entry.
4384fn remove_empty_dir_tree(path: &Path) -> std::io::Result<()> {
4385    let meta = match fs::symlink_metadata(path) {
4386        Ok(meta) => meta,
4387        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
4388        Err(err) => return Err(err),
4389    };
4390    if !meta.is_dir() {
4391        return Ok(());
4392    }
4393    for entry in fs::read_dir(path)? {
4394        let entry = entry?;
4395        if entry.file_type()?.is_dir() {
4396            remove_empty_dir_tree(&entry.path())?;
4397        } else {
4398            return Err(std::io::Error::other("directory not empty"));
4399        }
4400    }
4401    fs::remove_dir(path)
4402}
4403
4404fn stage_lock_file(lock_path: &Path, contents: &[u8]) -> Result<()> {
4405    let mut file = fs::OpenOptions::new()
4406        .write(true)
4407        .truncate(true)
4408        .open(lock_path)?;
4409    file.write_all(contents)?;
4410    Ok(())
4411}
4412
4413fn stage_pending_change(change: &PendingPathChange) -> Result<()> {
4414    match &change.action {
4415        PendingPathAction::Write { contents } => stage_lock_file(&change.lock_path, contents),
4416        PendingPathAction::Delete => stage_lock_file(&change.lock_path, b"delete\n"),
4417        PendingPathAction::ReleaseLock => Ok(()),
4418    }
4419}
4420
4421fn apply_pending_change(change: &PendingPathChange) -> Result<()> {
4422    match &change.action {
4423        PendingPathAction::Write { .. } => {
4424            // git's `remove_empty_directories`: an empty directory tree sitting
4425            // where the loose ref file belongs (e.g. leftover `refs/x/foo/bar/`)
4426            // is cleared before the rename so the ref can be written.
4427            remove_empty_dir_tree(&change.path).map_err(|err| GitError::Io(err.to_string()))?;
4428            fs::rename(&change.lock_path, &change.path).map_err(|err| GitError::Io(err.to_string()))
4429        }
4430        PendingPathAction::Delete => {
4431            if change.original.is_some() {
4432                fs::remove_file(&change.path).map_err(|err| GitError::Io(err.to_string()))?;
4433            }
4434            fs::remove_file(&change.lock_path).map_err(|err| GitError::Io(err.to_string()))
4435        }
4436        PendingPathAction::ReleaseLock => {
4437            fs::remove_file(&change.lock_path).map_err(|err| GitError::Io(err.to_string()))
4438        }
4439    }
4440}
4441
4442/// Delete every still-held lock file. Used when a transaction aborts before any
4443/// path change, so nothing on disk has changed yet.
4444fn release_pending_locks(pending: &[PendingPathChange]) {
4445    for change in pending {
4446        let _ = fs::remove_file(&change.lock_path);
4447    }
4448}
4449
4450/// Roll back after `applied` path changes have already landed: restore each to
4451/// its captured bytes (or remove it if it did not previously exist), then drop
4452/// the lock files that have not yet been applied.
4453fn rollback_after_apply(pending: &[PendingPathChange], applied: usize) {
4454    for change in pending.iter().take(applied) {
4455        if matches!(change.action, PendingPathAction::ReleaseLock) {
4456            let _ = fs::remove_file(&change.lock_path);
4457            continue;
4458        }
4459        match &change.original {
4460            Some(bytes) => {
4461                let _ = restore_file_atomically(&change.path, bytes);
4462            }
4463            None => {
4464                let _ = fs::remove_file(&change.path);
4465            }
4466        }
4467        let _ = fs::remove_file(&change.lock_path);
4468    }
4469    for change in pending.iter().skip(applied) {
4470        let _ = fs::remove_file(&change.lock_path);
4471    }
4472}
4473
4474#[cfg(test)]
4475thread_local! {
4476    static FAIL_LOOSE_COMMIT_ACTION: std::cell::Cell<Option<usize>> =
4477        const { std::cell::Cell::new(None) };
4478}
4479
4480#[cfg(test)]
4481fn set_fail_loose_commit_action_for_test(index: Option<usize>) {
4482    FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.set(index));
4483}
4484
4485#[cfg(test)]
4486fn maybe_fail_loose_commit_action(index: usize) -> Result<()> {
4487    let should_fail = FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.get() == Some(index));
4488    if should_fail {
4489        FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.set(None));
4490        return Err(GitError::Io(format!(
4491            "injected loose ref transaction failure at action {index}"
4492        )));
4493    }
4494    Ok(())
4495}
4496
4497#[cfg(not(test))]
4498fn maybe_fail_loose_commit_action(_index: usize) -> Result<()> {
4499    Ok(())
4500}
4501
4502/// Best-effort atomic restore of `path` to `bytes` during rollback, reusing the
4503/// write-to-temp-then-rename dance so a crash mid-rollback cannot truncate a ref.
4504fn restore_file_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
4505    if let Some(parent) = path.parent() {
4506        fs::create_dir_all(parent)?;
4507    }
4508    write_locked(path, bytes)
4509}
4510
4511#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
4512pub struct FullRefName<'a> {
4513    name: &'a str,
4514}
4515
4516impl<'a> FullRefName<'a> {
4517    pub fn new(name: &'a str) -> Result<Self> {
4518        validate_ref_name(name)?;
4519        Ok(Self { name })
4520    }
4521
4522    pub fn as_str(&self) -> &str {
4523        self.name
4524    }
4525
4526    pub fn into_str(self) -> &'a str {
4527        self.name
4528    }
4529
4530    pub fn to_owned(&self) -> FullRefNameBuf {
4531        FullRefNameBuf {
4532            name: self.name.to_string(),
4533        }
4534    }
4535
4536    pub fn as_branch(&self) -> Result<BranchRefName<'a>> {
4537        BranchRefName::from_full_ref(*self)
4538    }
4539
4540    pub fn as_tag(&self) -> Result<TagRefName<'a>> {
4541        TagRefName::from_full_ref(*self)
4542    }
4543
4544    pub fn as_remote(&self) -> Result<RemoteRefName<'a>> {
4545        RemoteRefName::from_full_ref(*self)
4546    }
4547}
4548
4549impl AsRef<str> for FullRefName<'_> {
4550    fn as_ref(&self) -> &str {
4551        self.as_str()
4552    }
4553}
4554
4555impl fmt::Display for FullRefName<'_> {
4556    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4557        f.write_str(self.as_str())
4558    }
4559}
4560
4561#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4562pub struct FullRefNameBuf {
4563    name: String,
4564}
4565
4566impl FullRefNameBuf {
4567    pub fn new(name: impl Into<String>) -> Result<Self> {
4568        let name = name.into();
4569        validate_ref_name(&name)?;
4570        Ok(Self { name })
4571    }
4572
4573    pub fn as_ref_name(&self) -> FullRefName<'_> {
4574        FullRefName { name: &self.name }
4575    }
4576
4577    pub fn as_str(&self) -> &str {
4578        &self.name
4579    }
4580
4581    pub fn into_string(self) -> String {
4582        self.name
4583    }
4584
4585    pub fn as_branch(&self) -> Result<BranchRefName<'_>> {
4586        self.as_ref_name().as_branch()
4587    }
4588
4589    pub fn as_tag(&self) -> Result<TagRefName<'_>> {
4590        self.as_ref_name().as_tag()
4591    }
4592
4593    pub fn as_remote(&self) -> Result<RemoteRefName<'_>> {
4594        self.as_ref_name().as_remote()
4595    }
4596}
4597
4598impl AsRef<str> for FullRefNameBuf {
4599    fn as_ref(&self) -> &str {
4600        self.as_str()
4601    }
4602}
4603
4604impl Borrow<str> for FullRefNameBuf {
4605    fn borrow(&self) -> &str {
4606        self.as_str()
4607    }
4608}
4609
4610impl Deref for FullRefNameBuf {
4611    type Target = str;
4612
4613    fn deref(&self) -> &Self::Target {
4614        self.as_str()
4615    }
4616}
4617
4618impl fmt::Display for FullRefNameBuf {
4619    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4620        f.write_str(self.as_str())
4621    }
4622}
4623
4624#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
4625pub struct BranchRefName<'a> {
4626    name: &'a str,
4627}
4628
4629impl<'a> BranchRefName<'a> {
4630    pub const PREFIX: &'static str = "refs/heads/";
4631
4632    pub fn from_full(name: &'a str) -> Result<Self> {
4633        let full = FullRefName::new(name)?;
4634        Self::from_full_ref(full)
4635    }
4636
4637    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
4638        validate_namespaced_ref(name.as_str(), Self::PREFIX, "branch")?;
4639        Ok(Self {
4640            name: name.into_str(),
4641        })
4642    }
4643
4644    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
4645        FullRefName { name: self.name }
4646    }
4647
4648    pub fn as_str(&self) -> &str {
4649        self.name
4650    }
4651
4652    pub fn branch_name(&self) -> &str {
4653        self.short_name()
4654    }
4655
4656    pub fn short_name(&self) -> &str {
4657        &self.name[Self::PREFIX.len()..]
4658    }
4659
4660    pub fn into_str(self) -> &'a str {
4661        self.name
4662    }
4663
4664    pub fn to_owned(&self) -> BranchRefNameBuf {
4665        BranchRefNameBuf {
4666            name: self.name.to_string(),
4667        }
4668    }
4669}
4670
4671impl AsRef<str> for BranchRefName<'_> {
4672    fn as_ref(&self) -> &str {
4673        self.as_str()
4674    }
4675}
4676
4677impl fmt::Display for BranchRefName<'_> {
4678    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4679        f.write_str(self.as_str())
4680    }
4681}
4682
4683impl<'a> From<BranchRefName<'a>> for FullRefName<'a> {
4684    fn from(name: BranchRefName<'a>) -> Self {
4685        name.as_full_ref_name()
4686    }
4687}
4688
4689#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4690pub struct BranchRefNameBuf {
4691    name: String,
4692}
4693
4694impl BranchRefNameBuf {
4695    pub fn from_branch_name(branch: &str) -> Result<Self> {
4696        validate_short_ref_name("branch", branch)?;
4697        let name = format!("{}{}", BranchRefName::PREFIX, branch);
4698        Self::from_full(name)
4699    }
4700
4701    pub fn from_full(name: impl Into<String>) -> Result<Self> {
4702        let name = name.into();
4703        BranchRefName::from_full(&name)?;
4704        Ok(Self { name })
4705    }
4706
4707    pub fn as_ref_name(&self) -> BranchRefName<'_> {
4708        BranchRefName { name: &self.name }
4709    }
4710
4711    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
4712        FullRefName { name: &self.name }
4713    }
4714
4715    pub fn as_str(&self) -> &str {
4716        &self.name
4717    }
4718
4719    pub fn branch_name(&self) -> &str {
4720        self.short_name()
4721    }
4722
4723    pub fn short_name(&self) -> &str {
4724        &self.name[BranchRefName::PREFIX.len()..]
4725    }
4726
4727    pub fn into_string(self) -> String {
4728        self.name
4729    }
4730}
4731
4732impl AsRef<str> for BranchRefNameBuf {
4733    fn as_ref(&self) -> &str {
4734        self.as_str()
4735    }
4736}
4737
4738impl Borrow<str> for BranchRefNameBuf {
4739    fn borrow(&self) -> &str {
4740        self.as_str()
4741    }
4742}
4743
4744impl Deref for BranchRefNameBuf {
4745    type Target = str;
4746
4747    fn deref(&self) -> &Self::Target {
4748        self.as_str()
4749    }
4750}
4751
4752impl fmt::Display for BranchRefNameBuf {
4753    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4754        f.write_str(self.as_str())
4755    }
4756}
4757
4758impl From<BranchRefNameBuf> for FullRefNameBuf {
4759    fn from(name: BranchRefNameBuf) -> Self {
4760        Self { name: name.name }
4761    }
4762}
4763
4764#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
4765pub struct TagRefName<'a> {
4766    name: &'a str,
4767}
4768
4769impl<'a> TagRefName<'a> {
4770    pub const PREFIX: &'static str = "refs/tags/";
4771
4772    pub fn from_full(name: &'a str) -> Result<Self> {
4773        let full = FullRefName::new(name)?;
4774        Self::from_full_ref(full)
4775    }
4776
4777    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
4778        validate_namespaced_ref(name.as_str(), Self::PREFIX, "tag")?;
4779        Ok(Self {
4780            name: name.into_str(),
4781        })
4782    }
4783
4784    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
4785        FullRefName { name: self.name }
4786    }
4787
4788    pub fn as_str(&self) -> &str {
4789        self.name
4790    }
4791
4792    pub fn tag_name(&self) -> &str {
4793        self.short_name()
4794    }
4795
4796    pub fn short_name(&self) -> &str {
4797        &self.name[Self::PREFIX.len()..]
4798    }
4799
4800    pub fn into_str(self) -> &'a str {
4801        self.name
4802    }
4803
4804    pub fn to_owned(&self) -> TagRefNameBuf {
4805        TagRefNameBuf {
4806            name: self.name.to_string(),
4807        }
4808    }
4809}
4810
4811impl AsRef<str> for TagRefName<'_> {
4812    fn as_ref(&self) -> &str {
4813        self.as_str()
4814    }
4815}
4816
4817impl fmt::Display for TagRefName<'_> {
4818    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4819        f.write_str(self.as_str())
4820    }
4821}
4822
4823impl<'a> From<TagRefName<'a>> for FullRefName<'a> {
4824    fn from(name: TagRefName<'a>) -> Self {
4825        name.as_full_ref_name()
4826    }
4827}
4828
4829#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4830pub struct TagRefNameBuf {
4831    name: String,
4832}
4833
4834impl TagRefNameBuf {
4835    pub fn from_tag_name(tag: &str) -> Result<Self> {
4836        // Mirror git's check_tag_ref(): reject a leading '-' or the literal
4837        // "HEAD", then validate refs/tags/<tag> with check_refname_format().
4838        if tag.starts_with('-') || tag == "HEAD" {
4839            return Err(GitError::InvalidPath(format!("invalid tag name {tag}")));
4840        }
4841        Self::from_tag_name_unrestricted(tag)
4842    }
4843
4844    /// Build `refs/tags/<tag>` validating only the refname format, without the
4845    /// creation-only restrictions (leading `-`, literal `HEAD`). Git's delete
4846    /// path does not run check_tag_ref(), so a tag literally named `HEAD` can
4847    /// still be removed.
4848    pub fn from_tag_name_unrestricted(tag: &str) -> Result<Self> {
4849        let name = format!("{}{}", TagRefName::PREFIX, tag);
4850        check_refname_format(&name, false)?;
4851        Ok(Self { name })
4852    }
4853
4854    pub fn from_full(name: impl Into<String>) -> Result<Self> {
4855        let name = name.into();
4856        TagRefName::from_full(&name)?;
4857        Ok(Self { name })
4858    }
4859
4860    pub fn as_ref_name(&self) -> TagRefName<'_> {
4861        TagRefName { name: &self.name }
4862    }
4863
4864    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
4865        FullRefName { name: &self.name }
4866    }
4867
4868    pub fn as_str(&self) -> &str {
4869        &self.name
4870    }
4871
4872    pub fn tag_name(&self) -> &str {
4873        self.short_name()
4874    }
4875
4876    pub fn short_name(&self) -> &str {
4877        &self.name[TagRefName::PREFIX.len()..]
4878    }
4879
4880    pub fn into_string(self) -> String {
4881        self.name
4882    }
4883}
4884
4885impl AsRef<str> for TagRefNameBuf {
4886    fn as_ref(&self) -> &str {
4887        self.as_str()
4888    }
4889}
4890
4891impl Borrow<str> for TagRefNameBuf {
4892    fn borrow(&self) -> &str {
4893        self.as_str()
4894    }
4895}
4896
4897impl Deref for TagRefNameBuf {
4898    type Target = str;
4899
4900    fn deref(&self) -> &Self::Target {
4901        self.as_str()
4902    }
4903}
4904
4905impl fmt::Display for TagRefNameBuf {
4906    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4907        f.write_str(self.as_str())
4908    }
4909}
4910
4911impl From<TagRefNameBuf> for FullRefNameBuf {
4912    fn from(name: TagRefNameBuf) -> Self {
4913        Self { name: name.name }
4914    }
4915}
4916
4917#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
4918pub struct RemoteRefName<'a> {
4919    name: &'a str,
4920}
4921
4922impl<'a> RemoteRefName<'a> {
4923    pub const PREFIX: &'static str = "refs/remotes/";
4924
4925    pub fn from_full(name: &'a str) -> Result<Self> {
4926        let full = FullRefName::new(name)?;
4927        Self::from_full_ref(full)
4928    }
4929
4930    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
4931        validate_namespaced_ref(name.as_str(), Self::PREFIX, "remote")?;
4932        Ok(Self {
4933            name: name.into_str(),
4934        })
4935    }
4936
4937    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
4938        FullRefName { name: self.name }
4939    }
4940
4941    pub fn as_str(&self) -> &str {
4942        self.name
4943    }
4944
4945    pub fn short_name(&self) -> &str {
4946        &self.name[Self::PREFIX.len()..]
4947    }
4948
4949    pub fn remote_name(&self) -> &str {
4950        match self.short_name().split_once('/') {
4951            Some((remote, _branch)) => remote,
4952            None => self.short_name(),
4953        }
4954    }
4955
4956    pub fn remote_branch(&self) -> Option<&str> {
4957        self.short_name()
4958            .split_once('/')
4959            .map(|(_remote, branch)| branch)
4960    }
4961
4962    pub fn into_str(self) -> &'a str {
4963        self.name
4964    }
4965
4966    pub fn to_owned(&self) -> RemoteRefNameBuf {
4967        RemoteRefNameBuf {
4968            name: self.name.to_string(),
4969        }
4970    }
4971}
4972
4973impl AsRef<str> for RemoteRefName<'_> {
4974    fn as_ref(&self) -> &str {
4975        self.as_str()
4976    }
4977}
4978
4979impl fmt::Display for RemoteRefName<'_> {
4980    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4981        f.write_str(self.as_str())
4982    }
4983}
4984
4985impl<'a> From<RemoteRefName<'a>> for FullRefName<'a> {
4986    fn from(name: RemoteRefName<'a>) -> Self {
4987        name.as_full_ref_name()
4988    }
4989}
4990
4991#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4992pub struct RemoteRefNameBuf {
4993    name: String,
4994}
4995
4996impl RemoteRefNameBuf {
4997    pub fn from_short_name(name: &str) -> Result<Self> {
4998        validate_short_ref_name("remote ref", name)?;
4999        let name = format!("{}{}", RemoteRefName::PREFIX, name);
5000        Self::from_full(name)
5001    }
5002
5003    pub fn from_remote_branch(remote: &str, branch: &str) -> Result<Self> {
5004        validate_remote_name(remote)?;
5005        validate_short_ref_name("remote branch", branch)?;
5006        let name = format!("{}{}/{}", RemoteRefName::PREFIX, remote, branch);
5007        Self::from_full(name)
5008    }
5009
5010    pub fn from_full(name: impl Into<String>) -> Result<Self> {
5011        let name = name.into();
5012        RemoteRefName::from_full(&name)?;
5013        Ok(Self { name })
5014    }
5015
5016    pub fn as_ref_name(&self) -> RemoteRefName<'_> {
5017        RemoteRefName { name: &self.name }
5018    }
5019
5020    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
5021        FullRefName { name: &self.name }
5022    }
5023
5024    pub fn as_str(&self) -> &str {
5025        &self.name
5026    }
5027
5028    pub fn short_name(&self) -> &str {
5029        &self.name[RemoteRefName::PREFIX.len()..]
5030    }
5031
5032    pub fn remote_name(&self) -> &str {
5033        match self.short_name().split_once('/') {
5034            Some((remote, _branch)) => remote,
5035            None => self.short_name(),
5036        }
5037    }
5038
5039    pub fn remote_branch(&self) -> Option<&str> {
5040        self.short_name()
5041            .split_once('/')
5042            .map(|(_remote, branch)| branch)
5043    }
5044
5045    pub fn into_string(self) -> String {
5046        self.name
5047    }
5048}
5049
5050impl AsRef<str> for RemoteRefNameBuf {
5051    fn as_ref(&self) -> &str {
5052        self.as_str()
5053    }
5054}
5055
5056impl Borrow<str> for RemoteRefNameBuf {
5057    fn borrow(&self) -> &str {
5058        self.as_str()
5059    }
5060}
5061
5062impl Deref for RemoteRefNameBuf {
5063    type Target = str;
5064
5065    fn deref(&self) -> &Self::Target {
5066        self.as_str()
5067    }
5068}
5069
5070impl fmt::Display for RemoteRefNameBuf {
5071    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5072        f.write_str(self.as_str())
5073    }
5074}
5075
5076impl From<RemoteRefNameBuf> for FullRefNameBuf {
5077    fn from(name: RemoteRefNameBuf) -> Self {
5078        Self { name: name.name }
5079    }
5080}
5081
5082pub fn branch_ref_name(branch: &str) -> Result<String> {
5083    BranchRefNameBuf::from_branch_name(branch).map(BranchRefNameBuf::into_string)
5084}
5085
5086pub fn branch_ref_name_for_read(branch: &str) -> Result<String> {
5087    let name = format!("{}{}", BranchRefName::PREFIX, branch);
5088    if validate_ref_name(&name).is_err() {
5089        if name.contains("..") {
5090            validate_ref_path_safe_for_read(&name)?;
5091        } else {
5092            return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
5093        }
5094    }
5095    Ok(name)
5096}
5097
5098pub fn branch_ref_name_for_source(branch: &str) -> Result<String> {
5099    if branch.starts_with("--") {
5100        return Err(GitError::InvalidPath(format!(
5101            "invalid branch name {branch}"
5102        )));
5103    }
5104    let name = format!("{}{}", BranchRefName::PREFIX, branch);
5105    check_refname_format(&name, false)?;
5106    Ok(name)
5107}
5108
5109pub fn tag_ref_name(tag: &str) -> Result<String> {
5110    TagRefNameBuf::from_tag_name(tag).map(TagRefNameBuf::into_string)
5111}
5112
5113fn write_locked(path: &Path, bytes: &[u8]) -> Result<()> {
5114    write_locked_with_timeout(path, bytes, 0)
5115}
5116
5117fn write_locked_with_timeout(path: &Path, bytes: &[u8], timeout_millis: u64) -> Result<()> {
5118    let lock_path = lock_path_for(path)?;
5119    let start = SystemTime::now();
5120    loop {
5121        match fs::OpenOptions::new()
5122            .write(true)
5123            .create_new(true)
5124            .open(&lock_path)
5125        {
5126            Ok(mut file) => {
5127                file.write_all(bytes)?;
5128                file.sync_all()?;
5129                break;
5130            }
5131            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists && timeout_millis > 0 => {
5132                let elapsed = start
5133                    .elapsed()
5134                    .unwrap_or_else(|_| Duration::from_millis(timeout_millis + 1));
5135                if elapsed.as_millis() as u64 >= timeout_millis {
5136                    return Err(GitError::Io(format!(
5137                        "could not lock {}: File exists",
5138                        path.display()
5139                    )));
5140                }
5141                thread::sleep(Duration::from_millis(50));
5142            }
5143            Err(err) => return Err(err.into()),
5144        }
5145    }
5146    match fs::rename(&lock_path, path) {
5147        Ok(()) => Ok(()),
5148        Err(err) => {
5149            let _ = fs::remove_file(lock_path);
5150            Err(GitError::Io(err.to_string()))
5151        }
5152    }
5153}
5154
5155fn lock_path_for(path: &Path) -> Result<PathBuf> {
5156    let file_name = path
5157        .file_name()
5158        .ok_or_else(|| GitError::InvalidPath("ref path has no filename".into()))?;
5159    let mut lock_name = file_name.to_os_string();
5160    lock_name.push(".lock");
5161    Ok(path.with_file_name(lock_name))
5162}
5163
5164/// Validate a ref name using git's `check_refname_format` rules.
5165pub fn check_refname_format(name: &str, allow_onelevel: bool) -> Result<()> {
5166    if name.is_empty()
5167        || name == "@"
5168        || name.starts_with('/')
5169        || name.ends_with('/')
5170        || name.ends_with('.')
5171        || name.contains("..")
5172        || name.contains("//")
5173        || name.contains("@{")
5174        || (!allow_onelevel && !name.contains('/'))
5175    {
5176        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
5177    }
5178    for component in name.split('/') {
5179        if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
5180            return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
5181        }
5182        for (idx, byte) in component.bytes().enumerate() {
5183            if byte <= b' '
5184                || byte == 0x7f
5185                || matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
5186            {
5187                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
5188            }
5189            if byte == b'.' && component.as_bytes().get(idx + 1) == Some(&b'.') {
5190                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
5191            }
5192            if byte == b'@' && component.as_bytes().get(idx + 1) == Some(&b'{') {
5193                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
5194            }
5195        }
5196    }
5197    Ok(())
5198}
5199
5200/// Validate a symbolic ref name (HEAD, one-level pseudo-refs, or `refs/...`).
5201pub fn validate_symref_name(name: &str) -> Result<()> {
5202    if name == "HEAD" {
5203        return Ok(());
5204    }
5205    check_refname_format(name, true)
5206}
5207
5208/// Validate a symbolic ref target (one-level pseudo-refs or `refs/...`).
5209pub fn validate_symref_target(name: &str) -> Result<()> {
5210    check_refname_format(name, true)
5211}
5212
5213/// Follow symbolic ref chains until a direct OID is reached.
5214/// Remove empty directories starting at `start` and walking up toward
5215/// `boundary`, stopping at the first non-empty directory or when `boundary` is
5216/// reached (exclusive). `boundary` itself is never removed.
5217fn prune_empty_dirs_up_to(start: &Path, boundary: &Path) {
5218    let mut dir = start.to_path_buf();
5219    while dir.starts_with(boundary) && dir != *boundary {
5220        if fs::remove_dir(&dir).is_err() {
5221            break;
5222        }
5223        dir = match dir.parent() {
5224            Some(parent) => parent.to_path_buf(),
5225            None => break,
5226        };
5227    }
5228}
5229
5230fn packable_loose_ref_name(name: &str) -> bool {
5231    name.starts_with("refs/")
5232        && !name.starts_with("refs/bisect/")
5233        && !name.starts_with("refs/worktree/")
5234        && !name.starts_with("refs/rewritten/")
5235}
5236
5237fn pack_refs_auto_required_for(packed_path: &Path, loose_count: usize) -> Result<bool> {
5238    let packed_size = match fs::metadata(packed_path) {
5239        Ok(meta) => meta.len() as usize,
5240        Err(err) if err.kind() == std::io::ErrorKind::NotFound => 0,
5241        Err(err) => return Err(err.into()),
5242    };
5243    let estimated_packed_refs = packed_size / 100;
5244    let log2 = if estimated_packed_refs == 0 {
5245        0
5246    } else {
5247        usize::BITS as usize - estimated_packed_refs.leading_zeros() as usize - 1
5248    };
5249    let limit = (log2 * 5).max(16);
5250    Ok(loose_count >= limit)
5251}
5252
5253pub fn resolve_ref_peeled(store: &FileRefStore, name: &str) -> Result<Option<ObjectId>> {
5254    let mut current = name.to_string();
5255    for _ in 0..16 {
5256        match store.read_ref(&current)? {
5257            Some(RefTarget::Direct(oid)) => return Ok(Some(oid)),
5258            Some(RefTarget::Symbolic(next)) => {
5259                if validate_ref_name_for_read(&next).is_err() {
5260                    return Ok(None);
5261                }
5262                current = next;
5263            }
5264            None => return Ok(None),
5265        }
5266    }
5267    Ok(None)
5268}
5269
5270pub fn validate_ref_name_for_read(name: &str) -> Result<()> {
5271    if validate_ref_name(name).is_ok() {
5272        return Ok(());
5273    }
5274    if is_root_ref_syntax(name) {
5275        return Ok(());
5276    }
5277    if check_refname_format(name, true).is_ok() {
5278        return Ok(());
5279    }
5280    validate_ref_path_safe_for_read(name)
5281}
5282
5283pub fn validate_ref_name_for_update(name: &str) -> Result<()> {
5284    if validate_ref_name(name).is_ok() {
5285        return Ok(());
5286    }
5287    if is_root_ref_syntax(name) {
5288        return Ok(());
5289    }
5290    check_refname_format(name, true)
5291}
5292
5293/// git's `refname_is_safe` (refs.c): the gate applied when *deleting* a ref
5294/// (`transaction_refname_valid` with a null new-oid). It is stricter than the
5295/// create-time `check_refname_format(_, REFNAME_ALLOW_ONELEVEL)`:
5296///   - a name under `refs/` is safe when the remainder is non-empty, has no
5297///     leading/trailing `/`, and does not escape `refs/` (`..`, absolute,
5298///     backslash component);
5299///   - any other (one-level) name is safe only when every byte is an uppercase
5300///     ASCII letter or `_` — the pseudo-ref shape (`HEAD`, `ORIG_HEAD`).
5301///
5302/// So a one-level name like `my-private-file` is *creatable* (`update-ref
5303/// my-private-file <oid>`) yet refused for deletion (`update-ref -d
5304/// my-private-file` → "refusing to update ref with bad name"), which is what
5305/// keeps `update-ref -d` from unlinking arbitrary files inside `.git`.
5306pub fn refname_is_safe(refname: &str) -> bool {
5307    if let Some(rest) = refname.strip_prefix("refs/") {
5308        if rest.is_empty() || rest.starts_with('/') || rest.ends_with('/') || rest.contains('\\') {
5309            return false;
5310        }
5311        let path = Path::new(rest);
5312        !path.is_absolute()
5313            && !path.components().any(|component| {
5314                matches!(
5315                    component,
5316                    std::path::Component::ParentDir
5317                        | std::path::Component::Prefix(_)
5318                        | std::path::Component::RootDir
5319                )
5320            })
5321    } else {
5322        !refname.is_empty() && refname.bytes().all(|b| b.is_ascii_uppercase() || b == b'_')
5323    }
5324}
5325
5326/// git's is_root_ref_syntax (refs.c): a ref name made only of uppercase ASCII,
5327/// `-`, and `_` (e.g. HEAD, FETCH_HEAD, MERGE_HEAD). Such names live in the
5328/// per-worktree gitdir rather than the common refs/ tree. An empty name is not
5329/// root-ref syntax.
5330fn is_root_ref_syntax(name: &str) -> bool {
5331    !name.is_empty()
5332        && name
5333            .bytes()
5334            .all(|b| b.is_ascii_uppercase() || b == b'-' || b == b'_')
5335}
5336
5337fn reftable_current_worktree_ref(name: &str) -> bool {
5338    is_root_ref_syntax(name)
5339        || name.starts_with("refs/bisect/")
5340        || name.starts_with("refs/worktree/")
5341        || name.starts_with("refs/rewritten/")
5342}
5343
5344fn reftable_other_worktree_ref(name: &str) -> Option<(&str, &str)> {
5345    let rest = name.strip_prefix("worktrees/")?;
5346    let (worktree, rewritten) = rest.split_once('/')?;
5347    if worktree.is_empty() || rewritten.is_empty() {
5348        return None;
5349    }
5350    Some((worktree, rewritten))
5351}
5352
5353pub fn validate_ref_name(name: &str) -> Result<()> {
5354    if name == "HEAD" {
5355        return Ok(());
5356    }
5357    if !name.starts_with("refs/") || check_refname_format(name, false).is_err() {
5358        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
5359    }
5360    Ok(())
5361}
5362
5363fn validate_ref_path_safe_for_read(name: &str) -> Result<()> {
5364    let path = Path::new(name);
5365    if !name.starts_with("refs/")
5366        || name.starts_with('/')
5367        || name.contains('\\')
5368        || path.is_absolute()
5369        || path.components().any(|component| {
5370            matches!(
5371                component,
5372                std::path::Component::ParentDir | std::path::Component::Prefix(_)
5373            )
5374        })
5375    {
5376        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
5377    }
5378    Ok(())
5379}
5380
5381fn safe_ref_prefix_for_directory_scan(prefix: &str) -> bool {
5382    let path = Path::new(prefix);
5383    prefix.starts_with("refs/")
5384        && !prefix.starts_with('/')
5385        && !prefix.contains('\\')
5386        && !path.is_absolute()
5387        && !path.components().any(|component| {
5388            matches!(
5389                component,
5390                std::path::Component::ParentDir | std::path::Component::Prefix(_)
5391            )
5392        })
5393}
5394
5395fn warn_broken_ref_name(name: &str) {
5396    eprintln!("warning: ignoring ref with broken name {name}");
5397}
5398
5399fn warn_broken_ref(name: &str) {
5400    eprintln!("warning: ignoring broken ref {name}");
5401}
5402
5403/// A direct ref resolving to the null OID is broken (git's `REF_ISBROKEN`).
5404fn ref_target_is_broken(target: &RefTarget) -> bool {
5405    matches!(target, RefTarget::Direct(oid) if oid.is_null())
5406}
5407
5408fn ref_directory_conflict_error(new_ref: &str, existing_ref: &str) -> GitError {
5409    GitError::Transaction(format!(
5410        "cannot lock ref '{new_ref}': '{existing_ref}' exists; cannot create '{new_ref}'"
5411    ))
5412}
5413
5414fn check_ref_directory_conflict_in_names(name: &str, names: &BTreeSet<String>) -> Result<()> {
5415    let components = name.split('/').collect::<Vec<_>>();
5416    for index in 1..components.len() {
5417        let ancestor = components[..index].join("/");
5418        if names.contains(&ancestor) {
5419            return Err(ref_directory_conflict_error(name, &ancestor));
5420        }
5421    }
5422    let child_prefix = format!("{name}/");
5423    if let Some(existing) = names.range(child_prefix.clone()..).next()
5424        && existing.starts_with(&child_prefix)
5425    {
5426        return Err(ref_directory_conflict_error(name, existing));
5427    }
5428    Ok(())
5429}
5430
5431fn parent_to_ref_name(base: &Path, parent: &Path) -> String {
5432    match parent.strip_prefix(base) {
5433        Ok(suffix) => suffix.to_string_lossy().replace('\\', "/"),
5434        Err(_) => parent.to_string_lossy().into_owned(),
5435    }
5436}
5437
5438fn validate_namespaced_ref(name: &str, prefix: &str, kind: &str) -> Result<()> {
5439    validate_ref_name(name)?;
5440    if name
5441        .strip_prefix(prefix)
5442        .is_none_or(|short_name| short_name.is_empty())
5443    {
5444        return Err(GitError::InvalidPath(format!(
5445            "invalid {kind} ref name {name}"
5446        )));
5447    }
5448    Ok(())
5449}
5450
5451fn validate_short_ref_name(kind: &str, name: &str) -> Result<()> {
5452    if name.is_empty()
5453        || name.starts_with('-')
5454        || name.starts_with('/')
5455        || name.ends_with('/')
5456        || name.contains(' ')
5457        || name.contains('\\')
5458    {
5459        return Err(GitError::InvalidPath(format!("invalid {kind} name {name}")));
5460    }
5461    Ok(())
5462}
5463
5464fn validate_remote_name(remote: &str) -> Result<()> {
5465    validate_short_ref_name("remote", remote)?;
5466    if remote.contains('/') {
5467        return Err(GitError::InvalidPath(format!(
5468            "invalid remote name {remote}"
5469        )));
5470    }
5471    Ok(())
5472}
5473
5474fn prepare_bundle_ref_updates<F>(
5475    refs: &[BundleRefUpdate],
5476    reflog: Option<&BundleRefUpdateReflog>,
5477    mut read_ref: F,
5478) -> Result<(Vec<RefUpdate>, Vec<AppliedBundleRefUpdate>)>
5479where
5480    F: FnMut(&str, &ObjectId) -> Result<Option<RefTarget>>,
5481{
5482    let mut seen = BTreeSet::new();
5483    let mut updates = Vec::with_capacity(refs.len());
5484    let mut applied = Vec::with_capacity(refs.len());
5485    for bundle_ref in refs {
5486        validate_ref_name(&bundle_ref.name)?;
5487        if !seen.insert(bundle_ref.name.clone()) {
5488            return Err(GitError::Transaction(format!(
5489                "duplicate bundle ref {}",
5490                bundle_ref.name
5491            )));
5492        }
5493        let old_oid = match read_ref(&bundle_ref.name, &bundle_ref.oid)? {
5494            Some(RefTarget::Direct(oid)) => Some(oid),
5495            Some(RefTarget::Symbolic(target)) => {
5496                return Err(GitError::Transaction(format!(
5497                    "bundle ref {} would overwrite symbolic ref {target}",
5498                    bundle_ref.name
5499                )));
5500            }
5501            None => None,
5502        };
5503        let reflog = match reflog {
5504            Some(reflog) => Some(ReflogEntry {
5505                old_oid: match &old_oid {
5506                    Some(oid) => *oid,
5507                    None => null_oid(bundle_ref.oid.format())?,
5508                },
5509                new_oid: bundle_ref.oid,
5510                committer: reflog.committer.clone(),
5511                message: reflog.message.clone(),
5512            }),
5513            None => None,
5514        };
5515        updates.push(RefUpdate {
5516            name: bundle_ref.name.clone(),
5517            expected: old_oid.map(RefTarget::Direct),
5518            new: RefTarget::Direct(bundle_ref.oid),
5519            reflog,
5520        });
5521        applied.push(AppliedBundleRefUpdate {
5522            name: bundle_ref.name.clone(),
5523            old_oid,
5524            new_oid: bundle_ref.oid,
5525        });
5526    }
5527    Ok((updates, applied))
5528}
5529
5530fn null_oid(format: ObjectFormat) -> Result<ObjectId> {
5531    Ok(ObjectId::null(format))
5532}
5533
5534#[cfg(test)]
5535mod tests {
5536    use super::*;
5537    use std::sync::atomic::{AtomicU64, Ordering};
5538
5539    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
5540
5541    #[test]
5542    fn loose_ref_round_trips_direct() {
5543        let oid = "ce013625030ba8dba906f756967f9e9ca394464a";
5544        let reference = parse_loose_ref(ObjectFormat::Sha1, "refs/heads/main", oid.as_bytes())
5545            .expect("test operation should succeed");
5546        assert_eq!(write_loose_ref(&reference), format!("{oid}\n").into_bytes());
5547    }
5548
5549    #[test]
5550    fn loose_fetch_head_reads_first_object_id() {
5551        let oid = ObjectId::from_hex(
5552            ObjectFormat::Sha1,
5553            "ce013625030ba8dba906f756967f9e9ca394464a",
5554        )
5555        .expect("test operation should succeed");
5556        let bytes = b"ce013625030ba8dba906f756967f9e9ca394464a\t\tbranch 'main' of ../sub\n";
5557        let reference = parse_loose_ref(ObjectFormat::Sha1, "FETCH_HEAD", bytes)
5558            .expect("test operation should succeed");
5559        assert_eq!(reference.target, RefTarget::Direct(oid));
5560    }
5561
5562    #[test]
5563    fn symref_names_allow_onelevel_pseudo_refs() {
5564        for name in ["NOTHEAD", "FOO", "ORIG_HEAD", "TEST_SYMREF"] {
5565            validate_symref_name(name).expect("symref name should be valid");
5566        }
5567        assert!(validate_ref_name("NOTHEAD").is_err());
5568        assert!(validate_symref_target("refs/heads/foo").is_ok());
5569        assert!(validate_symref_target("ORIG_HEAD").is_ok());
5570        assert!(validate_symref_target("foo..bar").is_err());
5571    }
5572
5573    #[test]
5574    fn resolve_ref_peeled_follows_symref_chains() {
5575        let git_dir = temp_git_dir();
5576        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5577        let oid = ObjectId::from_hex(
5578            ObjectFormat::Sha1,
5579            "ce013625030ba8dba906f756967f9e9ca394464a",
5580        )
5581        .expect("test operation should succeed");
5582        let mut tx = store.transaction();
5583        tx.update(RefUpdate {
5584            name: "refs/heads/target".into(),
5585            expected: None,
5586            new: RefTarget::Direct(oid),
5587            reflog: None,
5588        });
5589        tx.commit().expect("seed target ref");
5590        let mut tx = store.transaction();
5591        tx.update(RefUpdate {
5592            name: "refs/heads/alias".into(),
5593            expected: None,
5594            new: RefTarget::Symbolic("refs/heads/target".into()),
5595            reflog: None,
5596        });
5597        tx.commit().expect("seed alias ref");
5598        let mut tx = store.transaction();
5599        tx.update(RefUpdate {
5600            name: "ORIG_HEAD".into(),
5601            expected: None,
5602            new: RefTarget::Symbolic("refs/heads/alias".into()),
5603            reflog: None,
5604        });
5605        tx.commit().expect("seed ORIG_HEAD symref");
5606        assert_eq!(
5607            resolve_ref_peeled(&store, "ORIG_HEAD").expect("resolve ORIG_HEAD"),
5608            Some(oid)
5609        );
5610        let _ = fs::remove_dir_all(git_dir);
5611    }
5612
5613    #[test]
5614    fn symref_directory_conflict_is_reported_gracefully() {
5615        let git_dir = temp_git_dir();
5616        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5617        let oid = ObjectId::from_hex(
5618            ObjectFormat::Sha1,
5619            "ce013625030ba8dba906f756967f9e9ca394464a",
5620        )
5621        .expect("test operation should succeed");
5622        let mut tx = store.transaction();
5623        tx.update(RefUpdate {
5624            name: "refs/heads/df".into(),
5625            expected: None,
5626            new: RefTarget::Direct(oid),
5627            reflog: None,
5628        });
5629        tx.commit().expect("seed branch ref");
5630
5631        let mut tx = store.transaction();
5632        tx.update(RefUpdate {
5633            name: "refs/heads/df/conflict".into(),
5634            expected: None,
5635            new: RefTarget::Symbolic("refs/heads/df".into()),
5636            reflog: None,
5637        });
5638        let err = tx.commit().expect_err("child ref should conflict");
5639        assert!(
5640            matches!(err, GitError::Transaction(message) if message.contains(
5641            "cannot lock ref 'refs/heads/df/conflict'"
5642        ) && message.contains("refs/heads/df"))
5643        );
5644        let _ = fs::remove_dir_all(git_dir);
5645    }
5646
5647    #[test]
5648    fn transaction_checks_expected_value() {
5649        let oid = ObjectId::from_hex(
5650            ObjectFormat::Sha1,
5651            "ce013625030ba8dba906f756967f9e9ca394464a",
5652        )
5653        .expect("test operation should succeed");
5654        let mut store = RefStore::new();
5655        let mut tx = store.transaction();
5656        tx.update(RefUpdate {
5657            name: "refs/heads/main".into(),
5658            expected: None,
5659            new: RefTarget::Direct(oid),
5660            reflog: None,
5661        });
5662        tx.commit().expect("test operation should succeed");
5663        assert_eq!(store.get("refs/heads/main"), Some(&RefTarget::Direct(oid)));
5664    }
5665
5666    #[test]
5667    fn packed_refs_parse_peeled_refs() {
5668        let packed = b"# pack-refs with: peeled fully-peeled sorted \n\
5669ce013625030ba8dba906f756967f9e9ca394464a refs/tags/v1\n\
5670^e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n";
5671        let refs =
5672            parse_packed_refs(ObjectFormat::Sha1, packed).expect("test operation should succeed");
5673        assert_eq!(refs.len(), 1);
5674        assert_eq!(refs[0].reference.name, "refs/tags/v1");
5675        assert_eq!(
5676            refs[0]
5677                .peeled
5678                .as_ref()
5679                .expect("test operation should succeed")
5680                .to_hex(),
5681            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
5682        );
5683    }
5684
5685    #[test]
5686    fn packed_refs_write_sorted_with_peeled_refs() {
5687        let head_oid = ObjectId::from_hex(
5688            ObjectFormat::Sha1,
5689            "ce013625030ba8dba906f756967f9e9ca394464a",
5690        )
5691        .expect("test operation should succeed");
5692        let tag_oid = ObjectId::from_hex(
5693            ObjectFormat::Sha1,
5694            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5695        )
5696        .expect("test operation should succeed");
5697        let peeled_oid = ObjectId::from_hex(
5698            ObjectFormat::Sha1,
5699            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5700        )
5701        .expect("test operation should succeed");
5702        let refs = vec![
5703            PackedRef {
5704                reference: Ref {
5705                    name: "refs/tags/v1".into(),
5706                    target: RefTarget::Direct(tag_oid),
5707                },
5708                peeled: Some(peeled_oid),
5709            },
5710            PackedRef {
5711                reference: Ref {
5712                    name: "refs/heads/main".into(),
5713                    target: RefTarget::Direct(head_oid),
5714                },
5715                peeled: None,
5716            },
5717        ];
5718        let bytes = write_packed_refs(&refs).expect("test operation should succeed");
5719        let expected = format!(
5720            "# pack-refs with: peeled fully-peeled sorted \n\
5721{head_oid} refs/heads/main\n\
5722{tag_oid} refs/tags/v1\n\
5723^{peeled_oid}\n"
5724        );
5725        assert_eq!(
5726            String::from_utf8(bytes.clone()).expect("test operation should succeed"),
5727            expected
5728        );
5729        let parsed =
5730            parse_packed_refs(ObjectFormat::Sha1, &bytes).expect("test operation should succeed");
5731        assert_eq!(parsed[0], refs[1]);
5732        assert_eq!(parsed[1], refs[0]);
5733    }
5734
5735    #[test]
5736    fn full_ref_name_validates_and_round_trips_owned() {
5737        let full = FullRefName::new("refs/heads/main").expect("valid full branch ref");
5738        assert_eq!(full.as_str(), "refs/heads/main");
5739        assert_eq!(full.to_string(), "refs/heads/main");
5740        assert_eq!(full.to_owned().into_string(), "refs/heads/main");
5741
5742        let head = FullRefNameBuf::new("HEAD").expect("valid HEAD ref");
5743        assert_eq!(head.as_ref_name().into_str(), "HEAD");
5744
5745        assert!(FullRefName::new("main").is_err());
5746        assert!(FullRefNameBuf::new("refs/heads/bad.lock").is_err());
5747    }
5748
5749    #[test]
5750    fn branch_ref_name_helpers_validate_short_and_full_names() {
5751        let branch =
5752            BranchRefNameBuf::from_branch_name("feature/topic").expect("valid branch short name");
5753        assert_eq!(branch.as_str(), "refs/heads/feature/topic");
5754        assert_eq!(branch.branch_name(), "feature/topic");
5755        assert_eq!(
5756            branch.as_full_ref_name().as_str(),
5757            "refs/heads/feature/topic"
5758        );
5759        assert_eq!(
5760            branch_ref_name("feature/topic").expect("valid branch short name"),
5761            branch.as_str()
5762        );
5763
5764        let borrowed = BranchRefName::from_full("refs/heads/main").expect("valid full branch ref");
5765        assert_eq!(borrowed.branch_name(), "main");
5766        assert_eq!(borrowed.to_owned().into_string(), "refs/heads/main");
5767        assert_eq!(
5768            FullRefName::new("refs/heads/main")
5769                .expect("valid full branch ref")
5770                .as_branch()
5771                .expect("full ref is a branch")
5772                .branch_name(),
5773            "main"
5774        );
5775
5776        assert!(BranchRefName::from_full("refs/tags/main").is_err());
5777        assert!(BranchRefName::from_full("refs/heads").is_err());
5778        assert!(BranchRefNameBuf::from_branch_name("-bad").is_err());
5779    }
5780
5781    #[test]
5782    fn tag_ref_name_helpers_validate_short_and_full_names() {
5783        let tag = TagRefNameBuf::from_tag_name("v1.0").expect("valid tag short name");
5784        assert_eq!(tag.as_str(), "refs/tags/v1.0");
5785        assert_eq!(tag.tag_name(), "v1.0");
5786        assert_eq!(tag.as_full_ref_name().as_str(), "refs/tags/v1.0");
5787        assert_eq!(
5788            tag_ref_name("v1.0").expect("valid tag short name"),
5789            tag.as_str()
5790        );
5791
5792        let borrowed = TagRefName::from_full("refs/tags/release/1").expect("valid full tag ref");
5793        assert_eq!(borrowed.tag_name(), "release/1");
5794        assert_eq!(borrowed.to_owned().into_string(), "refs/tags/release/1");
5795        assert_eq!(
5796            FullRefName::new("refs/tags/release/1")
5797                .expect("valid full tag ref")
5798                .as_tag()
5799                .expect("full ref is a tag")
5800                .tag_name(),
5801            "release/1"
5802        );
5803
5804        assert!(TagRefName::from_full("refs/heads/v1.0").is_err());
5805        assert!(TagRefName::from_full("refs/tags").is_err());
5806        assert!(TagRefNameBuf::from_tag_name("bad tag").is_err());
5807    }
5808
5809    #[test]
5810    fn remote_ref_name_helpers_validate_namespace_and_components() {
5811        let remote = RemoteRefNameBuf::from_remote_branch("origin", "feature/topic")
5812            .expect("valid remote branch ref");
5813        assert_eq!(remote.as_str(), "refs/remotes/origin/feature/topic");
5814        assert_eq!(remote.short_name(), "origin/feature/topic");
5815        assert_eq!(remote.remote_name(), "origin");
5816        assert_eq!(remote.remote_branch(), Some("feature/topic"));
5817        assert_eq!(
5818            remote.as_full_ref_name().as_str(),
5819            "refs/remotes/origin/feature/topic"
5820        );
5821
5822        let head =
5823            RemoteRefName::from_full("refs/remotes/origin/HEAD").expect("valid remote HEAD ref");
5824        assert_eq!(head.remote_name(), "origin");
5825        assert_eq!(head.remote_branch(), Some("HEAD"));
5826        assert_eq!(
5827            FullRefName::new("refs/remotes/upstream/main")
5828                .expect("valid full remote ref")
5829                .as_remote()
5830                .expect("full ref is remote-tracking")
5831                .remote_name(),
5832            "upstream"
5833        );
5834
5835        let short =
5836            RemoteRefNameBuf::from_short_name("origin/main").expect("valid remote short ref");
5837        assert_eq!(short.as_str(), "refs/remotes/origin/main");
5838
5839        assert!(RemoteRefName::from_full("refs/heads/origin/main").is_err());
5840        assert!(RemoteRefName::from_full("refs/remotes/").is_err());
5841        assert!(RemoteRefNameBuf::from_remote_branch("origin/fork", "main").is_err());
5842    }
5843
5844    #[test]
5845    fn file_ref_store_writes_ref_and_reflog() {
5846        let git_dir = temp_git_dir();
5847        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5848        let oid = ObjectId::from_hex(
5849            ObjectFormat::Sha1,
5850            "ce013625030ba8dba906f756967f9e9ca394464a",
5851        )
5852        .expect("test operation should succeed");
5853        let mut tx = store.transaction();
5854        tx.update(RefUpdate {
5855            name: "refs/heads/main".into(),
5856            expected: None,
5857            new: RefTarget::Direct(oid),
5858            reflog: Some(ReflogEntry {
5859                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
5860                new_oid: oid,
5861                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
5862                message: b"update by test".to_vec(),
5863            }),
5864        });
5865        tx.commit().expect("test operation should succeed");
5866        assert_eq!(
5867            store
5868                .read_ref("refs/heads/main")
5869                .expect("test operation should succeed"),
5870            Some(RefTarget::Direct(oid))
5871        );
5872        let log = store
5873            .read_reflog("refs/heads/main")
5874            .expect("test operation should succeed");
5875        assert_eq!(log.len(), 1);
5876        assert_eq!(log[0].message, b"update by test");
5877        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5878    }
5879
5880    #[test]
5881    fn file_ref_store_applies_bundle_refs_with_reflog() {
5882        let git_dir = temp_git_dir();
5883        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5884        let old_main = ObjectId::from_hex(
5885            ObjectFormat::Sha1,
5886            "ce013625030ba8dba906f756967f9e9ca394464a",
5887        )
5888        .expect("test operation should succeed");
5889        let new_main = ObjectId::from_hex(
5890            ObjectFormat::Sha1,
5891            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5892        )
5893        .expect("test operation should succeed");
5894        let tag_oid = ObjectId::from_hex(
5895            ObjectFormat::Sha1,
5896            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5897        )
5898        .expect("test operation should succeed");
5899        let mut tx = store.transaction();
5900        tx.update(RefUpdate {
5901            name: "refs/heads/main".into(),
5902            expected: None,
5903            new: RefTarget::Direct(old_main.clone()),
5904            reflog: None,
5905        });
5906        tx.commit().expect("test operation should succeed");
5907
5908        let applied = store
5909            .apply_bundle_ref_updates(
5910                &[
5911                    BundleRefUpdate {
5912                        name: "refs/heads/main".into(),
5913                        oid: new_main.clone(),
5914                    },
5915                    BundleRefUpdate {
5916                        name: "refs/tags/v1.0".into(),
5917                        oid: tag_oid,
5918                    },
5919                ],
5920                Some(BundleRefUpdateReflog {
5921                    committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
5922                    message: b"bundle: import refs".to_vec(),
5923                }),
5924            )
5925            .expect("test operation should succeed");
5926
5927        assert_eq!(
5928            applied,
5929            vec![
5930                AppliedBundleRefUpdate {
5931                    name: "refs/heads/main".into(),
5932                    old_oid: Some(old_main.clone()),
5933                    new_oid: new_main.clone(),
5934                },
5935                AppliedBundleRefUpdate {
5936                    name: "refs/tags/v1.0".into(),
5937                    old_oid: None,
5938                    new_oid: tag_oid,
5939                }
5940            ]
5941        );
5942        assert_eq!(
5943            store
5944                .read_ref("refs/heads/main")
5945                .expect("test operation should succeed"),
5946            Some(RefTarget::Direct(new_main.clone()))
5947        );
5948        assert_eq!(
5949            store
5950                .read_ref("refs/tags/v1.0")
5951                .expect("test operation should succeed"),
5952            Some(RefTarget::Direct(tag_oid))
5953        );
5954        let main_log = store
5955            .read_reflog("refs/heads/main")
5956            .expect("test operation should succeed");
5957        assert_eq!(main_log.len(), 1);
5958        assert_eq!(main_log[0].old_oid, old_main);
5959        assert_eq!(main_log[0].new_oid, new_main);
5960        assert_eq!(main_log[0].message, b"bundle: import refs");
5961        let tag_log = store
5962            .read_reflog("refs/tags/v1.0")
5963            .expect("test operation should succeed");
5964        assert_eq!(tag_log.len(), 1);
5965        assert_eq!(
5966            tag_log[0].old_oid,
5967            zero_oid(ObjectFormat::Sha1).expect("test operation should succeed")
5968        );
5969        assert_eq!(tag_log[0].new_oid, tag_oid);
5970        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5971    }
5972
5973    #[test]
5974    fn file_ref_store_rejects_bad_bundle_ref_before_writing() {
5975        let git_dir = temp_git_dir();
5976        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5977        let oid = ObjectId::from_hex(
5978            ObjectFormat::Sha1,
5979            "ce013625030ba8dba906f756967f9e9ca394464a",
5980        )
5981        .expect("test operation should succeed");
5982
5983        let result = store.apply_bundle_ref_updates(
5984            &[
5985                BundleRefUpdate {
5986                    name: "refs/heads/main".into(),
5987                    oid,
5988                },
5989                BundleRefUpdate {
5990                    name: "refs/heads/bad.lock".into(),
5991                    oid,
5992                },
5993            ],
5994            None,
5995        );
5996
5997        assert!(result.is_err());
5998        assert_eq!(
5999            store
6000                .read_ref("refs/heads/main")
6001                .expect("test operation should succeed"),
6002            None
6003        );
6004        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6005    }
6006
6007    #[test]
6008    fn file_ref_store_rejects_bundle_ref_over_symbolic_ref() {
6009        let git_dir = temp_git_dir();
6010        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6011        let oid = ObjectId::from_hex(
6012            ObjectFormat::Sha1,
6013            "ce013625030ba8dba906f756967f9e9ca394464a",
6014        )
6015        .expect("test operation should succeed");
6016        let mut tx = store.transaction();
6017        tx.update(RefUpdate {
6018            name: "refs/heads/main".into(),
6019            expected: None,
6020            new: RefTarget::Symbolic("refs/heads/base".into()),
6021            reflog: None,
6022        });
6023        tx.commit().expect("test operation should succeed");
6024
6025        let result = store.apply_bundle_ref_updates(
6026            &[BundleRefUpdate {
6027                name: "refs/heads/main".into(),
6028                oid,
6029            }],
6030            None,
6031        );
6032
6033        assert!(result.is_err());
6034        assert_eq!(
6035            store
6036                .read_ref("refs/heads/main")
6037                .expect("test operation should succeed"),
6038            Some(RefTarget::Symbolic("refs/heads/base".into()))
6039        );
6040        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6041    }
6042
6043    #[test]
6044    fn file_ref_store_expires_reflog_entries_by_timestamp() {
6045        let git_dir = temp_git_dir();
6046        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6047        let first = ObjectId::from_hex(
6048            ObjectFormat::Sha1,
6049            "ce013625030ba8dba906f756967f9e9ca394464a",
6050        )
6051        .expect("test operation should succeed");
6052        let second = ObjectId::from_hex(
6053            ObjectFormat::Sha1,
6054            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
6055        )
6056        .expect("test operation should succeed");
6057        let mut tx = store.transaction();
6058        tx.update(RefUpdate {
6059            name: "refs/heads/main".into(),
6060            expected: None,
6061            new: RefTarget::Direct(first.clone()),
6062            reflog: Some(ReflogEntry {
6063                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
6064                new_oid: first.clone(),
6065                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
6066                message: b"old".to_vec(),
6067            }),
6068        });
6069        tx.update(RefUpdate {
6070            name: "refs/heads/main".into(),
6071            expected: None,
6072            new: RefTarget::Direct(second.clone()),
6073            reflog: Some(ReflogEntry {
6074                old_oid: first,
6075                new_oid: second.clone(),
6076                committer: b"Git Rs <sley@example.invalid> 100 +0000".to_vec(),
6077                message: b"new".to_vec(),
6078            }),
6079        });
6080        tx.commit().expect("test operation should succeed");
6081
6082        let removed = store
6083            .expire_reflog_older_than("refs/heads/main", 50)
6084            .expect("test operation should succeed");
6085        assert_eq!(removed, 1);
6086        let log = store
6087            .read_reflog("refs/heads/main")
6088            .expect("test operation should succeed");
6089        assert_eq!(log.len(), 1);
6090        assert_eq!(log[0].new_oid, second);
6091        assert_eq!(log[0].message, b"new");
6092        assert!(
6093            !git_dir
6094                .join("logs")
6095                .join("refs")
6096                .join("heads")
6097                .join("main.lock")
6098                .exists()
6099        );
6100        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6101    }
6102
6103    #[test]
6104    fn file_ref_store_creates_branch() {
6105        let git_dir = temp_git_dir();
6106        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6107        let oid = ObjectId::from_hex(
6108            ObjectFormat::Sha1,
6109            "ce013625030ba8dba906f756967f9e9ca394464a",
6110        )
6111        .expect("test operation should succeed");
6112        let branch = store
6113            .create_branch(
6114                "feature",
6115                oid,
6116                b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
6117                b"branch: Created from main".to_vec(),
6118            )
6119            .expect("test operation should succeed");
6120        assert_eq!(branch.name, "refs/heads/feature");
6121        assert_eq!(
6122            store
6123                .read_ref("refs/heads/feature")
6124                .expect("test operation should succeed"),
6125            Some(RefTarget::Direct(oid))
6126        );
6127        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6128    }
6129
6130    #[test]
6131    fn file_ref_store_deletes_loose_branch() {
6132        let git_dir = temp_git_dir();
6133        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6134        let oid = ObjectId::from_hex(
6135            ObjectFormat::Sha1,
6136            "ce013625030ba8dba906f756967f9e9ca394464a",
6137        )
6138        .expect("test operation should succeed");
6139        store
6140            .create_branch(
6141                "feature",
6142                oid,
6143                b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
6144                b"branch: Created from main".to_vec(),
6145            )
6146            .expect("test operation should succeed");
6147        let deleted = store
6148            .delete_branch("feature")
6149            .expect("test operation should succeed");
6150        assert_eq!(deleted.name, "refs/heads/feature");
6151        assert_eq!(deleted.oid, oid);
6152        assert_eq!(
6153            store
6154                .read_ref("refs/heads/feature")
6155                .expect("test operation should succeed"),
6156            None
6157        );
6158        assert!(!git_dir.join("refs").join("heads").join("feature").exists());
6159        assert!(
6160            !git_dir
6161                .join("logs")
6162                .join("refs")
6163                .join("heads")
6164                .join("feature")
6165                .exists()
6166        );
6167        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6168    }
6169
6170    #[test]
6171    fn file_ref_store_deletes_generic_loose_ref() {
6172        let git_dir = temp_git_dir();
6173        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6174        let oid = ObjectId::from_hex(
6175            ObjectFormat::Sha1,
6176            "ce013625030ba8dba906f756967f9e9ca394464a",
6177        )
6178        .expect("test operation should succeed");
6179        let mut tx = store.transaction();
6180        tx.update(RefUpdate {
6181            name: "refs/heads/topic".into(),
6182            expected: None,
6183            new: RefTarget::Direct(oid),
6184            reflog: Some(ReflogEntry {
6185                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
6186                new_oid: oid,
6187                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
6188                message: b"update by test".to_vec(),
6189            }),
6190        });
6191        tx.commit().expect("test operation should succeed");
6192        let deleted = store
6193            .delete_ref("refs/heads/topic")
6194            .expect("test operation should succeed");
6195        assert_eq!(deleted.name, "refs/heads/topic");
6196        assert_eq!(deleted.oid, oid);
6197        assert_eq!(
6198            store
6199                .read_ref("refs/heads/topic")
6200                .expect("test operation should succeed"),
6201            None
6202        );
6203        assert!(!git_dir.join("refs").join("heads").join("topic").exists());
6204        assert!(
6205            !git_dir
6206                .join("logs")
6207                .join("refs")
6208                .join("heads")
6209                .join("topic")
6210                .exists()
6211        );
6212        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6213    }
6214
6215    #[test]
6216    fn file_ref_store_delete_ref_checked_removes_reflog() {
6217        let git_dir = temp_git_dir();
6218        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6219        let oid = ObjectId::from_hex(
6220            ObjectFormat::Sha1,
6221            "ce013625030ba8dba906f756967f9e9ca394464a",
6222        )
6223        .expect("test operation should succeed");
6224        // Create the ref *with* a reflog entry so logs/refs/heads/main exists on
6225        // disk; git unlinks that file on delete rather than appending a deletion
6226        // entry, so the checked delete must remove it (mirroring delete_ref).
6227        let mut tx = store.transaction();
6228        tx.update(RefUpdate {
6229            name: "refs/heads/main".into(),
6230            expected: None,
6231            new: RefTarget::Direct(oid),
6232            reflog: Some(ReflogEntry {
6233                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
6234                new_oid: oid,
6235                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
6236                message: b"create main".to_vec(),
6237            }),
6238        });
6239        tx.commit().expect("test operation should succeed");
6240        assert!(
6241            git_dir
6242                .join("logs")
6243                .join("refs")
6244                .join("heads")
6245                .join("main")
6246                .exists(),
6247            "reflog file should exist before the checked delete"
6248        );
6249
6250        let deleted = store
6251            .delete_ref_checked(DeleteRef {
6252                name: "refs/heads/main".into(),
6253                expected_old: Some(oid),
6254                reflog: Some(DeleteRefReflog {
6255                    committer: b"Git Rs <sley@example.invalid> 123 +0000".to_vec(),
6256                    message: b"delete main".to_vec(),
6257                }),
6258            })
6259            .expect("test operation should succeed");
6260
6261        assert_eq!(deleted.name, "refs/heads/main");
6262        assert_eq!(deleted.oid, oid);
6263        assert_eq!(
6264            store
6265                .read_ref("refs/heads/main")
6266                .expect("test operation should succeed"),
6267            None
6268        );
6269        // Git unlinks the reflog on delete: the file is gone and there is no
6270        // lingering deletion entry to read back.
6271        assert!(
6272            !git_dir
6273                .join("logs")
6274                .join("refs")
6275                .join("heads")
6276                .join("main")
6277                .exists(),
6278            "reflog file should be removed by the checked delete"
6279        );
6280        assert!(
6281            store
6282                .read_reflog("refs/heads/main")
6283                .expect("test operation should succeed")
6284                .is_empty()
6285        );
6286        assert!(
6287            !git_dir
6288                .join("refs")
6289                .join("heads")
6290                .join("main.lock")
6291                .exists()
6292        );
6293        assert!(!git_dir.join("packed-refs.lock").exists());
6294        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6295    }
6296
6297    #[test]
6298    fn file_ref_store_delete_ref_checked_stale_expected_leaves_ref_untouched() {
6299        let git_dir = temp_git_dir();
6300        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6301        let actual = ObjectId::from_hex(
6302            ObjectFormat::Sha1,
6303            "ce013625030ba8dba906f756967f9e9ca394464a",
6304        )
6305        .expect("test operation should succeed");
6306        let expected = ObjectId::from_hex(
6307            ObjectFormat::Sha1,
6308            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
6309        )
6310        .expect("test operation should succeed");
6311        let mut tx = store.transaction();
6312        tx.update(RefUpdate {
6313            name: "refs/heads/main".into(),
6314            expected: None,
6315            new: RefTarget::Direct(actual),
6316            reflog: None,
6317        });
6318        tx.commit().expect("test operation should succeed");
6319
6320        let err = store
6321            .delete_ref_checked(DeleteRef {
6322                name: "refs/heads/main".into(),
6323                expected_old: Some(expected),
6324                reflog: None,
6325            })
6326            .expect_err("stale expected must fail");
6327
6328        assert!(matches!(
6329            err,
6330            RefDeleteError::ExpectedMismatch {
6331                expected: Some(got_expected),
6332                actual: Some(got_actual),
6333            } if got_expected == expected && got_actual == actual
6334        ));
6335        assert_eq!(
6336            store
6337                .read_ref("refs/heads/main")
6338                .expect("test operation should succeed"),
6339            Some(RefTarget::Direct(actual))
6340        );
6341        assert!(
6342            !git_dir
6343                .join("refs")
6344                .join("heads")
6345                .join("main.lock")
6346                .exists()
6347        );
6348        assert!(!git_dir.join("packed-refs.lock").exists());
6349        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6350    }
6351
6352    #[test]
6353    fn file_ref_store_delete_ref_checked_missing_returns_not_found() {
6354        let git_dir = temp_git_dir();
6355        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6356
6357        let err = store
6358            .delete_ref_checked(DeleteRef {
6359                name: "refs/heads/missing".into(),
6360                expected_old: None,
6361                reflog: None,
6362            })
6363            .expect_err("missing ref must fail");
6364
6365        assert!(matches!(err, RefDeleteError::NotFound));
6366        assert!(
6367            !git_dir
6368                .join("refs")
6369                .join("heads")
6370                .join("missing.lock")
6371                .exists()
6372        );
6373        assert!(!git_dir.join("packed-refs.lock").exists());
6374        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6375    }
6376
6377    #[test]
6378    fn file_ref_store_delete_ref_checked_removes_packed_ref() {
6379        let git_dir = temp_git_dir();
6380        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6381        let oid = ObjectId::from_hex(
6382            ObjectFormat::Sha1,
6383            "ce013625030ba8dba906f756967f9e9ca394464a",
6384        )
6385        .expect("test operation should succeed");
6386        let other = ObjectId::from_hex(
6387            ObjectFormat::Sha1,
6388            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
6389        )
6390        .expect("test operation should succeed");
6391        store
6392            .write_packed_refs(&[
6393                PackedRef {
6394                    reference: Ref {
6395                        name: "refs/heads/main".into(),
6396                        target: RefTarget::Direct(oid),
6397                    },
6398                    peeled: None,
6399                },
6400                PackedRef {
6401                    reference: Ref {
6402                        name: "refs/heads/other".into(),
6403                        target: RefTarget::Direct(other),
6404                    },
6405                    peeled: None,
6406                },
6407            ])
6408            .expect("test operation should succeed");
6409
6410        store
6411            .delete_ref_checked(DeleteRef {
6412                name: "refs/heads/main".into(),
6413                expected_old: Some(oid),
6414                reflog: None,
6415            })
6416            .expect("test operation should succeed");
6417
6418        assert_eq!(
6419            store
6420                .read_ref("refs/heads/main")
6421                .expect("test operation should succeed"),
6422            None
6423        );
6424        assert_eq!(
6425            store
6426                .read_ref("refs/heads/other")
6427                .expect("test operation should succeed"),
6428            Some(RefTarget::Direct(other))
6429        );
6430        let packed =
6431            fs::read_to_string(git_dir.join("packed-refs")).expect("test operation should succeed");
6432        assert!(!packed.contains("refs/heads/main"));
6433        assert!(packed.contains("refs/heads/other"));
6434        assert!(
6435            !git_dir
6436                .join("refs")
6437                .join("heads")
6438                .join("main.lock")
6439                .exists()
6440        );
6441        assert!(!git_dir.join("packed-refs.lock").exists());
6442        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6443    }
6444
6445    #[test]
6446    fn file_ref_store_delete_ref_checked_lock_conflict_returns_locked() {
6447        let git_dir = temp_git_dir();
6448        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6449        let oid = ObjectId::from_hex(
6450            ObjectFormat::Sha1,
6451            "ce013625030ba8dba906f756967f9e9ca394464a",
6452        )
6453        .expect("test operation should succeed");
6454        let mut tx = store.transaction();
6455        tx.update(RefUpdate {
6456            name: "refs/heads/main".into(),
6457            expected: None,
6458            new: RefTarget::Direct(oid),
6459            reflog: None,
6460        });
6461        tx.commit().expect("test operation should succeed");
6462        fs::write(
6463            git_dir.join("refs").join("heads").join("main.lock"),
6464            b"held\n",
6465        )
6466        .expect("test operation should succeed");
6467
6468        let err = store
6469            .delete_ref_checked(DeleteRef {
6470                name: "refs/heads/main".into(),
6471                expected_old: Some(oid),
6472                reflog: None,
6473            })
6474            .expect_err("held lock must fail");
6475
6476        assert!(matches!(err, RefDeleteError::Locked));
6477        assert_eq!(
6478            store
6479                .read_ref("refs/heads/main")
6480                .expect("test operation should succeed"),
6481            Some(RefTarget::Direct(oid))
6482        );
6483        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6484    }
6485
6486    #[test]
6487    fn file_ref_store_reports_current_branch() {
6488        let git_dir = temp_git_dir();
6489        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
6490            .expect("test operation should succeed");
6491        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6492        assert_eq!(
6493            store
6494                .current_branch_ref()
6495                .expect("test operation should succeed"),
6496            Some("refs/heads/main".into())
6497        );
6498        assert_eq!(
6499            store
6500                .current_branch()
6501                .expect("test operation should succeed"),
6502            Some("main".into())
6503        );
6504        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6505    }
6506
6507    #[test]
6508    fn file_ref_store_resolves_linked_worktree_head_through_common_refs() {
6509        let common = temp_git_dir();
6510        let admin = common.join("worktrees").join("linked");
6511        fs::create_dir_all(&admin).expect("test operation should succeed");
6512        fs::write(admin.join("commondir"), "../..\n").expect("test operation should succeed");
6513        fs::write(admin.join("HEAD"), b"ref: refs/heads/topic\n")
6514            .expect("test operation should succeed");
6515        let oid = ObjectId::from_hex(
6516            ObjectFormat::Sha256,
6517            "08ffba112b648c22b5425f01bec2c37ffc524c4d48ef04337779df3973733050",
6518        )
6519        .expect("test operation should succeed");
6520        fs::create_dir_all(common.join("refs").join("heads"))
6521            .expect("test operation should succeed");
6522        fs::write(
6523            common.join("refs").join("heads").join("topic"),
6524            format!("{oid}\n"),
6525        )
6526        .expect("test operation should succeed");
6527
6528        let store = FileRefStore::new(&admin, ObjectFormat::Sha256);
6529        assert_eq!(
6530            store
6531                .read_ref("HEAD")
6532                .expect("test operation should succeed"),
6533            Some(RefTarget::Symbolic("refs/heads/topic".into()))
6534        );
6535        assert_eq!(
6536            store
6537                .read_ref("refs/heads/topic")
6538                .expect("test operation should succeed"),
6539            Some(RefTarget::Direct(oid))
6540        );
6541
6542        fs::remove_dir_all(common).expect("test operation should succeed");
6543    }
6544
6545    #[test]
6546    fn file_ref_store_creates_tag() {
6547        let git_dir = temp_git_dir();
6548        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6549        let oid = ObjectId::from_hex(
6550            ObjectFormat::Sha1,
6551            "ce013625030ba8dba906f756967f9e9ca394464a",
6552        )
6553        .expect("test operation should succeed");
6554        let tag = store
6555            .create_tag("v1.0", oid)
6556            .expect("test operation should succeed");
6557        assert_eq!(tag.name, "refs/tags/v1.0");
6558        assert_eq!(
6559            store
6560                .read_ref("refs/tags/v1.0")
6561                .expect("test operation should succeed"),
6562            Some(RefTarget::Direct(oid))
6563        );
6564        assert!(
6565            store
6566                .read_reflog("refs/tags/v1.0")
6567                .expect("test operation should succeed")
6568                .is_empty()
6569        );
6570        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6571    }
6572
6573    #[test]
6574    fn file_ref_store_deletes_loose_tag() {
6575        let git_dir = temp_git_dir();
6576        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6577        let oid = ObjectId::from_hex(
6578            ObjectFormat::Sha1,
6579            "ce013625030ba8dba906f756967f9e9ca394464a",
6580        )
6581        .expect("test operation should succeed");
6582        store
6583            .create_tag("v1.0", oid)
6584            .expect("test operation should succeed");
6585        let deleted = store
6586            .delete_tag("v1.0")
6587            .expect("test operation should succeed");
6588        assert_eq!(deleted.name, "refs/tags/v1.0");
6589        assert_eq!(deleted.oid, oid);
6590        assert_eq!(
6591            store
6592                .read_ref("refs/tags/v1.0")
6593                .expect("test operation should succeed"),
6594            None
6595        );
6596        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
6597        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6598    }
6599
6600    #[test]
6601    fn file_ref_store_reads_packed_ref() {
6602        let git_dir = temp_git_dir();
6603        fs::write(
6604            git_dir.join("packed-refs"),
6605            b"ce013625030ba8dba906f756967f9e9ca394464a refs/heads/main\n",
6606        )
6607        .expect("test operation should succeed");
6608        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6609        assert!(matches!(
6610            store
6611                .read_ref("refs/heads/main")
6612                .expect("test operation should succeed"),
6613            Some(RefTarget::Direct(_))
6614        ));
6615        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6616    }
6617
6618    #[test]
6619    fn file_ref_store_lists_loose_refs_over_packed_refs() {
6620        let git_dir = temp_git_dir();
6621        fs::write(
6622            git_dir.join("packed-refs"),
6623            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n",
6624        )
6625        .expect("test operation should succeed");
6626        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6627        let oid = ObjectId::from_hex(
6628            ObjectFormat::Sha1,
6629            "ce013625030ba8dba906f756967f9e9ca394464a",
6630        )
6631        .expect("test operation should succeed");
6632        let mut tx = store.transaction();
6633        tx.update(RefUpdate {
6634            name: "refs/heads/main".into(),
6635            expected: None,
6636            new: RefTarget::Direct(oid),
6637            reflog: None,
6638        });
6639        tx.commit().expect("test operation should succeed");
6640        let refs = store.list_refs().expect("test operation should succeed");
6641        assert_eq!(refs.len(), 1);
6642        assert_eq!(refs[0].target, RefTarget::Direct(oid));
6643        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6644    }
6645
6646    #[test]
6647    fn file_ref_store_lists_refs_with_prefix_and_preserves_loose_shadowing() {
6648        let git_dir = temp_git_dir();
6649        fs::write(
6650            git_dir.join("packed-refs"),
6651            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n\
6652              18f002b4484b838b205a48b1e9e6763ba5e3a607 refs/heads/topic\n\
6653              ce013625030ba8dba906f756967f9e9ca394464a refs/tags/v1.0\n",
6654        )
6655        .expect("test operation should succeed");
6656        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6657        let loose_main = ObjectId::from_hex(
6658            ObjectFormat::Sha1,
6659            "ce013625030ba8dba906f756967f9e9ca394464a",
6660        )
6661        .expect("test operation should succeed");
6662        let packed_topic = ObjectId::from_hex(
6663            ObjectFormat::Sha1,
6664            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
6665        )
6666        .expect("test operation should succeed");
6667        let mut tx = store.transaction();
6668        tx.update(RefUpdate {
6669            name: "refs/heads/main".into(),
6670            expected: None,
6671            new: RefTarget::Direct(loose_main),
6672            reflog: None,
6673        });
6674        tx.commit().expect("test operation should succeed");
6675
6676        assert_eq!(
6677            store
6678                .list_refs_with_prefix("refs/heads/")
6679                .expect("test operation should succeed"),
6680            vec![
6681                Ref {
6682                    name: "refs/heads/main".into(),
6683                    target: RefTarget::Direct(loose_main),
6684                },
6685                Ref {
6686                    name: "refs/heads/topic".into(),
6687                    target: RefTarget::Direct(packed_topic),
6688                },
6689            ]
6690        );
6691        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6692    }
6693
6694    #[test]
6695    fn file_ref_store_writes_packed_refs() {
6696        let git_dir = temp_git_dir();
6697        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6698        let oid = ObjectId::from_hex(
6699            ObjectFormat::Sha1,
6700            "ce013625030ba8dba906f756967f9e9ca394464a",
6701        )
6702        .expect("test operation should succeed");
6703        store
6704            .write_packed_refs(&[PackedRef {
6705                reference: Ref {
6706                    name: "refs/heads/main".into(),
6707                    target: RefTarget::Direct(oid),
6708                },
6709                peeled: None,
6710            }])
6711            .expect("test operation should succeed");
6712        assert_eq!(
6713            store
6714                .read_ref("refs/heads/main")
6715                .expect("test operation should succeed"),
6716            Some(RefTarget::Direct(oid))
6717        );
6718        let refs = store.list_refs().expect("test operation should succeed");
6719        assert_eq!(refs.len(), 1);
6720        assert_eq!(refs[0].target, RefTarget::Direct(oid));
6721        assert!(git_dir.join("packed-refs").exists());
6722        assert!(!git_dir.join("packed-refs.lock").exists());
6723        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6724    }
6725
6726    #[test]
6727    fn file_ref_store_checks_ref_prefix_in_packed_refs() {
6728        let git_dir = temp_git_dir();
6729        fs::write(
6730            git_dir.join("packed-refs"),
6731            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n\
6732              ce013625030ba8dba906f756967f9e9ca394464a refs/replace/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n",
6733        )
6734        .expect("test operation should succeed");
6735        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6736        assert!(
6737            store
6738                .has_refs_with_prefix("refs/replace/")
6739                .expect("test operation should succeed")
6740        );
6741        assert!(
6742            !store
6743                .has_refs_with_prefix("refs/notes/")
6744                .expect("test operation should succeed")
6745        );
6746        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6747    }
6748
6749    #[test]
6750    fn file_ref_store_checks_ref_prefix_in_loose_refs() {
6751        let git_dir = temp_git_dir();
6752        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6753        let oid = ObjectId::from_hex(
6754            ObjectFormat::Sha1,
6755            "ce013625030ba8dba906f756967f9e9ca394464a",
6756        )
6757        .expect("test operation should succeed");
6758        let mut tx = store.transaction();
6759        tx.update(RefUpdate {
6760            name: "refs/replace/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391".into(),
6761            expected: None,
6762            new: RefTarget::Direct(oid),
6763            reflog: None,
6764        });
6765        tx.commit().expect("test operation should succeed");
6766        assert!(
6767            store
6768                .has_refs_with_prefix("refs/replace/")
6769                .expect("test operation should succeed")
6770        );
6771        assert!(
6772            !store
6773                .has_refs_with_prefix("refs/notes/")
6774                .expect("test operation should succeed")
6775        );
6776        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6777    }
6778
6779    #[test]
6780    fn file_ref_store_reads_reftable_stack_and_ignores_dummy_head() {
6781        let git_dir = temp_git_dir();
6782        write_reftable_config(&git_dir);
6783        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/.invalid\n")
6784            .expect("test operation should succeed");
6785        let head_oid = ObjectId::from_hex(
6786            ObjectFormat::Sha1,
6787            "ce013625030ba8dba906f756967f9e9ca394464a",
6788        )
6789        .expect("test operation should succeed");
6790        let tag_oid = ObjectId::from_hex(
6791            ObjectFormat::Sha1,
6792            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
6793        )
6794        .expect("test operation should succeed");
6795        let peeled_oid = ObjectId::from_hex(
6796            ObjectFormat::Sha1,
6797            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
6798        )
6799        .expect("test operation should succeed");
6800        write_reftable_stack(
6801            &git_dir,
6802            &[(
6803                "0x000000000001-0x000000000001-00000000.ref",
6804                vec![
6805                    sley_formats::ReftableRefRecord {
6806                        name: "HEAD".into(),
6807                        update_index: 1,
6808                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
6809                    },
6810                    sley_formats::ReftableRefRecord {
6811                        name: "refs/heads/main".into(),
6812                        update_index: 1,
6813                        value: ReftableRefValue::Direct(head_oid),
6814                    },
6815                    sley_formats::ReftableRefRecord {
6816                        name: "refs/tags/v1.0".into(),
6817                        update_index: 1,
6818                        value: ReftableRefValue::Peeled {
6819                            target: tag_oid,
6820                            peeled: peeled_oid,
6821                        },
6822                    },
6823                ],
6824            )],
6825        );
6826
6827        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6828        assert_eq!(
6829            store
6830                .read_ref("HEAD")
6831                .expect("test operation should succeed"),
6832            Some(RefTarget::Symbolic("refs/heads/main".into()))
6833        );
6834        assert_eq!(
6835            store
6836                .read_ref("refs/heads/main")
6837                .expect("test operation should succeed"),
6838            Some(RefTarget::Direct(head_oid))
6839        );
6840        assert_eq!(
6841            store
6842                .read_ref("refs/tags/v1.0")
6843                .expect("test operation should succeed"),
6844            Some(RefTarget::Direct(tag_oid))
6845        );
6846        let refs = store.list_refs().expect("test operation should succeed");
6847        assert_eq!(
6848            refs,
6849            vec![
6850                Ref {
6851                    name: "refs/heads/main".into(),
6852                    target: RefTarget::Direct(head_oid),
6853                },
6854                Ref {
6855                    name: "refs/tags/v1.0".into(),
6856                    target: RefTarget::Direct(tag_oid),
6857                },
6858            ]
6859        );
6860        assert_eq!(
6861            store
6862                .list_refs_with_prefix("refs/tags/")
6863                .expect("test operation should succeed"),
6864            vec![Ref {
6865                name: "refs/tags/v1.0".into(),
6866                target: RefTarget::Direct(tag_oid),
6867            }]
6868        );
6869
6870        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6871    }
6872
6873    #[test]
6874    fn file_ref_store_reads_loose_fetch_head_in_reftable_repo() {
6875        let git_dir = temp_git_dir();
6876        write_reftable_config(&git_dir);
6877        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/.invalid\n")
6878            .expect("test operation should succeed");
6879        fs::create_dir_all(git_dir.join("reftable")).expect("test operation should succeed");
6880        fs::write(git_dir.join("reftable").join("tables.list"), b"")
6881            .expect("test operation should succeed");
6882        let oid = ObjectId::from_hex(
6883            ObjectFormat::Sha1,
6884            "ce013625030ba8dba906f756967f9e9ca394464a",
6885        )
6886        .expect("test operation should succeed");
6887        fs::write(
6888            git_dir.join("FETCH_HEAD"),
6889            b"ce013625030ba8dba906f756967f9e9ca394464a\t\tbranch 'main' of ../sub\n",
6890        )
6891        .expect("test operation should succeed");
6892
6893        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6894        assert_eq!(
6895            store
6896                .read_ref("FETCH_HEAD")
6897                .expect("test operation should succeed"),
6898            Some(RefTarget::Direct(oid))
6899        );
6900        assert!(
6901            store
6902                .raw_ref_exists("FETCH_HEAD")
6903                .expect("test operation should succeed")
6904        );
6905
6906        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6907    }
6908
6909    #[test]
6910    fn file_ref_store_empty_reftable_reflog_rewrite_keeps_marker() {
6911        let git_dir = temp_git_dir();
6912        write_reftable_config(&git_dir);
6913        fs::create_dir_all(git_dir.join("reftable")).expect("test operation should succeed");
6914        fs::write(git_dir.join("reftable").join("tables.list"), b"")
6915            .expect("test operation should succeed");
6916        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6917
6918        store
6919            .write_reflog("refs/heads/main", &[])
6920            .expect("test operation should succeed");
6921
6922        assert!(
6923            store
6924                .read_reflog("refs/heads/main")
6925                .expect("test operation should succeed")
6926                .is_empty()
6927        );
6928        let tables = store.reftables().expect("test operation should succeed");
6929        let marker = tables
6930            .iter()
6931            .flat_map(|table| table.logs.iter())
6932            .find(|record| record.refname == "refs/heads/main")
6933            .expect("empty reflog marker should exist");
6934        let ReftableLogValue::Update(update) = &marker.value else {
6935            panic!("empty reflog marker should be an update");
6936        };
6937        let null = ObjectId::null(ObjectFormat::Sha1);
6938        assert_eq!(update.old_oid, null);
6939        assert_eq!(update.new_oid, null);
6940
6941        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6942    }
6943
6944    #[test]
6945    fn file_ref_store_applies_reftable_stack_overrides_and_deletions() {
6946        let git_dir = temp_git_dir();
6947        write_reftable_config(&git_dir);
6948        let first = ObjectId::from_hex(
6949            ObjectFormat::Sha1,
6950            "ce013625030ba8dba906f756967f9e9ca394464a",
6951        )
6952        .expect("test operation should succeed");
6953        let second = ObjectId::from_hex(
6954            ObjectFormat::Sha1,
6955            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
6956        )
6957        .expect("test operation should succeed");
6958        write_reftable_stack(
6959            &git_dir,
6960            &[
6961                (
6962                    "0x000000000001-0x000000000001-00000000.ref",
6963                    vec![
6964                        sley_formats::ReftableRefRecord {
6965                            name: "refs/heads/main".into(),
6966                            update_index: 1,
6967                            value: ReftableRefValue::Direct(first),
6968                        },
6969                        sley_formats::ReftableRefRecord {
6970                            name: "refs/heads/topic".into(),
6971                            update_index: 1,
6972                            value: ReftableRefValue::Direct(second.clone()),
6973                        },
6974                    ],
6975                ),
6976                (
6977                    "000000000002-000000000002-tip.ref",
6978                    vec![
6979                        sley_formats::ReftableRefRecord {
6980                            name: "refs/heads/main".into(),
6981                            update_index: 2,
6982                            value: ReftableRefValue::Direct(second.clone()),
6983                        },
6984                        sley_formats::ReftableRefRecord {
6985                            name: "refs/heads/topic".into(),
6986                            update_index: 2,
6987                            value: ReftableRefValue::Deletion,
6988                        },
6989                    ],
6990                ),
6991            ],
6992        );
6993
6994        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6995        assert_eq!(
6996            store
6997                .read_ref("refs/heads/main")
6998                .expect("test operation should succeed"),
6999            Some(RefTarget::Direct(second.clone()))
7000        );
7001        assert_eq!(
7002            store
7003                .read_ref("refs/heads/topic")
7004                .expect("test operation should succeed"),
7005            None
7006        );
7007        assert_eq!(
7008            store.list_refs().expect("test operation should succeed"),
7009            vec![Ref {
7010                name: "refs/heads/main".into(),
7011                target: RefTarget::Direct(second),
7012            }]
7013        );
7014
7015        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7016    }
7017
7018    #[test]
7019    fn file_ref_store_writes_reftable_transaction_table() {
7020        let git_dir = temp_git_dir();
7021        write_reftable_config(&git_dir);
7022        let first = ObjectId::from_hex(
7023            ObjectFormat::Sha1,
7024            "ce013625030ba8dba906f756967f9e9ca394464a",
7025        )
7026        .expect("test operation should succeed");
7027        let second = ObjectId::from_hex(
7028            ObjectFormat::Sha1,
7029            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7030        )
7031        .expect("test operation should succeed");
7032        write_reftable_stack(
7033            &git_dir,
7034            &[(
7035                "0x000000000001-0x000000000001-00000000.ref",
7036                vec![sley_formats::ReftableRefRecord {
7037                    name: "refs/heads/main".into(),
7038                    update_index: 1,
7039                    value: ReftableRefValue::Direct(first),
7040                }],
7041            )],
7042        );
7043
7044        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7045        let mut tx = store.transaction();
7046        tx.update(RefUpdate {
7047            name: "HEAD".into(),
7048            expected: None,
7049            new: RefTarget::Symbolic("refs/heads/main".into()),
7050            reflog: None,
7051        });
7052        tx.update(RefUpdate {
7053            name: "refs/heads/main".into(),
7054            expected: None,
7055            new: RefTarget::Direct(second.clone()),
7056            reflog: None,
7057        });
7058        tx.commit().expect("test operation should succeed");
7059
7060        assert_eq!(
7061            store
7062                .read_ref("HEAD")
7063                .expect("test operation should succeed"),
7064            Some(RefTarget::Symbolic("refs/heads/main".into()))
7065        );
7066        assert_eq!(
7067            store
7068                .read_ref("refs/heads/main")
7069                .expect("test operation should succeed"),
7070            Some(RefTarget::Direct(second.clone()))
7071        );
7072        assert_eq!(
7073            store
7074                .list_refs()
7075                .expect("test operation should succeed")
7076                .len(),
7077            1
7078        );
7079        assert!(!git_dir.join("HEAD").exists());
7080        let tables = fs::read_to_string(git_dir.join("reftable").join("tables.list"))
7081            .expect("test operation should succeed");
7082        assert_eq!(tables.lines().count(), 2);
7083        let last = tables
7084            .lines()
7085            .last()
7086            .expect("test operation should succeed");
7087        // The rust-written table name follows git's `0x%012x-0x%012x-%08x.ref`
7088        // shape (reftable/stack.c::format_name) so `git fsck` accepts it; the
7089        // earlier `-sley-<nanos>` token tripped `badReftableTableName`.
7090        assert!(
7091            last.starts_with("0x") && last.ends_with(".ref"),
7092            "expected git-format reftable name in tables.list, got {tables}"
7093        );
7094        assert!(
7095            reftable_table_name_is_valid(last),
7096            "rust-written reftable name must parse as git's hex format, got {last}"
7097        );
7098
7099        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7100    }
7101
7102    #[test]
7103    fn file_ref_store_deletes_reftable_refs_with_tombstones() {
7104        let git_dir = temp_git_dir();
7105        write_reftable_config(&git_dir);
7106        let oid = ObjectId::from_hex(
7107            ObjectFormat::Sha1,
7108            "ce013625030ba8dba906f756967f9e9ca394464a",
7109        )
7110        .expect("test operation should succeed");
7111        write_reftable_stack(
7112            &git_dir,
7113            &[(
7114                "0x000000000001-0x000000000001-00000000.ref",
7115                vec![
7116                    sley_formats::ReftableRefRecord {
7117                        name: "refs/heads/main".into(),
7118                        update_index: 1,
7119                        value: ReftableRefValue::Direct(oid),
7120                    },
7121                    sley_formats::ReftableRefRecord {
7122                        name: "refs/alias/main".into(),
7123                        update_index: 1,
7124                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
7125                    },
7126                ],
7127            )],
7128        );
7129
7130        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7131        assert!(
7132            store
7133                .delete_symbolic_ref("refs/alias/main")
7134                .expect("test operation should succeed")
7135        );
7136        assert_eq!(
7137            store
7138                .read_ref("refs/alias/main")
7139                .expect("test operation should succeed"),
7140            None
7141        );
7142        let deleted = store
7143            .delete_ref("refs/heads/main")
7144            .expect("test operation should succeed");
7145        assert_eq!(deleted.oid, oid);
7146        assert_eq!(
7147            store
7148                .read_ref("refs/heads/main")
7149                .expect("test operation should succeed"),
7150            None
7151        );
7152        assert!(
7153            store
7154                .list_refs()
7155                .expect("test operation should succeed")
7156                .is_empty()
7157        );
7158        let tables = fs::read_to_string(git_dir.join("reftable").join("tables.list"))
7159            .expect("test operation should succeed");
7160        assert_eq!(tables.lines().count(), 3);
7161
7162        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7163    }
7164
7165    #[test]
7166    fn file_ref_store_deletes_packed_branch() {
7167        let git_dir = temp_git_dir();
7168        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7169        let branch_oid = ObjectId::from_hex(
7170            ObjectFormat::Sha1,
7171            "ce013625030ba8dba906f756967f9e9ca394464a",
7172        )
7173        .expect("test operation should succeed");
7174        let tag_oid = ObjectId::from_hex(
7175            ObjectFormat::Sha1,
7176            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7177        )
7178        .expect("test operation should succeed");
7179        store
7180            .write_packed_refs(&[
7181                PackedRef {
7182                    reference: Ref {
7183                        name: "refs/heads/feature".into(),
7184                        target: RefTarget::Direct(branch_oid),
7185                    },
7186                    peeled: None,
7187                },
7188                PackedRef {
7189                    reference: Ref {
7190                        name: "refs/tags/v1.0".into(),
7191                        target: RefTarget::Direct(tag_oid),
7192                    },
7193                    peeled: None,
7194                },
7195            ])
7196            .expect("test operation should succeed");
7197        let deleted = store
7198            .delete_branch("feature")
7199            .expect("test operation should succeed");
7200        assert_eq!(deleted.name, "refs/heads/feature");
7201        assert_eq!(deleted.oid, branch_oid);
7202        assert_eq!(
7203            store
7204                .read_ref("refs/heads/feature")
7205                .expect("test operation should succeed"),
7206            None
7207        );
7208        assert_eq!(
7209            store
7210                .read_ref("refs/tags/v1.0")
7211                .expect("test operation should succeed"),
7212            Some(RefTarget::Direct(tag_oid))
7213        );
7214        assert!(!git_dir.join("packed-refs.lock").exists());
7215        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7216    }
7217
7218    #[test]
7219    fn file_ref_store_deletes_packed_tag() {
7220        let git_dir = temp_git_dir();
7221        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7222        let oid = ObjectId::from_hex(
7223            ObjectFormat::Sha1,
7224            "ce013625030ba8dba906f756967f9e9ca394464a",
7225        )
7226        .expect("test operation should succeed");
7227        store
7228            .write_packed_refs(&[PackedRef {
7229                reference: Ref {
7230                    name: "refs/tags/v1.0".into(),
7231                    target: RefTarget::Direct(oid),
7232                },
7233                peeled: None,
7234            }])
7235            .expect("test operation should succeed");
7236        let deleted = store
7237            .delete_tag("v1.0")
7238            .expect("test operation should succeed");
7239        assert_eq!(deleted.name, "refs/tags/v1.0");
7240        assert_eq!(deleted.oid, oid);
7241        assert_eq!(
7242            store
7243                .read_ref("refs/tags/v1.0")
7244                .expect("test operation should succeed"),
7245            None
7246        );
7247        assert!(!git_dir.join("packed-refs.lock").exists());
7248        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7249    }
7250
7251    #[test]
7252    fn file_ref_store_packs_loose_refs_and_prunes() {
7253        let git_dir = temp_git_dir();
7254        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7255        let main_oid = ObjectId::from_hex(
7256            ObjectFormat::Sha1,
7257            "ce013625030ba8dba906f756967f9e9ca394464a",
7258        )
7259        .expect("test operation should succeed");
7260        let tag_oid = ObjectId::from_hex(
7261            ObjectFormat::Sha1,
7262            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7263        )
7264        .expect("test operation should succeed");
7265        let mut tx = store.transaction();
7266        tx.update(RefUpdate {
7267            name: "refs/heads/main".into(),
7268            expected: None,
7269            new: RefTarget::Direct(main_oid),
7270            reflog: None,
7271        });
7272        tx.update(RefUpdate {
7273            name: "refs/tags/v1.0".into(),
7274            expected: None,
7275            new: RefTarget::Direct(tag_oid),
7276            reflog: None,
7277        });
7278        tx.commit().expect("test operation should succeed");
7279
7280        let packed = store
7281            .pack_refs(true)
7282            .expect("test operation should succeed");
7283        assert_eq!(packed.len(), 2);
7284        assert_eq!(
7285            store
7286                .read_ref("refs/heads/main")
7287                .expect("test operation should succeed"),
7288            Some(RefTarget::Direct(main_oid))
7289        );
7290        assert_eq!(
7291            store
7292                .read_ref("refs/tags/v1.0")
7293                .expect("test operation should succeed"),
7294            Some(RefTarget::Direct(tag_oid))
7295        );
7296        assert!(!git_dir.join("refs").join("heads").join("main").exists());
7297        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
7298        assert!(git_dir.join("packed-refs").exists());
7299        assert!(!git_dir.join("packed-refs.lock").exists());
7300        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7301    }
7302
7303    #[test]
7304    fn file_ref_store_packs_loose_refs_without_pruning() {
7305        let git_dir = temp_git_dir();
7306        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7307        let oid = ObjectId::from_hex(
7308            ObjectFormat::Sha1,
7309            "ce013625030ba8dba906f756967f9e9ca394464a",
7310        )
7311        .expect("test operation should succeed");
7312        let mut tx = store.transaction();
7313        tx.update(RefUpdate {
7314            name: "refs/heads/main".into(),
7315            expected: None,
7316            new: RefTarget::Direct(oid),
7317            reflog: None,
7318        });
7319        tx.commit().expect("test operation should succeed");
7320
7321        let packed = store
7322            .pack_refs(false)
7323            .expect("test operation should succeed");
7324        assert_eq!(packed.len(), 1);
7325        assert!(git_dir.join("refs").join("heads").join("main").exists());
7326        assert_eq!(
7327            store
7328                .read_ref("refs/heads/main")
7329                .expect("test operation should succeed"),
7330            Some(RefTarget::Direct(oid))
7331        );
7332        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7333    }
7334
7335    #[test]
7336    fn file_ref_store_packs_loose_refs_with_peeled_ids() {
7337        let git_dir = temp_git_dir();
7338        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7339        let tag_oid = ObjectId::from_hex(
7340            ObjectFormat::Sha1,
7341            "ce013625030ba8dba906f756967f9e9ca394464a",
7342        )
7343        .expect("test operation should succeed");
7344        let peeled_oid = ObjectId::from_hex(
7345            ObjectFormat::Sha1,
7346            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7347        )
7348        .expect("test operation should succeed");
7349        let mut tx = store.transaction();
7350        tx.update(RefUpdate {
7351            name: "refs/tags/v1.0".into(),
7352            expected: None,
7353            new: RefTarget::Direct(tag_oid),
7354            reflog: None,
7355        });
7356        tx.commit().expect("test operation should succeed");
7357
7358        let packed = store
7359            .pack_refs_with_peeler(true, |name, oid| {
7360                if name == "refs/tags/v1.0" && oid == &tag_oid {
7361                    Ok(Some(peeled_oid))
7362                } else {
7363                    Ok(None)
7364                }
7365            })
7366            .expect("test operation should succeed");
7367        assert_eq!(packed.len(), 1);
7368        assert_eq!(packed[0].peeled, Some(peeled_oid));
7369        let bytes =
7370            fs::read_to_string(git_dir.join("packed-refs")).expect("test operation should succeed");
7371        assert!(bytes.contains(&format!("^{peeled_oid}\n")));
7372        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
7373        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7374    }
7375
7376    fn reflog_entry(new_oid: &ObjectId, timestamp: i64, message: &str) -> ReflogEntry {
7377        ReflogEntry {
7378            old_oid: zero_oid(new_oid.format()).expect("test operation should succeed"),
7379            new_oid: *new_oid,
7380            committer: format!("Git Rs <sley@example.invalid> {timestamp} +0000").into_bytes(),
7381            message: message.as_bytes().to_vec(),
7382        }
7383    }
7384
7385    #[test]
7386    fn expire_reflog_drops_old_entries_and_keeps_latest() {
7387        let oid_a = ObjectId::from_hex(
7388            ObjectFormat::Sha1,
7389            "ce013625030ba8dba906f756967f9e9ca394464a",
7390        )
7391        .expect("test operation should succeed");
7392        let oid_b = ObjectId::from_hex(
7393            ObjectFormat::Sha1,
7394            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7395        )
7396        .expect("test operation should succeed");
7397        let oid_c = ObjectId::from_hex(
7398            ObjectFormat::Sha1,
7399            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
7400        )
7401        .expect("test operation should succeed");
7402        let entries = vec![
7403            reflog_entry(&oid_a, 10, "oldest"),
7404            reflog_entry(&oid_b, 100, "middle"),
7405            reflog_entry(&oid_c, 20, "latest"),
7406        ];
7407
7408        // Cutoff drops the oldest entry; the most recent entry survives even
7409        // though its timestamp (20) is below the cutoff (50).
7410        let retained =
7411            expire_reflog(&entries, 50, None, |_| true).expect("test operation should succeed");
7412        assert_eq!(retained.len(), 2);
7413        assert_eq!(retained[0].message, b"middle");
7414        assert_eq!(retained[1].message, b"latest");
7415    }
7416
7417    #[test]
7418    fn expire_reflog_applies_stricter_unreachable_cutoff() {
7419        let reachable = ObjectId::from_hex(
7420            ObjectFormat::Sha1,
7421            "ce013625030ba8dba906f756967f9e9ca394464a",
7422        )
7423        .expect("test operation should succeed");
7424        let unreachable = ObjectId::from_hex(
7425            ObjectFormat::Sha1,
7426            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7427        )
7428        .expect("test operation should succeed");
7429        let tip = ObjectId::from_hex(
7430            ObjectFormat::Sha1,
7431            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
7432        )
7433        .expect("test operation should succeed");
7434        // Both candidate entries sit above the lenient cutoff (50) but below the
7435        // stricter unreachable cutoff (150). Only the unreachable one is dropped.
7436        let entries = vec![
7437            reflog_entry(&reachable, 100, "reachable"),
7438            reflog_entry(&unreachable, 100, "unreachable"),
7439            reflog_entry(&tip, 200, "tip"),
7440        ];
7441        let retained = expire_reflog(&entries, 50, Some(150), |oid| {
7442            oid == &reachable || oid == &tip
7443        })
7444        .expect("test operation should succeed");
7445        assert_eq!(retained.len(), 2);
7446        assert_eq!(retained[0].message, b"reachable");
7447        assert_eq!(retained[1].message, b"tip");
7448    }
7449
7450    #[test]
7451    fn expire_reflog_keeps_single_entry_below_cutoff() {
7452        let oid = ObjectId::from_hex(
7453            ObjectFormat::Sha1,
7454            "ce013625030ba8dba906f756967f9e9ca394464a",
7455        )
7456        .expect("test operation should succeed");
7457        let entries = vec![reflog_entry(&oid, 1, "only")];
7458        let retained = expire_reflog(&entries, i64::MAX, Some(i64::MAX), |_| false)
7459            .expect("test operation should succeed");
7460        assert_eq!(retained.len(), 1);
7461        assert_eq!(retained[0].message, b"only");
7462    }
7463
7464    #[test]
7465    fn file_ref_store_expire_reflog_file_rewrites_and_dry_runs() {
7466        let git_dir = temp_git_dir();
7467        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7468        let first = ObjectId::from_hex(
7469            ObjectFormat::Sha1,
7470            "ce013625030ba8dba906f756967f9e9ca394464a",
7471        )
7472        .expect("test operation should succeed");
7473        let second = ObjectId::from_hex(
7474            ObjectFormat::Sha1,
7475            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7476        )
7477        .expect("test operation should succeed");
7478        store
7479            .write_reflog(
7480                "refs/heads/main",
7481                &[
7482                    reflog_entry(&first, 10, "old"),
7483                    reflog_entry(&second, 100, "new"),
7484                ],
7485            )
7486            .expect("test operation should succeed");
7487
7488        // Dry run reports the removal count without touching the file.
7489        let would_remove = store
7490            .expire_reflog_file("refs/heads/main", 50, None, false, |_| true)
7491            .expect("test operation should succeed");
7492        assert_eq!(would_remove, 1);
7493        assert_eq!(
7494            store
7495                .read_reflog("refs/heads/main")
7496                .expect("test operation should succeed")
7497                .len(),
7498            2
7499        );
7500
7501        // Opt-in rewrite drops the stale entry and leaves the latest.
7502        let removed = store
7503            .expire_reflog_file("refs/heads/main", 50, None, true, |_| true)
7504            .expect("test operation should succeed");
7505        assert_eq!(removed, 1);
7506        let log = store
7507            .read_reflog("refs/heads/main")
7508            .expect("test operation should succeed");
7509        assert_eq!(log.len(), 1);
7510        assert_eq!(log[0].new_oid, second);
7511        assert!(
7512            !git_dir
7513                .join("logs")
7514                .join("refs")
7515                .join("heads")
7516                .join("main.lock")
7517                .exists()
7518        );
7519        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7520    }
7521
7522    #[test]
7523    fn file_ref_transaction_commits_all_refs_atomically() {
7524        let git_dir = temp_git_dir();
7525        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7526        let main_oid = ObjectId::from_hex(
7527            ObjectFormat::Sha1,
7528            "ce013625030ba8dba906f756967f9e9ca394464a",
7529        )
7530        .expect("test operation should succeed");
7531        let topic_oid = ObjectId::from_hex(
7532            ObjectFormat::Sha1,
7533            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7534        )
7535        .expect("test operation should succeed");
7536        let tag_oid = ObjectId::from_hex(
7537            ObjectFormat::Sha1,
7538            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
7539        )
7540        .expect("test operation should succeed");
7541        let mut tx = store.transaction();
7542        tx.update(RefUpdate {
7543            name: "refs/heads/main".into(),
7544            expected: None,
7545            new: RefTarget::Direct(main_oid),
7546            reflog: Some(reflog_entry(&main_oid, 0, "create main")),
7547        });
7548        tx.update(RefUpdate {
7549            name: "refs/heads/topic".into(),
7550            expected: None,
7551            new: RefTarget::Direct(topic_oid),
7552            reflog: None,
7553        });
7554        tx.update(RefUpdate {
7555            name: "refs/tags/v1.0".into(),
7556            expected: None,
7557            new: RefTarget::Direct(tag_oid),
7558            reflog: None,
7559        });
7560        tx.commit().expect("test operation should succeed");
7561
7562        assert_eq!(
7563            store
7564                .read_ref("refs/heads/main")
7565                .expect("test operation should succeed"),
7566            Some(RefTarget::Direct(main_oid))
7567        );
7568        assert_eq!(
7569            store
7570                .read_ref("refs/heads/topic")
7571                .expect("test operation should succeed"),
7572            Some(RefTarget::Direct(topic_oid))
7573        );
7574        assert_eq!(
7575            store
7576                .read_ref("refs/tags/v1.0")
7577                .expect("test operation should succeed"),
7578            Some(RefTarget::Direct(tag_oid))
7579        );
7580        let main_log = store
7581            .read_reflog("refs/heads/main")
7582            .expect("test operation should succeed");
7583        assert_eq!(main_log.len(), 1);
7584        assert_eq!(main_log[0].new_oid, main_oid);
7585        // No lock files survive a successful commit.
7586        assert!(
7587            !git_dir
7588                .join("refs")
7589                .join("heads")
7590                .join("main.lock")
7591                .exists()
7592        );
7593        assert!(
7594            !git_dir
7595                .join("refs")
7596                .join("heads")
7597                .join("topic.lock")
7598                .exists()
7599        );
7600        assert!(!git_dir.join("refs").join("tags").join("v1.0.lock").exists());
7601        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7602    }
7603
7604    #[test]
7605    fn file_ref_transaction_rolls_back_all_refs_on_expected_mismatch() {
7606        let git_dir = temp_git_dir();
7607        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7608        let old_topic = ObjectId::from_hex(
7609            ObjectFormat::Sha1,
7610            "ce013625030ba8dba906f756967f9e9ca394464a",
7611        )
7612        .expect("test operation should succeed");
7613        let new_main = ObjectId::from_hex(
7614            ObjectFormat::Sha1,
7615            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7616        )
7617        .expect("test operation should succeed");
7618        let new_tag = ObjectId::from_hex(
7619            ObjectFormat::Sha1,
7620            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
7621        )
7622        .expect("test operation should succeed");
7623        let wrong_expected = ObjectId::from_hex(
7624            ObjectFormat::Sha1,
7625            "0000000000000000000000000000000000000001",
7626        )
7627        .expect("test operation should succeed");
7628
7629        // Seed an existing topic ref so the failing update has a real prior value
7630        // to be compared against (and left untouched).
7631        let mut seed = store.transaction();
7632        seed.update(RefUpdate {
7633            name: "refs/heads/topic".into(),
7634            expected: None,
7635            new: RefTarget::Direct(old_topic.clone()),
7636            reflog: None,
7637        });
7638        seed.commit().expect("test operation should succeed");
7639
7640        let mut tx = store.transaction();
7641        // 1st ref: brand new, would succeed in isolation.
7642        tx.update(RefUpdate {
7643            name: "refs/heads/main".into(),
7644            expected: None,
7645            new: RefTarget::Direct(new_main.clone()),
7646            reflog: Some(reflog_entry(&new_main, 0, "create main")),
7647        });
7648        // 2nd ref: expected value does not match on disk -> whole tx must abort.
7649        tx.update(RefUpdate {
7650            name: "refs/heads/topic".into(),
7651            expected: Some(RefTarget::Direct(wrong_expected)),
7652            new: RefTarget::Direct(new_main.clone()),
7653            reflog: None,
7654        });
7655        // 3rd ref: brand new, must not be written because the tx aborts.
7656        tx.update(RefUpdate {
7657            name: "refs/tags/v1.0".into(),
7658            expected: None,
7659            new: RefTarget::Direct(new_tag),
7660            reflog: None,
7661        });
7662        let result = tx.commit();
7663        assert!(result.is_err());
7664
7665        // Nothing changed: the new refs were never created and the existing one
7666        // keeps its original value.
7667        assert_eq!(
7668            store
7669                .read_ref("refs/heads/main")
7670                .expect("test operation should succeed"),
7671            None
7672        );
7673        assert_eq!(
7674            store
7675                .read_ref("refs/heads/topic")
7676                .expect("test operation should succeed"),
7677            Some(RefTarget::Direct(old_topic))
7678        );
7679        assert_eq!(
7680            store
7681                .read_ref("refs/tags/v1.0")
7682                .expect("test operation should succeed"),
7683            None
7684        );
7685        assert!(
7686            store
7687                .read_reflog("refs/heads/main")
7688                .expect("test operation should succeed")
7689                .is_empty()
7690        );
7691
7692        // All lock files were released.
7693        assert!(
7694            !git_dir
7695                .join("refs")
7696                .join("heads")
7697                .join("main.lock")
7698                .exists()
7699        );
7700        assert!(
7701            !git_dir
7702                .join("refs")
7703                .join("heads")
7704                .join("topic.lock")
7705                .exists()
7706        );
7707        assert!(!git_dir.join("refs").join("tags").join("v1.0.lock").exists());
7708        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7709    }
7710
7711    #[test]
7712    fn file_ref_transaction_mixes_update_and_delete() {
7713        let git_dir = temp_git_dir();
7714        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7715        let old_main = ObjectId::from_hex(
7716            ObjectFormat::Sha1,
7717            "ce013625030ba8dba906f756967f9e9ca394464a",
7718        )
7719        .expect("test operation should succeed");
7720        let new_topic = ObjectId::from_hex(
7721            ObjectFormat::Sha1,
7722            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7723        )
7724        .expect("test operation should succeed");
7725        let mut seed = store.transaction();
7726        seed.update(RefUpdate {
7727            name: "refs/heads/main".into(),
7728            expected: None,
7729            new: RefTarget::Direct(old_main),
7730            reflog: None,
7731        });
7732        seed.commit().expect("test operation should succeed");
7733
7734        let mut tx = store.transaction();
7735        tx.update(RefUpdate {
7736            name: "refs/heads/topic".into(),
7737            expected: None,
7738            new: RefTarget::Direct(new_topic),
7739            reflog: None,
7740        });
7741        tx.delete_with_precondition(
7742            "refs/heads/main",
7743            RefDeletePrecondition::Direct(Some(old_main)),
7744            None,
7745        );
7746        tx.commit().expect("test operation should succeed");
7747
7748        assert_eq!(
7749            store
7750                .read_ref("refs/heads/main")
7751                .expect("test operation should succeed"),
7752            None
7753        );
7754        assert_eq!(
7755            store
7756                .read_ref("refs/heads/topic")
7757                .expect("test operation should succeed"),
7758            Some(RefTarget::Direct(new_topic))
7759        );
7760        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7761    }
7762
7763    #[test]
7764    fn file_ref_transaction_rejects_deleted_descendant_parent_create() {
7765        let git_dir = temp_git_dir();
7766        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7767        let old_conflict = ObjectId::from_hex(
7768            ObjectFormat::Sha1,
7769            "ce013625030ba8dba906f756967f9e9ca394464a",
7770        )
7771        .expect("test operation should succeed");
7772        let new_parent = ObjectId::from_hex(
7773            ObjectFormat::Sha1,
7774            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7775        )
7776        .expect("test operation should succeed");
7777        let mut seed = store.transaction();
7778        seed.update(RefUpdate {
7779            name: "refs/heads/branch/conflict".into(),
7780            expected: None,
7781            new: RefTarget::Direct(old_conflict),
7782            reflog: None,
7783        });
7784        seed.commit().expect("test operation should succeed");
7785
7786        let mut tx = store.transaction();
7787        tx.delete_with_precondition(
7788            "refs/heads/branch/conflict",
7789            RefDeletePrecondition::Direct(Some(old_conflict)),
7790            None,
7791        );
7792        tx.update(RefUpdate {
7793            name: "refs/heads/branch".into(),
7794            expected: None,
7795            new: RefTarget::Direct(new_parent),
7796            reflog: None,
7797        });
7798        let err = tx
7799            .commit()
7800            .expect_err("D/F-conflicting delete plus create must fail");
7801        assert_eq!(
7802            err.to_string(),
7803            "transaction failed: cannot lock ref 'refs/heads/branch': 'refs/heads/branch/conflict' exists; cannot create 'refs/heads/branch'"
7804        );
7805
7806        assert_eq!(
7807            store
7808                .read_ref("refs/heads/branch/conflict")
7809                .expect("test operation should succeed"),
7810            Some(RefTarget::Direct(old_conflict))
7811        );
7812        assert_eq!(
7813            store
7814                .read_ref("refs/heads/branch")
7815                .expect("test operation should succeed"),
7816            None
7817        );
7818        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7819    }
7820
7821    #[test]
7822    fn file_ref_transaction_stale_delete_rolls_back_update() {
7823        let git_dir = temp_git_dir();
7824        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7825        let old_oid = ObjectId::from_hex(
7826            ObjectFormat::Sha1,
7827            "ce013625030ba8dba906f756967f9e9ca394464a",
7828        )
7829        .expect("test operation should succeed");
7830        let new_oid = ObjectId::from_hex(
7831            ObjectFormat::Sha1,
7832            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7833        )
7834        .expect("test operation should succeed");
7835        let mut seed = store.transaction();
7836        for name in ["refs/heads/main", "refs/heads/topic"] {
7837            seed.update(RefUpdate {
7838                name: name.into(),
7839                expected: None,
7840                new: RefTarget::Direct(old_oid),
7841                reflog: None,
7842            });
7843        }
7844        seed.commit().expect("test operation should succeed");
7845
7846        let mut tx = store.transaction();
7847        tx.update(RefUpdate {
7848            name: "refs/heads/topic".into(),
7849            expected: None,
7850            new: RefTarget::Direct(new_oid),
7851            reflog: None,
7852        });
7853        tx.delete_with_precondition(
7854            "refs/heads/main",
7855            RefDeletePrecondition::Direct(Some(new_oid)),
7856            None,
7857        );
7858        let err = tx.commit().expect_err("stale delete must abort");
7859        assert!(err.to_string().contains("expected ref refs/heads/main"));
7860
7861        assert_eq!(
7862            store
7863                .read_ref("refs/heads/main")
7864                .expect("test operation should succeed"),
7865            Some(RefTarget::Direct(old_oid))
7866        );
7867        assert_eq!(
7868            store
7869                .read_ref("refs/heads/topic")
7870                .expect("test operation should succeed"),
7871            Some(RefTarget::Direct(old_oid))
7872        );
7873        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7874    }
7875
7876    #[test]
7877    fn file_ref_transaction_rejects_duplicate_mixed_ref() {
7878        let git_dir = temp_git_dir();
7879        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7880        let oid = ObjectId::from_hex(
7881            ObjectFormat::Sha1,
7882            "ce013625030ba8dba906f756967f9e9ca394464a",
7883        )
7884        .expect("test operation should succeed");
7885        let mut tx = store.transaction();
7886        tx.update(RefUpdate {
7887            name: "refs/heads/main".into(),
7888            expected: None,
7889            new: RefTarget::Direct(oid),
7890            reflog: None,
7891        });
7892        tx.delete_with_precondition("refs/heads/main", RefDeletePrecondition::Any, None);
7893
7894        let err = tx.commit().expect_err("duplicate ref must fail");
7895        assert!(err.to_string().contains("refs/heads/main"));
7896        assert_eq!(
7897            store
7898                .read_ref("refs/heads/main")
7899                .expect("test operation should succeed"),
7900            None
7901        );
7902        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7903    }
7904
7905    #[test]
7906    fn file_ref_transaction_deletes_symbolic_ref_with_immediate_expectation() {
7907        let git_dir = temp_git_dir();
7908        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7909        let oid = ObjectId::from_hex(
7910            ObjectFormat::Sha1,
7911            "ce013625030ba8dba906f756967f9e9ca394464a",
7912        )
7913        .expect("test operation should succeed");
7914        let mut seed = store.transaction();
7915        seed.update(RefUpdate {
7916            name: "refs/heads/main".into(),
7917            expected: None,
7918            new: RefTarget::Direct(oid),
7919            reflog: None,
7920        });
7921        seed.update(RefUpdate {
7922            name: "refs/aliases/main".into(),
7923            expected: None,
7924            new: RefTarget::Symbolic("refs/heads/main".into()),
7925            reflog: None,
7926        });
7927        seed.commit().expect("test operation should succeed");
7928
7929        let mut tx = store.transaction();
7930        tx.delete_with_precondition(
7931            "refs/aliases/main",
7932            RefDeletePrecondition::Immediate(RefTarget::Symbolic("refs/heads/main".into())),
7933            None,
7934        );
7935        tx.commit().expect("test operation should succeed");
7936
7937        assert_eq!(
7938            store
7939                .read_ref("refs/aliases/main")
7940                .expect("test operation should succeed"),
7941            None
7942        );
7943        assert_eq!(
7944            store
7945                .read_ref("refs/heads/main")
7946                .expect("test operation should succeed"),
7947            Some(RefTarget::Direct(oid))
7948        );
7949        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7950    }
7951
7952    #[test]
7953    fn file_ref_transaction_rolls_back_delete_after_late_write_failure() {
7954        let git_dir = temp_git_dir();
7955        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7956        let old_oid = ObjectId::from_hex(
7957            ObjectFormat::Sha1,
7958            "ce013625030ba8dba906f756967f9e9ca394464a",
7959        )
7960        .expect("test operation should succeed");
7961        let new_oid = ObjectId::from_hex(
7962            ObjectFormat::Sha1,
7963            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7964        )
7965        .expect("test operation should succeed");
7966        let mut seed = store.transaction();
7967        for name in ["refs/heads/main", "refs/heads/topic"] {
7968            seed.update(RefUpdate {
7969                name: name.into(),
7970                expected: None,
7971                new: RefTarget::Direct(old_oid),
7972                reflog: None,
7973            });
7974        }
7975        seed.commit().expect("test operation should succeed");
7976
7977        set_fail_loose_commit_action_for_test(Some(1));
7978        let mut tx = store.transaction();
7979        tx.delete_with_precondition(
7980            "refs/heads/main",
7981            RefDeletePrecondition::Direct(Some(old_oid)),
7982            None,
7983        );
7984        tx.update(RefUpdate {
7985            name: "refs/heads/topic".into(),
7986            expected: None,
7987            new: RefTarget::Direct(new_oid),
7988            reflog: None,
7989        });
7990        let err = tx.commit().expect_err("injected failure must abort");
7991        assert!(
7992            err.to_string()
7993                .contains("injected loose ref transaction failure")
7994        );
7995
7996        assert_eq!(
7997            store
7998                .read_ref("refs/heads/main")
7999                .expect("test operation should succeed"),
8000            Some(RefTarget::Direct(old_oid))
8001        );
8002        assert_eq!(
8003            store
8004                .read_ref("refs/heads/topic")
8005                .expect("test operation should succeed"),
8006            Some(RefTarget::Direct(old_oid))
8007        );
8008        assert!(
8009            !git_dir
8010                .join("refs")
8011                .join("heads")
8012                .join("main.lock")
8013                .exists()
8014        );
8015        assert!(
8016            !git_dir
8017                .join("refs")
8018                .join("heads")
8019                .join("topic.lock")
8020                .exists()
8021        );
8022        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8023    }
8024
8025    fn temp_git_dir() -> PathBuf {
8026        let path = std::env::temp_dir().join(format!(
8027            "sley-refs-{}-{}",
8028            std::process::id(),
8029            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
8030        ));
8031        fs::create_dir_all(&path).expect("test operation should succeed");
8032        path
8033    }
8034
8035    fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
8036        Ok(ObjectId::null(format))
8037    }
8038
8039    fn write_reftable_config(git_dir: &Path) {
8040        fs::write(
8041            git_dir.join("config"),
8042            b"[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefStorage = reftable\n",
8043        )
8044        .expect("test operation should succeed");
8045    }
8046
8047    fn write_reftable_stack(
8048        git_dir: &Path,
8049        tables: &[(&str, Vec<sley_formats::ReftableRefRecord>)],
8050    ) {
8051        let reftable_dir = git_dir.join("reftable");
8052        fs::create_dir_all(&reftable_dir).expect("test operation should succeed");
8053        let mut list = String::new();
8054        for (idx, (name, refs)) in tables.iter().enumerate() {
8055            let update_index = (idx + 1) as u64;
8056            let bytes = sley_formats::Reftable::write_ref_only(
8057                ObjectFormat::Sha1,
8058                update_index,
8059                update_index,
8060                refs,
8061            )
8062            .expect("test operation should succeed");
8063            fs::write(reftable_dir.join(name), bytes).expect("test operation should succeed");
8064            list.push_str(name);
8065            list.push('\n');
8066        }
8067        fs::write(reftable_dir.join("tables.list"), list).expect("test operation should succeed");
8068    }
8069}