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