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