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